"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay/zpay(PAYMENT_PROVIDER)""" import logging import random import string import time from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse, JSONResponse from ..config import BASE_URL from ..payment import get_payment_provider, get_payment_provider_by_name from ..payment.zpay import ZPayProvider from ..services import handle_payment_success, processed_orders router = APIRouter(prefix="/nomadvip", tags=["nomadvip"]) def _get_notify_path(provider_name: str) -> str: return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify" if provider_name == "xorpay" else f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify" @router.post("/payh5") async def nomadvip_payh5(item: dict): """nomadvip 专用:创建支付订单,支持 per-request provider 覆盖(微信用 xorpay,PC/H5 用 zpay)""" print("nomadvip/payh5 item:", item) req_provider = (item.get("provider") or "").strip().lower() provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider() total_fee = int(item.get("total_fee", 880)) total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = (item.get("type") or "vip").lower() 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, "VIP会员充值") notify_url = _get_notify_path(provider.name) channel = item.get("channel", "alipay") 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"), ) if provider.name == "xorpay": return { "status": "ok", "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 nomadvip_payh5_redirect(item: dict, request: Request): """nomadvip 专用:ZPAY mapi 接口(xorpay 无需 redirect,直接 payh5 即可)""" req_provider = (item.get("provider") or "").strip().lower() provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider() if not isinstance(provider, ZPayProvider): raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay,xorpay 请直接使用 payh5") logging.info(f"nomadvip payh5/redirect 请求: item={item}") try: total_fee = float(item.get("total_fee", 880)) except (TypeError, ValueError): logging.warning(f"nomadvip payh5/redirect 无效 total_fee: {item.get('total_fee')}") total_fee = 880.0 total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = (item.get("type") or "vip").lower() channel = item.get("channel", "alipay") 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, "VIP会员充值") notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify" if not isinstance(provider, ZPayProvider): raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay") 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": err_msg = result.get("msg", "ZPAY mapi 创建订单失败") logging.error(f"nomadvip payh5/redirect ZPAY 创建订单失败: {err_msg}, result={result}") raise HTTPException(status_code=500, detail=err_msg) 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) logging.error(f"nomadvip payh5/redirect ZPAY 未返回支付链接: device={device}, channel={channel}, result keys={list(result.keys())}") raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接") @router.get("/zpay_order_status") async def nomadvip_zpay_order_status(out_trade_no: str): """nomadvip 专用:查询 ZPAY 订单支付状态(xorpay 无此接口)""" provider = get_payment_provider() if not isinstance(provider, ZPayProvider): raise HTTPException(status_code=400, detail="zpay_order_status 仅支持 zpay") result = provider.query_order_status(out_trade_no, timeout=15) if result.get("error"): logging.warning(f"nomadvip ZPAY 订单查询失败: {result.get('error')}") return result @router.get("/order_status") async def nomadvip_order_status(order_id: str): """nomadvip 通用:查询订单支付状态。zpay 先查 ZPAY 实时,xorpay 或失败时查 PocketBase""" zpay = get_payment_provider_by_name("zpay") if isinstance(zpay, ZPayProvider): result = zpay.query_order_status(order_id, timeout=15) if not result.get("error") and result.get("paid"): return result # zpay 查不到或 xorpay:查 PocketBase(notify 后写入) 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}"'}) return {"paid": len(records.items) > 0} except Exception as e: logging.warning(f"nomadvip PocketBase 查询失败: {e}") return {"paid": False, "error": str(e)} @router.post("/xorpay_notify") async def nomadvip_xorpay_notify(request: Request): """nomadvip 专用:XorPay 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)""" form_data = await request.form() data = {k: v for k, v in form_data.items()} logging.info(f"nomadvip XorPay 异步通知: {data}") order_id = data.get("order_id", "") if order_id in processed_orders: logging.info(f"nomadvip 订单已处理,跳过: {order_id}") return "ok" provider = get_payment_provider_by_name("xorpay") if not provider.verify_notify(data): logging.error(f"nomadvip XorPay 验签失败: {data}") raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": return "ok" pay_price = parsed.get("pay_price", data.get("pay_price", "")) try: handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True) logging.info(f"nomadvip 业务处理完成: order_id={order_id}") except Exception as e: logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True) raise return provider.success_response() @router.get("/zpay_notify") @router.post("/zpay_notify") async def nomadvip_zpay_notify(request: Request): """nomadvip 专用:ZPAY 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)""" provider = get_payment_provider_by_name("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()} logging.info(f"nomadvip ZPAY 异步通知: {data}") order_id = data.get("out_trade_no", "") if order_id in processed_orders: logging.info(f"nomadvip 订单已处理,跳过: {order_id}") return "success" if not provider.verify_notify(data): logging.error(f"nomadvip ZPAY 验签失败: {data}") raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) trade_status = parsed.get("trade_status") or data.get("trade_status", "") if trade_status != "success" and trade_status != "TRADE_SUCCESS": logging.info(f"nomadvip 非成功状态,跳过: trade_status={trade_status}") return "success" pay_price = parsed.get("pay_price") or data.get("money", "") try: handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True) logging.info(f"nomadvip 业务处理完成: order_id={order_id}") except Exception as e: logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True) raise return provider.success_response()