"""wxH5 项目支付接口:/wxh5/*,支持 xorpay/zpay,支付成功后同步 Supabase wxgzh_vip_users""" import logging import random import string import time from datetime import datetime, timedelta 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, resolve_provider, resolve_channel, ) from ..payment.zpay import ZPayProvider from ..services import handle_payment_success, processed_orders router = APIRouter(prefix="/wxh5", tags=["wxh5"]) # 套餐金额(分)与有效期映射 PLAN_MAP = { "month": (2900, 30), # 月卡 29 元,30 天 "year": (19900, 365), # 年卡 199 元,365 天 "lifetime": (39900, 36500), # 终身 399 元,100 年 } def _infer_plan_from_amount(amount_cents: int) -> str: """根据支付金额(分)推断套餐""" if amount_cents >= 39900: return "lifetime" if amount_cents >= 19900: return "year" return "month" def _get_notify_path(provider_name: str) -> str: base = BASE_URL.rstrip("/") return f"{base}/wxh5/xorpay_notify" if provider_name == "xorpay" else f"{base}/wxh5/zpay_notify" def _sync_wxh5_vip_to_supabase(user_id: str, plan: str): """支付成功后同步 VIP 到 Supabase wxgzh_vip_users""" try: from supabase import create_client from ..config import SUPABASE_URL, SUPABASE_SERVICE_KEY url = SUPABASE_URL key = SUPABASE_SERVICE_KEY if not url or not key: logging.warning("wxh5: SUPABASE_URL/SUPABASE_SERVICE_KEY 未配置,跳过 Supabase 同步") return days = PLAN_MAP.get(plan, PLAN_MAP["year"])[1] expire_at = (datetime.utcnow() + timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S+00:00") client = create_client(url, key) # upsert: 存在则更新 expire_at,不存在则插入 client.table("wxgzh_vip_users").upsert( {"user_id": user_id, "expire_at": expire_at}, on_conflict="user_id", ).execute() logging.info(f"wxh5 Supabase VIP 同步成功: user_id={user_id}, plan={plan}") except Exception as e: logging.error(f"wxh5 Supabase 同步失败: {e}", exc_info=True) @router.post("/payh5") async def wxh5_payh5(item: dict, request: Request): """wxH5 专用:创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" 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")) plan = (item.get("plan") or "year").lower() total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0] total_fee = int(item.get("total_fee", total_fee)) 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}" return_url = item.get("return_url", "") if return_url and "order_id=" not in return_url: sep = "&" if "?" in return_url else "?" return_url = f"{return_url}{sep}order_id={order_id}" type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"} name = type_map.get(pay_type, "VIP会员充值") notify_url = _get_notify_path(provider.name) 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=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, "order_id": order_id, } 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", "order_id": order_id, } @router.post("/payh5/redirect") async def wxh5_payh5_redirect(item: dict, request: Request): """wxH5 专用:ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" provider = get_payment_provider_by_name("zpay") channel = resolve_channel(item.get("channel")) plan = (item.get("plan") or "year").lower() total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0] try: total_fee = int(item.get("total_fee", total_fee)) except (TypeError, ValueError): total_fee = PLAN_MAP["year"][0] total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = (item.get("type") or "vip").lower() 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)) order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{random_suffix}" return_url_raw = item.get("return_url", "") if return_url_raw and "order_id=" not in return_url_raw: sep = "&" if "?" in return_url_raw else "?" return_url = f"{return_url_raw}{sep}order_id={order_id}" else: return_url = return_url_raw type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"} name = type_map.get(pay_type, "VIP会员充值") notify_url = f"{BASE_URL.rstrip('/')}/wxh5/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": err_msg = result.get("msg", "ZPAY mapi 创建订单失败") 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} prefer_json = str(item.get("prefer_json", "")).lower() in ("1", "true", "yes") if prefer_json: target = payurl2 if (device == "h5" and channel.lower() in ("wxpay", "wx", "wechat")) else payurl if target: return JSONResponse(status_code=200, content={ "status": "ok", "redirect_url": target, "order_id": order_id, "provider": "zpay", }) 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 未返回支付链接") def _parse_wxh5_user_id(order_id: str) -> str: """从 order_id 解析 user_id,格式: user_id_vip_order_ts""" if "_order_" not in order_id: return "" prefix = order_id.split("_order_")[0] if "_" in prefix: return prefix.rsplit("_", 1)[0] return prefix def _handle_wxh5_payment_success(order_id: str, pay_price: str, provider_name: str): """wxH5 支付成功:PocketBase + Supabase""" user_id = _parse_wxh5_user_id(order_id) if not user_id: return handle_payment_success(order_id, pay_price, provider_name, use_memos=False) amount_cents = int(float(pay_price or 0) * 100) plan = _infer_plan_from_amount(amount_cents) _sync_wxh5_vip_to_supabase(user_id, plan) @router.get("/order_status") async def wxh5_order_status(order_id: str): """wxH5 通用:查询订单支付状态""" 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 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"wxh5 PocketBase 查询失败: {e}") return {"paid": False, "error": str(e)} @router.post("/xorpay_notify") async def wxh5_xorpay_notify(request: Request): """wxH5 XorPay 异步通知""" form_data = await request.form() data = {k: v for k, v in form_data.items()} 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): raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": return "ok" try: _handle_wxh5_payment_success(order_id, parsed.get("pay_price", ""), "wxh5-xorpay") except Exception as e: logging.error(f"wxh5 业务处理异常: {e}", exc_info=True) raise return provider.success_response() @router.get("/zpay_notify") @router.post("/zpay_notify") async def wxh5_zpay_notify(request: Request): """wxH5 ZPAY 异步通知""" 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()} order_id = data.get("out_trade_no", "") if order_id in processed_orders: return "success" if not provider.verify_notify(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 not in ("success", "TRADE_SUCCESS"): return "success" try: _handle_wxh5_payment_success(order_id, parsed.get("pay_price") or data.get("money", ""), "wxh5-zpay") except Exception as e: logging.error(f"wxh5 业务处理异常: {e}", exc_info=True) raise return provider.success_response()