diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 2a3b8d9..c7df1b5 100644 Binary files a/app/__pycache__/config.cpython-312.pyc and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 29c0587..9a67bc2 100755 Binary files a/app/__pycache__/main.cpython-312.pyc and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc index 5ecee05..79309f6 100644 Binary files a/app/payment/__pycache__/__init__.cpython-312.pyc and b/app/payment/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/payment/__pycache__/factory.cpython-312.pyc b/app/payment/__pycache__/factory.cpython-312.pyc index 090f955..8c32903 100644 Binary files a/app/payment/__pycache__/factory.cpython-312.pyc and b/app/payment/__pycache__/factory.cpython-312.pyc differ diff --git a/app/payment/__pycache__/zpay.cpython-312.pyc b/app/payment/__pycache__/zpay.cpython-312.pyc index d8c5257..429e36d 100644 Binary files a/app/payment/__pycache__/zpay.cpython-312.pyc and b/app/payment/__pycache__/zpay.cpython-312.pyc differ diff --git a/app/routers/__pycache__/__init__.cpython-312.pyc b/app/routers/__pycache__/__init__.cpython-312.pyc index fc737c6..be691f3 100644 Binary files a/app/routers/__pycache__/__init__.cpython-312.pyc and b/app/routers/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/routers/__pycache__/_digital_base.cpython-312.pyc b/app/routers/__pycache__/_digital_base.cpython-312.pyc index 25846bc..3cacc7b 100644 Binary files a/app/routers/__pycache__/_digital_base.cpython-312.pyc and b/app/routers/__pycache__/_digital_base.cpython-312.pyc differ diff --git a/app/routers/__pycache__/cnomadcna.cpython-312.pyc b/app/routers/__pycache__/cnomadcna.cpython-312.pyc index 6ca5955..e204276 100644 Binary files a/app/routers/__pycache__/cnomadcna.cpython-312.pyc and b/app/routers/__pycache__/cnomadcna.cpython-312.pyc differ diff --git a/app/routers/__pycache__/digital.cpython-312.pyc b/app/routers/__pycache__/digital.cpython-312.pyc index a5279a2..f9cd874 100644 Binary files a/app/routers/__pycache__/digital.cpython-312.pyc and b/app/routers/__pycache__/digital.cpython-312.pyc differ diff --git a/app/routers/__pycache__/nomadvip.cpython-312.pyc b/app/routers/__pycache__/nomadvip.cpython-312.pyc index 808d15e..b6fbc5f 100644 Binary files a/app/routers/__pycache__/nomadvip.cpython-312.pyc and b/app/routers/__pycache__/nomadvip.cpython-312.pyc differ diff --git a/app/routers/_digital_base.py b/app/routers/_digital_base.py index fff8d83..0a4d27d 100644 --- a/app/routers/_digital_base.py +++ b/app/routers/_digital_base.py @@ -15,11 +15,12 @@ from ..payment.zpay import ZPayProvider from ..services import handle_payment_success, processed_orders -def create_digital_router(prefix: str, project_name: str) -> APIRouter: +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]) @@ -166,7 +167,13 @@ def create_digital_router(prefix: str, project_name: str) -> APIRouter: 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) + 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") @@ -191,7 +198,13 @@ def create_digital_router(prefix: str, project_name: str) -> APIRouter: 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) + 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 diff --git a/app/routers/cnomadcna.py b/app/routers/cnomadcna.py index 5f8034d..f4bbbeb 100644 --- a/app/routers/cnomadcna.py +++ b/app/routers/cnomadcna.py @@ -1,4 +1,4 @@ """cnomadcna 项目支付接口:/cnomadcna/*""" from ._digital_base import create_digital_router -router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna") +router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna", site_id="meetup") diff --git a/app/routers/digital.py b/app/routers/digital.py index 2c71aa7..56de4c0 100644 --- a/app/routers/digital.py +++ b/app/routers/digital.py @@ -1,4 +1,4 @@ """digital 项目支付接口:/digital/*""" from ._digital_base import create_digital_router -router = create_digital_router(prefix="/digital", project_name="digital") +router = create_digital_router(prefix="/digital", project_name="digital", site_id="digital") diff --git a/app/routers/nomadvip.py b/app/routers/nomadvip.py index ba59f81..9b63300 100644 --- a/app/routers/nomadvip.py +++ b/app/routers/nomadvip.py @@ -183,7 +183,7 @@ async def nomadvip_xorpay_notify(request: Request): 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) + handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True, site_id="vip") logging.info(f"nomadvip 业务处理完成: order_id={order_id}") except Exception as e: logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True) @@ -216,7 +216,7 @@ async def nomadvip_zpay_notify(request: Request): 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) + handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True, site_id="vip") logging.info(f"nomadvip 业务处理完成: order_id={order_id}") except Exception as e: logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True) diff --git a/app/routers/wxh5.py b/app/routers/wxh5.py new file mode 100644 index 0000000..2ccf9a0 --- /dev/null +++ b/app/routers/wxh5.py @@ -0,0 +1,270 @@ +"""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 +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): + """wxH5 专用:创建支付订单,支持 per-request provider 覆盖""" + 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() + 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) + 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=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 接口(xorpay 无需 redirect)""" + 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") + 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() + channel = item.get("channel", "alipay") + 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() diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc index d7985b9..05d5e40 100644 Binary files a/app/services/__pycache__/payment.cpython-312.pyc and b/app/services/__pycache__/payment.cpython-312.pyc differ diff --git a/app/services/payment.py b/app/services/payment.py index 83ba4cd..f7a3d3c 100644 --- a/app/services/payment.py +++ b/app/services/payment.py @@ -25,16 +25,18 @@ def handle_payment_success( provider_name: str, *, use_memos: bool = False, + site_id: str | None = None, ): """ 支付成功后的统一业务处理 :param use_memos: 是否创建 Memos 用户,仅 nomadvip 为 True + :param site_id: 站点标识(digital/meetup/vip),用于写入 site_vip """ user_id, pay_type = parse_order_id(order_id) if not user_id: return - print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}") + print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {order_id}") if pay_type == "vip" and use_memos: create_memos_user(user_id) @@ -79,6 +81,32 @@ def handle_payment_success( "order_id": order_id, "amount": amount_cents, }) + + # 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup + effective_site_id = "meetup" if pay_type == "meetup" else site_id + if effective_site_id: + try: + site_vip = pb_client.collection("site_vip") + existing_vip = site_vip.get_list(1, 1, { + "filter": f'user_id = "{user_id}" && site_id = "{effective_site_id}"', + }) + if existing_vip.items: + site_vip.update(existing_vip.items[0].id, { + "order_id": order_id, + "expires_at": "", + }) + logging.info(f"site_vip 更新成功 - user_id: {user_id}, site_id: {effective_site_id}") + else: + site_vip.create({ + "user_id": user_id, + "site_id": effective_site_id, + "order_id": order_id, + "expires_at": "", + }) + logging.info(f"site_vip 创建成功 - user_id: {user_id}, site_id: {effective_site_id}") + except Exception as e: + logging.warning(f"site_vip 写入失败(不影响主流程): {e}") + processed_orders.add(order_id) except Exception as e: print(f"PocketBase 操作失败: {e}")