import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger("payment") from app.dependencies import get_db, get_current_user from app.models.user import User from app.models.payment_order import PaymentOrder from app.models.credit.product import CreditProduct from app.services.credit.upgrade_service import release_upgrade_reservation from app.services.credit.utils import utc_now from app.schemas.payment import RechargeRequest, PaymentOrderOut from app.services.payment import ( create_recharge_order, verify_alipay_callback, process_payment_success_by_order_no, _get_payment_configs, _close_alipay_order, ) router = APIRouter(prefix="/payments", tags=["payments"]) @router.get("/methods") async def get_payment_methods( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Return which payment methods are enabled (from admin config).""" from app.services.payment import _get_payment_configs configs = await _get_payment_configs(db) return { "alipay": configs.get("payment_alipay_enabled", "").lower() == "true", "wechat": configs.get("payment_wechat_enabled", "").lower() == "true", } @router.post("/recharge", response_model=PaymentOrderOut) async def recharge( req: RechargeRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if req.method not in ("wechat", "alipay"): raise HTTPException(status_code=400, detail="不支持的支付方式") # Check if the selected payment method is enabled in admin config from app.services.payment import _get_payment_configs, _is_mock_mode configs = await _get_payment_configs(db) if not _is_mock_mode(configs): enabled_key = f"payment_{req.method}_enabled" if configs.get(enabled_key, "").lower() != "true": raise HTTPException(status_code=400, detail="该支付方式未启用") result = await db.execute( select(CreditProduct).where( CreditProduct.id == req.plan, CreditProduct.is_active.is_(True), ).limit(1) ) product = result.scalar_one_or_none() if not product: raise HTTPException(status_code=400, detail="无效或已下架的积分商品") try: order = await create_recharge_order( db, current_user.id, method=req.method, product_id=product.id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return order @router.post("/wechat/callback") async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)): # 读取微信支付回调数据 body_bytes = await request.body() body_str = body_bytes.decode("utf-8") # 获取配置 from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client db_configs = await _get_payment_configs(db) # 检查 mock 模式 if _is_mock_mode(db_configs): try: import json data = json.loads(body_str) if body_str else {} order_no = data.get("out_trade_no") if order_no: await process_payment_success_by_order_no(db, order_no) logger.info(f"Mock WeChat callback processed: order_no={order_no}") return {"code": "SUCCESS", "message": "OK"} except Exception as e: logger.exception(f"Mock WeChat callback error: {e}") return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功 # 真实模式:使用 wechatpayv3 SDK 工具验证回调并解析数据 try: from wechatpayv3.utils import ( rsa_verify, load_public_key, sha256, b64decode, AESGCM, InvalidTag ) mch_id = db_configs.get("payment_wechat_mch_id", "") api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") public_key = db_configs.get("payment_wechat_public_key", "") if not all([mch_id, api_v3_key]): logger.error("WeChat payment config missing for callback") return {"code": "SUCCESS", "message": "OK"} # 从请求头获取必要信息(不区分大小写) headers = {k.lower(): v for k, v in dict(request.headers).items()} timestamp = headers.get("wechatpay-timestamp", "") nonce = headers.get("wechatpay-nonce", "") signature = headers.get("wechatpay-signature", "") serial_no = headers.get("wechatpay-serial", "") # 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝 if not public_key: logger.error("WeChat platform public key not configured, cannot verify callback signature") return {"code": "FAIL", "message": "Platform public key not configured"} if not serial_no: logger.error("Wechatpay-Serial header missing in callback") return {"code": "FAIL", "message": "Missing Wechatpay-Serial header"} if not timestamp or not nonce or not signature: logger.error("WeChat callback missing required signature headers") return {"code": "FAIL", "message": "Missing signature headers"} # 验证签名:使用平台公钥验证 try: is_verified = rsa_verify( timestamp=timestamp, nonce=nonce, body=body_str, signature=signature, public_key=load_public_key(public_key) ) if not is_verified: logger.warning(f"WeChat callback signature verification failed: serial={serial_no}") return {"code": "FAIL", "message": "Signature verification failed"} except Exception as e: logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}") return {"code": "FAIL", "message": "Signature verification error"} # 解密回调数据:使用 API v3 key # 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382 import json body_data = json.loads(body_str) if body_str else {} resource = body_data.get("resource", {}) if not resource: logger.error("WeChat callback resource not found") raise HTTPException(status_code=400, detail="数据格式错误") # 验证加密算法(官方文档要求固定为 AEAD_AES_256_GCM) algorithm = resource.get("algorithm", "") if algorithm != "AEAD_AES_256_GCM": logger.error(f"WeChat callback unsupported algorithm: {algorithm}") raise HTTPException(status_code=400, detail="不支持的加密算法") ciphertext = resource.get("ciphertext", "") associated_data = resource.get("associated_data", "") nonce_str = resource.get("nonce", "") # 参数验证 if not ciphertext: logger.error("WeChat callback ciphertext is empty") raise HTTPException(status_code=400, detail="密文为空") if not nonce_str: logger.error("WeChat callback nonce is empty") raise HTTPException(status_code=400, detail="随机数为空") # 使用 AES-GCM 解密(符合官方文档规范) # 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382 try: # API v3 key 需要转换为字节串 api_v3_key_bytes = api_v3_key.encode('utf-8') # ciphertext 是 Base64 编码的,需要解码 ciphertext_bytes = b64decode(ciphertext) # nonce 直接使用字符串编码(官方文档方式) nonce_bytes = nonce_str.encode('utf-8') # associated_data 是字符串,直接编码 associated_data_bytes = associated_data.encode('utf-8') if associated_data else b'' aesgcm = AESGCM(api_v3_key_bytes) decrypted_str = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes) except InvalidTag: logger.error("WeChat callback decryption failed: Invalid tag (key or data mismatch)") raise HTTPException(status_code=400, detail="数据解密失败(密钥或数据不匹配)") except Exception as e: logger.error(f"WeChat callback decryption failed: {e}") raise HTTPException(status_code=400, detail="数据解密失败") if not decrypted_str: logger.error("WeChat callback decryption returned empty") raise HTTPException(status_code=400, detail="数据解密失败") decrypted_data = json.loads(decrypted_str) event_type = body_data.get("event_type", "") # 处理支付成功回调 if event_type == "TRANSACTION.SUCCESS": order_no = decrypted_data.get("out_trade_no", "") transaction_id = decrypted_data.get("transaction_id", "") amount_info = decrypted_data.get("amount", {}) total_amount = amount_info.get("total", 0) / 100 # 转换为元 if order_no: try: processed = await process_payment_success_by_order_no( db, order_no, transaction_id, total_amount ) if processed: logger.info( f"WeChat callback processed: order_no={order_no}, " f"transaction_id={transaction_id}, amount={total_amount}" ) else: logger.warning( f"WeChat payment success not fulfilled locally: order_no={order_no}, " f"transaction_id={transaction_id}, amount={total_amount}" ) except Exception as e: # 微信已经通过验签、解密并明确通知支付成功。 # 本地履约异常属于项目内部故障:回滚本地事务,但仍向微信返回 SUCCESS, # 后续由 pending 主动查单重试或日志人工对账处理。 await db.rollback() logger.exception( f"WeChat payment fulfillment error: order_no={order_no}, " f"transaction_id={transaction_id}, error={e}" ) return {"code": "SUCCESS", "message": "OK"} # 处理退款回调 elif event_type == "REFUND.SUCCESS": order_no = decrypted_data.get("out_trade_no", "") refund_id = decrypted_data.get("refund_id", "") refund_status = decrypted_data.get("status", "") if order_no and refund_status == "SUCCESS": # 更新订单状态为已退款 from app.models import PaymentOrder from sqlalchemy import select result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no)) order = result.scalar_one_or_none() if order and order.status == "refunding": order.status = "refunded" order.transaction_id = refund_id await db.commit() logger.info( f"WeChat refund callback processed: order_no={order_no}, " f"refund_id={refund_id}, status={refund_status}" ) return {"code": "SUCCESS", "message": "OK"} except Exception as e: logger.exception(f"WeChat callback processing error: {e}") # 微信支付要求即使处理失败也返回成功,避免重复回调 return {"code": "SUCCESS", "message": "OK"} @router.post("/alipay/callback") async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)): form_data = await request.form() data = dict(form_data) logger.info( f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} " f"data={data}" ) # Verify signature first if not await verify_alipay_callback(data, db): raise HTTPException(status_code=400, detail="签名验证失败") # Check trade_status – only "TRADE_SUCCESS" and "TRADE_FINISHED" mean paid trade_status = data.get("trade_status", "") if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"): logger.info(f"Alipay callback trade_status={trade_status}, ignoring") return "success" order_no = data.get("out_trade_no") trade_no = data.get("trade_no", "") total_amount_str = data.get("total_amount", "") total_amount = float(total_amount_str) if total_amount_str else None if order_no: await process_payment_success_by_order_no(db, order_no, trade_no, total_amount) return "success" @router.get("/orders") async def list_orders( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status_filter: str | None = Query(None, description="按状态筛选: pending/paid/refunded/failed/cancelled"), start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"), end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"), invoice_mode: bool = Query(False, description="开票模式:仅返回已支付订单"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from app.services.payment import _check_and_expire_order from datetime import datetime, timezone, timedelta # 构建筛选条件 conditions = [PaymentOrder.user_id == current_user.id] if status_filter: conditions.append(PaymentOrder.status == status_filter) if invoice_mode: conditions.append(PaymentOrder.status == "paid") if start_date: start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) conditions.append(PaymentOrder.created_at >= start_dt) if end_date: end_dt = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=timezone.utc) conditions.append(PaymentOrder.created_at < end_dt) # 统计总数 count_query = select(func.count(PaymentOrder.id)).where(*conditions) total = (await db.execute(count_query)).scalar() or 0 result = await db.execute( select(PaymentOrder) .where(*conditions) .order_by(PaymentOrder.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) orders = result.scalars().all() for o in orders: await _check_and_expire_order(db, o) # 开票模式:附带订单占用状态 items = [] if invoice_mode: # 收集当前页订单ID order_ids = [o.id for o in orders] # 查询这些订单是否已被占用 from app.models.invoice import Invoice, InvoiceOrder occupied_map: dict[str, str] = {} if order_ids: occ_result = await db.execute( select(InvoiceOrder.order_id, Invoice.invoice_no) .join(Invoice, InvoiceOrder.invoice_id == Invoice.id) .where( InvoiceOrder.order_id.in_(order_ids), Invoice.status.in_(["processing", "success"]), ) ) for row in occ_result.all(): occupied_map[row.order_id] = row.invoice_no for o in orders: item = PaymentOrderOut.model_validate(o) item_dict = item.model_dump() item_dict["is_occupied"] = o.id in occupied_map item_dict["occupied_by"] = occupied_map.get(o.id) items.append(item_dict) else: for o in orders: item = PaymentOrderOut.model_validate(o) items.append(item.model_dump()) return {"items": items, "total": total} @router.get("/orders/{order_no}", response_model=PaymentOrderOut) async def get_order( order_no: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from app.services.payment import _check_and_expire_order result = await db.execute( select(PaymentOrder) .where( PaymentOrder.order_no == order_no, PaymentOrder.user_id == current_user.id, ) .limit(1) ) order = result.scalar_one_or_none() if not order: raise HTTPException(status_code=404, detail="订单不存在") # Auto-expire if needed await _check_and_expire_order(db, order) return order @router.post("/orders/{order_no}/cancel") async def cancel_order( order_no: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Cancel a pending order. Only the order owner can cancel, only if still pending.""" result = await db.execute( select(PaymentOrder).where( PaymentOrder.order_no == order_no, PaymentOrder.user_id == current_user.id, ).with_for_update().limit(1) ) order = result.scalar_one_or_none() if not order: raise HTTPException(status_code=404, detail="订单不存在") if order.status != "pending": raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消") # Alipay/WeChat must confirm remote close before local cancellation. # If close fails or the remote state is uncertain, keep the local order # pending so a later gateway query can still discover a successful payment. db_configs = await _get_payment_configs(db) closed = True if order.payment_method == "alipay": closed = await _close_alipay_order(db, order, db_configs) elif order.payment_method == "wechat": from app.services.payment import _close_wechat_order closed = await _close_wechat_order(db, order, db_configs) if not closed: logger.warning( f"ORDER_CANCEL_CLOSE_PENDING order_no={order_no} " f"user={current_user.id} method={order.payment_method}" ) raise HTTPException( status_code=409, detail="支付渠道暂未确认订单关闭,请稍后重试", ) order.status = "cancelled" if order.upgrade_period_ids_json: await release_upgrade_reservation(db, order=order, released_at=utc_now()) await db.flush() logger.info( f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}" ) return {"ok": True}