""" digital/cnomadcna/nomadlms 共享的支付逻辑 根据 prefix 生成对应 notify_path,业务互不影响 """ import logging import random import string import time from fastapi import APIRouter, Request, HTTPException from ..config import BASE_URL from ..payment import ( get_payment_provider, get_payment_provider_by_name, resolve_provider, resolve_channel, ) from ..payment.zpay import ZPayProvider from ..services import handle_payment_success, processed_orders def _safe_log(level: int, message: str, *args, **kwargs) -> None: try: logging.log(level, message, *args, **kwargs) except Exception: pass def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter: """ 创建 digital/cnomadcna/nomadlms 的支付路由 prefix: 如 "/digital", "/cnomadcna", "/nomadlms" 或 ""(legacy) project_name: 用于日志,如 "digital", "cnomadcna", "nomadlms", "legacy" site_id: 站点标识,用于 site_vip(digital/meetup/vip) """ router = APIRouter(prefix=prefix, tags=[project_name]) def _notify_path(path: str) -> str: base = BASE_URL.rstrip("/") if prefix: return f"{base}{prefix}{path}" return f"{base}{path}" @router.post("/payh5") async def payh5(item: dict, request: Request): """创建 H5 支付订单。默认微信支付,PC/手机用 zpay,微信内用 xorpay,支付宝暂不提供""" req_provider = (item.get("provider") or "").strip().lower() or None device = (item.get("device") or "pc").lower() ua = (request.headers.get("user-agent") or "").strip() provider = resolve_provider(device, req_provider, ua) channel = resolve_channel(item.get("channel")) user_id = item.get("user_id", "unknown") pay_type = (item.get("type") or "unknown").lower() _safe_log( logging.INFO, "[payjsapi] %s/payh5 user_id=%s type=%s provider=%s channel=%s", project_name, user_id, pay_type, provider.name, channel, ) try: total_fee = int(item.get("total_fee", 80)) except (TypeError, ValueError): total_fee = 80 total_fee_yuan = f"{total_fee / 100:.2f}" random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) timestamp = int(time.time()) order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" type_map = { "book": "购买书籍", "meetup": "数字游民社区报名", "video": "视频解锁", "vip": "VIP会员充值", } name = type_map.get(pay_type, "商品支付") notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify" notify_url = _notify_path(notify_path) result = provider.create_order( order_id=order_id, name=name, total_fee_yuan=total_fee_yuan, pay_type=channel, notify_url=notify_url, return_url=item.get("return_url"), client_ip=item.get("client_ip"), ) _safe_log( logging.INFO, "[payjsapi] %s/payh5 created order_id=%s provider=%s notify_url=%s return_url=%s", project_name, order_id, provider.name, notify_url, item.get("return_url") or "", ) if provider.name == "xorpay": return { "status": "ok", "order_id": order_id, "xorpay_params": result.get("params", {}), "xorpay_url": result.get("pay_url", ""), "provider": provider.name, } params = result.get("params", {}) params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""} return { "status": "ok", "params": params_str, "pay_url": result.get("pay_url", ""), "provider": provider.name, "submit_method": "POST", } @router.post("/payh5/redirect") async def payh5_redirect(item: dict, request: Request): """ZPAY mapi 接口,返回 302 或二维码。PC/手机用 zpay,默认微信支付,支付宝暂不提供""" from fastapi.responses import RedirectResponse, JSONResponse provider = get_payment_provider_by_name("zpay") channel = resolve_channel(item.get("channel")) _safe_log( logging.INFO, "[payjsapi] %s/payh5/redirect user_id=%s type=%s device=%s channel=%s", project_name, item.get("user_id", ""), item.get("type", ""), item.get("device", "pc"), channel, ) try: total_fee = float(item.get("total_fee", 80)) except (TypeError, ValueError): total_fee = 80.0 total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = item.get("type", "unknown").lower() return_url = item.get("return_url") device = item.get("device", "pc") client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1") forwarded = request.headers.get("x-forwarded-for") if forwarded: client_ip = forwarded.split(",")[0].strip() random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) timestamp = int(time.time()) order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" type_map = { "book": "购买书籍", "meetup": "数字游民社区报名", "video": "视频解锁", "vip": "VIP会员充值", } name = type_map.get(pay_type, "商品支付") notify_url = _notify_path("/zpay_notify") result = provider.create_order_api( order_id=order_id, name=name, total_fee_yuan=total_fee_yuan, pay_type=channel, notify_url=notify_url, return_url=return_url, client_ip=client_ip, device=device, ) if result.get("status") != "ok": raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败")) _safe_log( logging.INFO, "[payjsapi] %s/payh5/redirect created order_id=%s device=%s channel=%s", project_name, order_id, device, channel, ) payurl = result.get("payurl") payurl2 = result.get("payurl2") img = result.get("img") if device == "pc" and img: return JSONResponse(status_code=200, content={ "status": "ok", "qrcode_mode": True, "order_id": order_id, "img": img, "payurl": payurl, "return_url": return_url, "channel": channel, }) if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"): return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id}) headers = {"X-Pay-Order-Id": order_id} if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2: return RedirectResponse(url=payurl2, status_code=302, headers=headers) if payurl: return RedirectResponse(url=payurl, status_code=302, headers=headers) raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接") @router.get("/zpay_order_status") async def zpay_order_status(out_trade_no: str): """查询 ZPAY 订单支付状态""" provider = get_payment_provider() if not isinstance(provider, ZPayProvider): raise HTTPException(status_code=400, detail="仅 zpay 渠道支持") result = provider.query_order_status(out_trade_no, timeout=15) if result.get("error"): _safe_log(logging.WARNING, "%s ZPAY 订单查询失败: %s", project_name, result.get("error")) return result @router.get("/order_status") async def order_status(order_id: str = "", out_trade_no: str = ""): order_id = (order_id or out_trade_no or "").strip() if not order_id: return {"paid": False, "error": "missing order_id"} try: from ..services.pb import get_pb_client pb = get_pb_client() records = pb.collection("payments").get_list( 1, 1, {"filter": f'order_id = "{order_id}"'} ) if records.items: return {"paid": True, "source": "pocketbase"} except Exception as error: _safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error) try: xorpay = get_payment_provider_by_name("xorpay") xorpay_result = xorpay.query_order_status(order_id, timeout=8) if xorpay_result.get("paid"): return { "paid": True, "source": "xorpay", "status": xorpay_result.get("status", ""), } except Exception as error: _safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error) try: zpay = get_payment_provider_by_name("zpay") if isinstance(zpay, ZPayProvider): zpay_result = zpay.query_order_status(order_id, timeout=8) if zpay_result.get("paid"): return { "paid": True, "source": "zpay", "status": zpay_result.get("status", ""), } except Exception as error: _safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error) return {"paid": False} @router.post("/xorpay_notify") async def xorpay_notify(request: Request): """XorPay 异步通知""" form_data = await request.form() data = {k: v for k, v in form_data.items()} _safe_log(logging.INFO, "%s XorPay 异步通知: %s", project_name, data) order_id = data.get("order_id", "") if order_id in processed_orders: return "ok" provider = get_payment_provider_by_name("xorpay") if not provider.verify_notify(data): _safe_log(logging.ERROR, "%s XorPay 验签失败: %s", project_name, data) raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": return "ok" handle_payment_success( order_id, parsed.get("pay_price", data.get("pay_price", "")), f"{project_name}-xorpay", use_memos=False, site_id=site_id, ) return provider.success_response() @router.get("/zpay_notify") @router.post("/zpay_notify") async def zpay_notify(request: Request): """ZPAY 异步通知""" if request.method == "GET": data = dict(request.query_params) else: form_data = await request.form() data = {k: v for k, v in form_data.items()} _safe_log(logging.INFO, "%s ZPAY 异步通知: %s", project_name, data) order_id = data.get("out_trade_no", "") if order_id in processed_orders: return "success" provider = get_payment_provider_by_name("zpay") if not provider.verify_notify(data): _safe_log(logging.ERROR, "%s ZPAY 验签失败: %s", project_name, data) raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": return "success" handle_payment_success( order_id, parsed.get("pay_price", data.get("money", "")), f"{project_name}-zpay", use_memos=False, site_id=site_id, ) return provider.success_response() return router