diff --git a/.env.example b/.env.example index 803fa98..8f5c641 100644 --- a/.env.example +++ b/.env.example @@ -17,14 +17,14 @@ XORPAY_SECRET=your_secret ZPAY_PID=2025121809351743 ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89 -# PocketBase(check-user、ensure-user、支付回调必需) +# PocketBase(check-user、ensure-user、支付回调、nomadvip/digital/cnomadcna 共用) PB_URL=https://pocketbase.hackrobot.cn -PB_ADMIN_EMAIL=your@email.com -PB_ADMIN_PASSWORD=your_password +PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com +PB_ADMIN_PASSWORD=Xiao4669805@ -# Memos(仅 nomadvip 使用) +# Memos(nomadvip 支付成功后创建账号,与 payjsapi 同源) MEMOS_API=https://qun.hackrobot.cn/api/v1/users -MEMOS_TOKEN=your_token +MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg # MinIO 对象存储(可选) MINIO_ENDPOINT=minioweb.hackrobot.cn diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 296eaf9..20add50 100755 Binary files a/app/__pycache__/main.cpython-312.pyc and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/main.py b/app/main.py index 7f88a6f..a24cdbb 100755 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ 线上部署:BASE_URL=https://api.hackrobot.cn(必须为 ZPAY 可公网访问的地址) """ import logging +import sys import requests from fastapi import FastAPI, Request, HTTPException @@ -46,21 +47,57 @@ app.add_middleware( ) +class _SafeStderrProxy: + def write(self, message: str) -> int: + try: + return sys.stderr.write(message) + except (OSError, ValueError): + return 0 + + def flush(self) -> None: + try: + sys.stderr.flush() + except (OSError, ValueError): + pass + + +def _safe_log(level: int, message: str, *args, **kwargs) -> None: + try: + logging.log(level, message, *args, **kwargs) + except Exception: + pass + + @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): """捕获未处理异常,记录日志后返回 500(HTTPException 交由 FastAPI 默认处理)""" if isinstance(exc, HTTPException): raise exc - logging.error(f"未处理异常: {request.url.path} - {exc}", exc_info=True) + _safe_log(logging.ERROR, "未处理异常: %s - %s", request.url.path, exc, exc_info=True) return JSONResponse(status_code=500, content={"detail": str(exc)}) -# 日志(仅控制台,不写文件,避免 git 本地/线上不一致) +logging.raiseExceptions = False logging.basicConfig( level=logging.INFO, - format="%(asctime)s - %(message)s", - handlers=[logging.StreamHandler()], + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[logging.StreamHandler(_SafeStderrProxy())], + force=True, ) + +def _safe_stderr_write(message: str) -> None: + try: + sys.stderr.write(message) + sys.stderr.flush() + except (OSError, ValueError): + pass + + +def _should_trace_request(path: str) -> bool: + return any( + segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms") + ) + # 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调) def _check_base_url(): url = (BASE_URL or "").strip().lower() @@ -93,6 +130,17 @@ async def health(check_zpay: str = ""): return out +# 请求日志中间件:每个请求都打印到 stderr,确保终端可见 +@app.middleware("http") +async def log_requests(request, call_next): + path = request.url.path + if _should_trace_request(path): + _safe_stderr_write(f"[payjsapi] >>> {request.method} {path}\n") + resp = await call_next(request) + if _should_trace_request(path): + _safe_stderr_write(f"[payjsapi] <<< {request.method} {path} {resp.status_code}\n") + return resp + # 挂载路由 app.include_router(nomadvip_router) app.include_router(digital_router) @@ -110,7 +158,7 @@ async def root(): "status": "ok", "service": "payjsapi", "routes": { - "nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify", + "nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/check_vip, /nomadvip/check_vip_by_email, /nomadvip/order_status, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify", "digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify", "cnomadcna": "/cnomadcna/payh5, ...", "nomadlms": "/nomadlms/payh5, ...", diff --git a/app/payment/__init__.py b/app/payment/__init__.py index 54ab8fa..6b6ab1e 100644 --- a/app/payment/__init__.py +++ b/app/payment/__init__.py @@ -5,7 +5,13 @@ from .base import PaymentProvider from .xorpay import XorPayProvider from .zpay import ZPayProvider -from .factory import get_payment_provider, get_payment_provider_by_name, reset_provider +from .factory import ( + get_payment_provider, + get_payment_provider_by_name, + reset_provider, + resolve_provider, + resolve_channel, +) __all__ = [ "PaymentProvider", @@ -14,4 +20,6 @@ __all__ = [ "get_payment_provider", "get_payment_provider_by_name", "reset_provider", + "resolve_provider", + "resolve_channel", ] diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc index 79309f6..751e5ff 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 8c32903..2430877 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__/xorpay.cpython-312.pyc b/app/payment/__pycache__/xorpay.cpython-312.pyc index 1421d14..bb4b95a 100644 Binary files a/app/payment/__pycache__/xorpay.cpython-312.pyc and b/app/payment/__pycache__/xorpay.cpython-312.pyc differ diff --git a/app/payment/factory.py b/app/payment/factory.py index 06fd5b4..40ca263 100644 --- a/app/payment/factory.py +++ b/app/payment/factory.py @@ -49,3 +49,31 @@ def reset_provider(): """重置 Provider(用于测试或切换渠道)""" global _provider_instance _provider_instance = None + + +def resolve_provider( + device: str | None = None, + req_provider: str | None = None, + user_agent: str | None = None, +) -> PaymentProvider: + """ + 根据设备解析支付渠道: + - 微信内(device=wechat 或 UA 含 MicroMessenger) → xorpay + - PC/手机浏览器 → zpay + 显式传入的 provider 优先。 + """ + req = (req_provider or "").strip().lower() + if req in ("xorpay", "zpay"): + return get_payment_provider_by_name(req) + dev = (device or "pc").lower() + if dev == "wechat": + return get_payment_provider_by_name("xorpay") + ua = (user_agent or "").lower() + if "micromessenger" in ua: + return get_payment_provider_by_name("xorpay") + return get_payment_provider_by_name("zpay") + + +def resolve_channel(item_channel: str | None) -> str: + """支付方式固定为微信支付,支付宝暂不提供""" + return "wxpay" diff --git a/app/payment/xorpay.py b/app/payment/xorpay.py index 5414b5c..99d2941 100644 --- a/app/payment/xorpay.py +++ b/app/payment/xorpay.py @@ -5,6 +5,8 @@ XorPay 支付渠道实现 import hashlib from typing import Any, Dict, Optional +import requests + from .base import PaymentProvider @@ -24,6 +26,10 @@ class XorPayProvider(PaymentProvider): raw = name + pay_type + price + order_id + notify_url + self.secret return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + def _query2_sign(self, order_id: str) -> str: + raw = order_id + self.secret + return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + def create_order( self, *, @@ -80,3 +86,23 @@ class XorPayProvider(PaymentProvider): def success_response(self) -> str: return "ok" + + def query_order_status(self, order_id: str, timeout: int = 10) -> Dict[str, Any]: + url = f"https://xorpay.com/api/query2/{self.aid}" + params = { + "order_id": order_id, + "sign": self._query2_sign(order_id), + } + try: + response = requests.get(url, params=params, timeout=timeout) + response.raise_for_status() + data = response.json() or {} + status = str(data.get("status", "")).strip().lower() + return { + "paid": status in ("payed", "success"), + "status": status, + "pay_price": data.get("pay_price") or data.get("price") or "", + "raw": data, + } + except Exception as error: + return {"paid": False, "error": str(error)} diff --git a/app/routers/__pycache__/_digital_base.cpython-312.pyc b/app/routers/__pycache__/_digital_base.cpython-312.pyc index 3cacc7b..5ab043f 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__/common.cpython-312.pyc b/app/routers/__pycache__/common.cpython-312.pyc index 9752328..f6ebe17 100644 Binary files a/app/routers/__pycache__/common.cpython-312.pyc and b/app/routers/__pycache__/common.cpython-312.pyc differ diff --git a/app/routers/__pycache__/nomadvip.cpython-312.pyc b/app/routers/__pycache__/nomadvip.cpython-312.pyc index b6fbc5f..d5bd854 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 0a4d27d..7361955 100644 --- a/app/routers/_digital_base.py +++ b/app/routers/_digital_base.py @@ -10,11 +10,23 @@ import time from fastapi import APIRouter, Request, HTTPException from ..config import BASE_URL -from ..payment import get_payment_provider +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 的支付路由 @@ -31,12 +43,29 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = return f"{base}{path}" @router.post("/payh5") - async def payh5(item: dict): - """创建 H5 支付订单""" - print(f"{project_name}/payh5 item:", item) - total_fee_yuan = f"{item['total_fee'] / 100:.2f}" + 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", "unknown").lower() + 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}" @@ -47,10 +76,8 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = "vip": "VIP会员充值", } name = type_map.get(pay_type, "商品支付") - provider = get_payment_provider() notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify" notify_url = _notify_path(notify_path) - channel = item.get("channel", "alipay") result = provider.create_order( order_id=order_id, name=name, @@ -60,9 +87,19 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = 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, @@ -79,17 +116,27 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = @router.post("/payh5/redirect") async def payh5_redirect(item: dict, request: Request): - """ZPAY mapi 接口,返回 302 或二维码""" + """ZPAY mapi 接口,返回 302 或二维码。PC/手机用 zpay,默认微信支付,支付宝暂不提供""" from fastapi.responses import RedirectResponse, JSONResponse - provider = get_payment_provider() - if provider.name != "zpay": - raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道") - total_fee = float(item.get("total_fee", 80)) + 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() - 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") @@ -107,8 +154,6 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = } name = type_map.get(pay_type, "商品支付") notify_url = _notify_path("/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, @@ -121,6 +166,14 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = ) 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") @@ -146,23 +199,66 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = raise HTTPException(status_code=400, detail="仅 zpay 渠道支持") result = provider.query_order_status(out_trade_no, timeout=15) if result.get("error"): - logging.warning(f"{project_name} ZPAY 订单查询失败: {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()} - logging.info(f"{project_name} XorPay 异步通知: {data}") + _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() - if provider.name != "xorpay": - raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay") + provider = get_payment_provider_by_name("xorpay") if not provider.verify_notify(data): - logging.error(f"{project_name} XorPay 验签失败: {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": @@ -185,15 +281,13 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = else: form_data = await request.form() data = {k: v for k, v in form_data.items()} - logging.info(f"{project_name} ZPAY 异步通知: {data}") + _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() - if provider.name != "zpay": - raise HTTPException(status_code=400, detail="zpay_notify only for zpay") + provider = get_payment_provider_by_name("zpay") if not provider.verify_notify(data): - logging.error(f"{project_name} ZPAY 验签失败: {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": diff --git a/app/routers/common.py b/app/routers/common.py index a648708..40391bd 100644 --- a/app/routers/common.py +++ b/app/routers/common.py @@ -15,7 +15,7 @@ router = APIRouter(tags=["common"]) @router.post("/submit_meetup_application") async def submit_meetup_application(item: dict): """数字游民社区报名表单""" - print("submit_meetup_application item:", item) + logging.info("submit_meetup_application item: %s", item) user_id = item.get("user_id") if not user_id: raise HTTPException(status_code=400, detail="missing user_id") @@ -41,7 +41,7 @@ async def submit_meetup_application(item: dict): logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}") return {"status": "ok", "record_id": record.id} except Exception as e: - print(f"meetup 申请提交失败: {e}") + logging.error("meetup 申请提交失败: %s", e) logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}") raise HTTPException(status_code=500, detail="submit failed") @@ -61,5 +61,5 @@ async def create_user(item: CreateUserRequest): username = item.username or f"user{timenew}" password = item.password or "12345678" result = sdk.create_user_by_username(username=username, password=password) - print("memos_result-", result) + logging.info("memos_result- %s", result) return result diff --git a/app/routers/meetup.py b/app/routers/meetup.py index dd74be2..6bd2aba 100644 --- a/app/routers/meetup.py +++ b/app/routers/meetup.py @@ -14,9 +14,18 @@ from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD router = APIRouter(prefix="/api/meetup", tags=["meetup"]) DEFAULT_PASSWORD = "12345678" # PocketBase 默认要求至少 8 位 +# Admin token 缓存,避免每次请求都向 PocketBase 认证(PB 远程时易超时) +_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None +_TOKEN_CACHE_TTL = 300 # 5 分钟 + def _get_admin_token() -> tuple[str | None, str | None]: - """返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因""" + """返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因。带 5 分钟缓存。""" + global _ADMIN_TOKEN_CACHE + now = time.time() + if _ADMIN_TOKEN_CACHE and _ADMIN_TOKEN_CACHE[1] > now: + return _ADMIN_TOKEN_CACHE[0], None + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置" base = (PB_URL or "").rstrip("/") @@ -26,11 +35,14 @@ def _get_admin_token() -> tuple[str | None, str | None]: r = requests.post( f"{base}{path}", json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, - timeout=10, + timeout=4, ) if r.status_code == 200: data = r.json() - return data.get("token"), None + token = data.get("token") + if token: + _ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL) + return token, None if r.status_code == 404: continue # 尝试下一个路径 try: @@ -56,9 +68,10 @@ class EnsureUserRequest(BaseModel): password: str | None = None -def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool: - """检查用户是否有有效 site_vip(未过期)""" - token, _ = _get_admin_token() +def _check_site_vip(user_id: str, site_id: str = "meetup", token: str | None = None) -> bool: + """检查用户是否有有效 site_vip(未过期)。可传入 token 避免重复认证。""" + if not token: + token, _ = _get_admin_token() if not token: return False try: @@ -72,7 +85,7 @@ def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool: "sort": "-expires_at", }, headers={"Authorization": f"Bearer {token}"}, - timeout=10, + timeout=4, ) if r.status_code != 200: return False @@ -115,7 +128,7 @@ async def check_user(item: CheckUserRequest): f"{PB_URL}/api/collections/users/records", params={"filter": filter_val, "perPage": 1}, headers={"Authorization": f"Bearer {token}"}, - timeout=10, + timeout=4, ) if r.status_code != 200: raise HTTPException(status_code=500, detail="查询失败") @@ -123,7 +136,7 @@ async def check_user(item: CheckUserRequest): items = data.get("items") or [] exists = len(items) > 0 user_id = items[0].get("id") if items else None - vip = _check_site_vip(user_id, "meetup") if user_id else False + vip = _check_site_vip(user_id, "meetup", token) if user_id else False return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id} except HTTPException: raise diff --git a/app/routers/nomadvip.py b/app/routers/nomadvip.py index 9b63300..406b3d8 100644 --- a/app/routers/nomadvip.py +++ b/app/routers/nomadvip.py @@ -1,14 +1,21 @@ -"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay/zpay(PAYMENT_PROVIDER)""" +"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay / zpay。""" + import logging import random import string import time -from fastapi import APIRouter, Request, HTTPException -from fastapi.responses import RedirectResponse, JSONResponse +import requests +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse -from ..config import BASE_URL -from ..payment import get_payment_provider, get_payment_provider_by_name +from ..config import BASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_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 @@ -16,15 +23,27 @@ 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" + if provider_name == "xorpay": + return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify" + return 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() +async def nomadvip_payh5(item: dict, request: Request): + """创建支付订单。微信内用 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")) + logging.info( + "[payjsapi] payh5 user_id=%s type=%s provider=%s channel=%s", + item.get("user_id", ""), + item.get("type", ""), + provider.name, + channel, + ) + total_fee = int(item.get("total_fee", 880)) total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") @@ -32,6 +51,7 @@ async def nomadvip_payh5(item: dict): 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": "数字游民社区报名", @@ -40,7 +60,6 @@ async def nomadvip_payh5(item: dict): } 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, @@ -50,15 +69,27 @@ async def nomadvip_payh5(item: dict): return_url=item.get("return_url"), client_ip=item.get("client_ip"), ) + logging.info( + "[payjsapi] payh5 created order_id=%s provider=%s notify_url=%s return_url=%s", + 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() != ""} + 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, @@ -70,35 +101,49 @@ async def nomadvip_payh5(item: dict): @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}") + """ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" + provider = get_payment_provider_by_name("zpay") + channel = resolve_channel(item.get("channel")) + + logging.info("nomadvip payh5/redirect request: item=%s", 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')}") + logging.warning( + "nomadvip payh5/redirect invalid total_fee=%s", 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") + 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会员充值"} + + # 追加 order_id,供 paid 页在 cookie 丢失时恢复 user_id。 + if return_url and "order_id=" not in return_url and "out_trade_no=" 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 = 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, @@ -111,82 +156,684 @@ async def nomadvip_payh5_redirect(item: dict, request: Request): ) if result.get("status") != "ok": err_msg = result.get("msg", "ZPAY mapi 创建订单失败") - logging.error(f"nomadvip payh5/redirect ZPAY 创建订单失败: {err_msg}, result={result}") + logging.error( + "nomadvip payh5/redirect ZPAY create failed: %s result=%s", + err_msg, + 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, - }) + 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}) + 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())}") + + logging.error( + "nomadvip payh5/redirect ZPAY missing pay url device=%s channel=%s keys=%s", + device, + channel, + 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 无此接口)""" + """查询 ZPAY 订单支付状态。""" 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')}") + logging.warning( + "nomadvip ZPAY order status failed: %s", 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 后写入) +def _get_linked_email_for_user(pb, user_id: str) -> str | None: + """按 user_id 查询 vip_email_link 关联邮箱。""" + try: + link_rec = pb.collection("vip_email_link").get_list( + 1, 1, {"filter": f'anonymous_user_id = "{user_id}"'} + ) + if link_rec.items: + return link_rec.items[0].get("email") or "" + + if user_id and user_id.startswith("user") and user_id[4:].isdigit(): + link_rec = pb.collection("vip_email_link").get_list( + 1, 1, {"filter": f'user_id = "{user_id}"'} + ) + if link_rec.items: + return link_rec.items[0].get("email") or "" + except Exception: + pass + return None + + +def _uid_has_vip(pb, uid: str, site_id: str = "vip") -> bool: + if not uid: + return False + + sv = pb.collection("site_vip").get_list( + 1, 1, {"filter": f'user_id = "{uid}" && site_id = "{site_id}"'} + ) + if sv.items: + rec = sv.items[0] + expires = rec.get("expires_at") or "" + if not expires: + return True + + from datetime import datetime + + try: + exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00")) + now = datetime.now(exp_dt.tzinfo) if exp_dt.tzinfo else datetime.now() + return exp_dt.timestamp() >= now.timestamp() + except Exception: + return True + + pm = pb.collection("payments").get_list( + 1, + 1, + {"filter": f'user_id = "{uid}" && type = "vip"', "sort": "-created"}, + ) + return bool(pm.items) + + +@router.get("/check_vip") +async def nomadvip_check_vip(user_id: str = ""): + """Return vip status for a user_id.""" + user_id = (user_id or "").strip() + logging.info("[payjsapi] check_vip user_id=%s", user_id or "(empty)") + if not user_id: + return {"vip": False, "error": "missing user_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}"'}) - return {"paid": len(records.items) > 0} - except Exception as e: - logging.warning(f"nomadvip PocketBase 查询失败: {e}") - return {"paid": False, "error": str(e)} + if not _uid_has_vip(pb, user_id): + logging.info("[payjsapi] check_vip miss user_id=%s vip=False", user_id) + return {"vip": False} + + email = _get_linked_email_for_user(pb, user_id) + if not email: + admin_token = _get_pb_admin_token() + if admin_token and user_id.startswith("user") and user_id[4:].isdigit(): + linked_member = _pb_find_linked_member_from_order(admin_token, user_id) + if linked_member: + linked_user_id, linked_email = linked_member + email = linked_email + _pb_upsert_email_link(admin_token, linked_email, linked_user_id, user_id) + + logging.info( + "[payjsapi] check_vip hit user_id=%s vip=True email=%s", + user_id, + email or "(none)", + ) + return {"vip": True, "email": email} + except Exception as error: + logging.warning("[payjsapi] check_vip failed: %s", error) + return {"vip": False, "error": str(error)} + +def _get_pb_admin_token() -> str | None: + """获取 PocketBase 管理员 token。""" + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: + return None + + base = (PB_URL or "").rstrip("/") + for path in [ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ]: + try: + response = requests.post( + f"{base}{path}", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + timeout=10, + ) + if response.status_code == 200: + return response.json().get("token") + except Exception: + pass + return None + + +def _pb_escape(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"') + + +def _pb_list_email_links(admin_token: str, email: str) -> list[dict]: + base = (PB_URL or "").rstrip("/") + headers = {"Authorization": f"Bearer {admin_token}"} + + for candidate in [email, email.lower()]: + safe_email = _pb_escape(candidate) + response = requests.get( + f"{base}/api/collections/vip_email_link/records", + params={"filter": f'email = "{safe_email}"', "perPage": 1}, + headers=headers, + timeout=10, + ) + if response.status_code != 200: + continue + + items = (response.json() or {}).get("items") or [] + if items: + return items + + return [] + + +def _pb_upsert_email_link( + admin_token: str, + email: str, + pb_user_id: str, + anonymous_user_id: str | None = None, +): + base = (PB_URL or "").rstrip("/") + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + items = _pb_list_email_links(admin_token, email) + payload = {"email": email.lower(), "user_id": pb_user_id} + if anonymous_user_id: + payload["anonymous_user_id"] = anonymous_user_id + + if items: + record_id = items[0].get("id") + if record_id: + requests.patch( + f"{base}/api/collections/vip_email_link/records/{record_id}", + json=payload, + headers=headers, + timeout=10, + ) + return + + requests.post( + f"{base}/api/collections/vip_email_link/records", + json=payload, + headers=headers, + timeout=10, + ) + + +def _pb_move_site_vip( + admin_token: str, + from_user_id: str, + to_user_id: str, + site_id: str = "vip", +): + if not from_user_id or not to_user_id or from_user_id == to_user_id: + return + + base = (PB_URL or "").rstrip("/") + safe_from_user_id = _pb_escape(from_user_id) + safe_site_id = _pb_escape(site_id) + response = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return + + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + items = (response.json() or {}).get("items") or [] + for item in items: + record_id = item.get("id") + if not record_id: + continue + requests.patch( + f"{base}/api/collections/site_vip/records/{record_id}", + json={"user_id": to_user_id}, + headers=headers, + timeout=10, + ) + + +def _pb_latest_vip_payment(admin_token: str, user_id: str) -> dict | None: + base = (PB_URL or "").rstrip("/") + safe_user_id = _pb_escape(user_id) + response = requests.get( + f"{base}/api/collections/payments/records", + params={ + "filter": f'user_id = "{safe_user_id}" && type = "vip"', + "perPage": 1, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return None + + items = (response.json() or {}).get("items") or [] + return items[0] if items else None + + +def _pb_list_site_vip_by_order( + admin_token: str, + order_id: str, + site_id: str = "vip", +) -> list[dict]: + base = (PB_URL or "").rstrip("/") + safe_order_id = _pb_escape(order_id) + safe_site_id = _pb_escape(site_id) + response = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'order_id = "{safe_order_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return [] + + return (response.json() or {}).get("items") or [] + + +def _pb_get_user_email(admin_token: str, user_id: str) -> str | None: + base = (PB_URL or "").rstrip("/") + response = requests.get( + f"{base}/api/collections/users/records/{user_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return None + + email = (response.json() or {}).get("email") or "" + return email.strip().lower() or None + + +def _pb_find_linked_member_from_order( + admin_token: str, + anonymous_user_id: str, +) -> tuple[str, str] | None: + base = (PB_URL or "").rstrip("/") + safe_user_id = _pb_escape(anonymous_user_id) + response = requests.get( + f"{base}/api/collections/payments/records", + params={ + "filter": f'user_id = "{safe_user_id}" && type = "vip"', + "perPage": 20, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return None + + items = (response.json() or {}).get("items") or [] + seen_orders = set() + for item in items: + order_id = (item.get("order_id") or "").strip() + if not order_id or order_id in seen_orders: + continue + seen_orders.add(order_id) + + for site_vip in _pb_list_site_vip_by_order(admin_token, order_id): + linked_user_id = (site_vip.get("user_id") or "").strip() + if ( + not linked_user_id + or linked_user_id == anonymous_user_id + or (linked_user_id.startswith("user") and linked_user_id[4:].isdigit()) + ): + continue + + email = _pb_get_user_email(admin_token, linked_user_id) + if email: + return linked_user_id, email + + return None + + +def _pb_ensure_site_vip( + admin_token: str, + from_user_id: str, + to_user_id: str, + site_id: str = "vip", +): + if not from_user_id or not to_user_id or from_user_id == to_user_id: + return + + base = (PB_URL or "").rstrip("/") + safe_from_user_id = _pb_escape(from_user_id) + safe_to_user_id = _pb_escape(to_user_id) + safe_site_id = _pb_escape(site_id) + source_records = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + to_records = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{safe_to_user_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + payment = _pb_latest_vip_payment(admin_token, from_user_id) + order_id = (payment or {}).get("order_id") or "" + order_records = ( + _pb_list_site_vip_by_order(admin_token, order_id, site_id=site_id) + if order_id + else [] + ) + source_items = (source_records.json() or {}).get("items") or [] if source_records.status_code == 200 else [] + target_items = (to_records.json() or {}).get("items") or [] if to_records.status_code == 200 else [] + canonical = (target_items or source_items or order_records or [None])[0] + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + + if canonical and canonical.get("id"): + patch_body = {"user_id": to_user_id, "site_id": site_id} + next_order_id = order_id or (canonical.get("order_id") or "").strip() + if next_order_id: + patch_body["order_id"] = next_order_id + requests.patch( + f"{base}/api/collections/site_vip/records/{canonical['id']}", + json=patch_body, + headers=headers, + timeout=10, + ) + + duplicate_ids = set() + for record in [*target_items, *source_items, *order_records]: + record_id = record.get("id") + if record_id and record_id != canonical["id"]: + duplicate_ids.add(record_id) + for record_id in duplicate_ids: + requests.delete( + f"{base}/api/collections/site_vip/records/{record_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + return + + if not order_id: + return + + requests.post( + f"{base}/api/collections/site_vip/records", + json={"user_id": to_user_id, "site_id": site_id, "order_id": order_id}, + headers=headers, + timeout=10, + ) + + +@router.post("/check_vip_by_email") +async def nomadvip_check_vip_by_email(item: dict): + """Recover VIP by email/password, optionally with anonymous user_id.""" + email = (item.get("email") or "").strip().lower() + password = (item.get("password") or "").strip() + anonymous_user_id = ( + item.get("user_id") or item.get("anonymousUserId") or "" + ).strip() + logging.info( + "[payjsapi] check_vip_by_email email=%s user_id=%s", + email or "(empty)", + anonymous_user_id or "(empty)", + ) + + if not email or not password: + return {"vip": False, "error": "missing email or password"} + + base = (PB_URL or "").rstrip("/") + login_res = requests.post( + f"{base}/api/collections/users/auth-with-password", + json={"identity": email, "password": password}, + timeout=10, + ) + if login_res.status_code != 200: + try: + error = login_res.json() or {} + except Exception: + error = {} + logging.info( + "[payjsapi] check_vip_by_email login failed status=%s message=%s", + login_res.status_code, + error.get("message", ""), + ) + return { + "vip": False, + "error": error.get("message") or "invalid email or password", + } + + login_data = login_res.json() or {} + token = login_data.get("token") + record = login_data.get("record") or {} + pb_user_id = (record.get("id") or "").strip() + if not token or not pb_user_id: + return {"vip": False, "error": "login failed"} + + admin_token = _get_pb_admin_token() + if not admin_token: + return {"vip": False, "error": "PocketBase admin auth failed"} + + try: + from ..services.pb import get_pb_client + + pb = get_pb_client() + matched_user_id = None + recovered_anonymous_user_id = ( + anonymous_user_id + if anonymous_user_id.startswith("user") and anonymous_user_id[4:].isdigit() + else None + ) + recovery_source = None + + if recovered_anonymous_user_id and _uid_has_vip(pb, recovered_anonymous_user_id): + matched_user_id = recovered_anonymous_user_id + recovery_source = "request_user_id" + else: + items = _pb_list_email_links(admin_token, email) + logging.info( + "[payjsapi] check_vip_by_email vip_email_link email=%s count=%s", + email, + len(items), + ) + for link_record in items: + linked_ids = [] + linked_pb_user_id = (link_record.get("user_id") or "").strip() + linked_anonymous_user_id = ( + link_record.get("anonymous_user_id") or "" + ).strip() + if linked_pb_user_id: + linked_ids.append(linked_pb_user_id) + if linked_anonymous_user_id and linked_anonymous_user_id not in linked_ids: + linked_ids.append(linked_anonymous_user_id) + + for linked_uid in linked_ids: + if not _uid_has_vip(pb, linked_uid): + continue + matched_user_id = linked_uid + if linked_anonymous_user_id.startswith("user") and linked_anonymous_user_id[4:].isdigit(): + recovered_anonymous_user_id = linked_anonymous_user_id + elif linked_uid.startswith("user") and linked_uid[4:].isdigit(): + recovered_anonymous_user_id = linked_uid + recovery_source = "vip_email_link" + break + + if matched_user_id: + break + + if not matched_user_id and _uid_has_vip(pb, pb_user_id): + matched_user_id = pb_user_id + recovered_anonymous_user_id = None + recovery_source = "pb_user_id" + + if not matched_user_id: + logging.info("[payjsapi] check_vip_by_email no vip match for email=%s", email) + return { + "vip": False, + "error": "no linked vip record found; provide the paid user_id if this is a historical anonymous order", + } + + if recovered_anonymous_user_id: + _pb_ensure_site_vip(admin_token, recovered_anonymous_user_id, pb_user_id) + _pb_upsert_email_link( + admin_token, + email, + pb_user_id, + recovered_anonymous_user_id, + ) + else: + _pb_upsert_email_link(admin_token, email, pb_user_id) + + logging.info( + "[payjsapi] check_vip_by_email recovered by %s matched_user_id=%s effectiveUserId=%s anonymousUserId=%s", + recovery_source or "unknown", + matched_user_id, + pb_user_id, + recovered_anonymous_user_id or "(none)", + ) + return { + "vip": True, + "token": token, + "record": record, + "effectiveUserId": pb_user_id, + "anonymousUserId": recovered_anonymous_user_id, + } + except Exception as error: + logging.warning("[payjsapi] check_vip_by_email failed: %s", error) + return {"vip": False, "error": str(error)} + +@router.get("/order_status") +async def nomadvip_order_status(order_id: str = "", out_trade_no: str = ""): + """查询订单支付状态,优先查 ZPAY,失败时回退 PocketBase。""" + order_id = (order_id or out_trade_no or "").strip() + logging.info("[payjsapi] order_status order_id=%s", order_id or "(空)") + if not order_id: + return {"paid": False, "error": "缺少 order_id 或 out_trade_no"} + + 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}"'} + ) + paid = len(records.items) > 0 + logging.info("[payjsapi] order_status PocketBase paid=%s", paid) + if paid: + return {"paid": True, "source": "pocketbase"} + except Exception as error: + logging.warning("[payjsapi] order_status PocketBase failed: %s", error) + + xorpay = get_payment_provider_by_name("xorpay") + xorpay_result = xorpay.query_order_status(order_id, timeout=8) + if xorpay_result.get("error"): + logging.warning( + "[payjsapi] order_status XorPay query failed order_id=%s error=%s", + order_id, + xorpay_result.get("error"), + ) + else: + logging.info( + "[payjsapi] order_status XorPay status=%s paid=%s", + xorpay_result.get("status", ""), + xorpay_result.get("paid", False), + ) + if xorpay_result.get("paid"): + return { + "paid": True, + "source": "xorpay", + "status": xorpay_result.get("status"), + "pay_price": xorpay_result.get("pay_price") or "", + } + + zpay = get_payment_provider_by_name("zpay") + if isinstance(zpay, ZPayProvider): + result = zpay.query_order_status(order_id, timeout=8) + if not result.get("error") and result.get("paid"): + logging.info("[payjsapi] order_status ZPAY paid=True") + return result + + return {"paid": False} @router.post("/xorpay_notify") async def nomadvip_xorpay_notify(request: Request): - """nomadvip 专用:XorPay 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)""" + """XorPay 异步通知。""" form_data = await request.form() data = {k: v for k, v in form_data.items()} - logging.info(f"nomadvip XorPay 异步通知: {data}") + logging.info("nomadvip XorPay notify: %s", data) order_id = data.get("order_id", "") if order_id in processed_orders: - logging.info(f"nomadvip 订单已处理,跳过: {order_id}") + logging.info("nomadvip order already processed, skip: %s", order_id) return "ok" + provider = get_payment_provider_by_name("xorpay") if not provider.verify_notify(data): - logging.error(f"nomadvip XorPay 验签失败: {data}") + logging.error("nomadvip XorPay verify failed: %s", 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, 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) + handle_payment_success( + order_id, + pay_price, + "nomadvip-xorpay", + use_memos=True, + site_id="vip", + ) + logging.info("nomadvip business done: order_id=%s", order_id) + except Exception as error: + logging.error( + "nomadvip PocketBase/Memos failed: %s", error, exc_info=True + ) raise return provider.success_response() @@ -194,31 +841,43 @@ async def nomadvip_xorpay_notify(request: Request): @router.get("/zpay_notify") @router.post("/zpay_notify") async def nomadvip_zpay_notify(request: Request): - """nomadvip 专用:ZPAY 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)""" + """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()} - logging.info(f"nomadvip ZPAY 异步通知: {data}") + + logging.info("nomadvip ZPAY notify: %s", data) order_id = data.get("out_trade_no", "") if order_id in processed_orders: - logging.info(f"nomadvip 订单已处理,跳过: {order_id}") + logging.info("nomadvip order already processed, skip: %s", order_id) return "success" + if not provider.verify_notify(data): - logging.error(f"nomadvip ZPAY 验签失败: {data}") + logging.error("nomadvip ZPAY verify failed: %s", 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}") + if trade_status not in ("success", "TRADE_SUCCESS"): + logging.info("nomadvip non-success trade status, skip: %s", 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, 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) + handle_payment_success( + order_id, + pay_price, + "nomadvip-zpay", + use_memos=True, + site_id="vip", + ) + logging.info("nomadvip business done: order_id=%s", order_id) + except Exception as error: + logging.error( + "nomadvip PocketBase/Memos failed: %s", error, exc_info=True + ) raise return provider.success_response() diff --git a/app/routers/wxh5.py b/app/routers/wxh5.py index 2ccf9a0..08526dd 100644 --- a/app/routers/wxh5.py +++ b/app/routers/wxh5.py @@ -9,7 +9,12 @@ 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 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 @@ -61,10 +66,13 @@ def _sync_wxh5_vip_to_supabase(user_id: str, plan: str): @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() +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)) @@ -81,7 +89,6 @@ async def wxh5_payh5(item: dict): 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, @@ -113,11 +120,9 @@ async def wxh5_payh5(item: dict): @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") + """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: @@ -127,7 +132,6 @@ async def wxh5_payh5_redirect(item: dict, request: Request): 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") diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc index 1178f6e..791667f 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/__pycache__/pb.cpython-312.pyc b/app/services/__pycache__/pb.cpython-312.pyc index f7649a5..d4bc2c4 100644 Binary files a/app/services/__pycache__/pb.cpython-312.pyc and b/app/services/__pycache__/pb.cpython-312.pyc differ diff --git a/app/services/payment.py b/app/services/payment.py index 44c54dd..e1af7e2 100644 --- a/app/services/payment.py +++ b/app/services/payment.py @@ -123,7 +123,8 @@ def handle_payment_success( logging.error(f"meetup 支付成功但创建用户失败: {email}") return - print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {order_id}") + logging.info("[payjsapi] 支付成功 provider=%s user_id=%s pay_type=%s site_id=%s order_id=%s", + provider_name, user_id, pay_type, site_id, order_id) if pay_type == "vip" and use_memos: create_memos_user(user_id, password="12345678") @@ -162,12 +163,19 @@ def handle_payment_success( }) logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}") else: - pb_client.collection("payments").create({ - "user_id": user_id, - "type": pay_type, - "order_id": order_id, - "amount": amount_cents, + existing = pb_client.collection("payments").get_list(1, 1, { + "filter": f'order_id = "{order_id}"', }) + if not existing.items: + payload = { + "user_id": user_id, + "type": pay_type, + "order_id": order_id, + "amount": amount_cents, + "total_fee": amount_cents, + "channel": "wxpay", + } + pb_client.collection("payments").create(payload) # 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup effective_site_id = "meetup" if pay_type == "meetup" else site_id @@ -196,6 +204,5 @@ def handle_payment_success( processed_orders.add(order_id) except Exception as e: - print(f"PocketBase 操作失败: {e}") - logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") + logging.error("PocketBase 操作失败 order_id=%s error=%s", order_id, e) raise diff --git a/app/services/pb.py b/app/services/pb.py index 6b9e076..0772f0b 100644 --- a/app/services/pb.py +++ b/app/services/pb.py @@ -1,7 +1,10 @@ """PocketBase 客户端(基于 SDK)""" +import logging + from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD from app.sdk.pocketbase import PocketBaseSDK +logger = logging.getLogger(__name__) _pb_sdk: PocketBaseSDK | None = None @@ -20,5 +23,5 @@ def get_pb_client(): """获取已认证的 PocketBase 客户端""" sdk = _get_pb_sdk() client = sdk.get_client() - print("PocketBase admin 登录成功") + logger.info("PocketBase admin 登录成功") return client diff --git a/run-with-logs.bat b/run-with-logs.bat new file mode 100644 index 0000000..e460b54 --- /dev/null +++ b/run-with-logs.bat @@ -0,0 +1,4 @@ +@echo off +REM 强制无缓冲输出,确保日志立即显示在终端 +set PYTHONUNBUFFERED=1 +python -u run.py diff --git a/run-with-logs.sh b/run-with-logs.sh new file mode 100644 index 0000000..2d9c563 --- /dev/null +++ b/run-with-logs.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 强制无缓冲输出,确保日志立即显示在终端 +export PYTHONUNBUFFERED=1 +exec python -u run.py diff --git a/run.py b/run.py index b2e01ae..3dfdd03 100755 --- a/run.py +++ b/run.py @@ -1,11 +1,31 @@ +import os +import sys + +os.environ.setdefault("PYTHONUNBUFFERED", "1") +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) + import uvicorn from dotenv import load_dotenv load_dotenv() if __name__ == "__main__": - import os port = int(os.getenv("PORT", "8007")) - uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True) - # uvicorn.run("app.main:app", host="0.0.0.0", port=8200, reload=True) - + reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes") + try: + sys.stderr.write(f"\n[payjsapi] startup http://0.0.0.0:{port}\n") + sys.stderr.flush() + except (OSError, ValueError): + pass + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=port, + reload=reload_enabled, + log_level="info", + ) +