From adeb1112822b788642965ef2046c679f114d7919 Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 10 Mar 2026 04:42:05 -0500 Subject: [PATCH] s'' --- .env.example | 17 +- README.md | 78 ++- app/__pycache__/config.cpython-312.pyc | Bin 1839 -> 2773 bytes app/__pycache__/main.cpython-312.pyc | Bin 21237 -> 2722 bytes app/config.py | 14 +- app/main.py | 488 ++---------------- .../__pycache__/__init__.cpython-312.pyc | Bin 489 -> 489 bytes .../__pycache__/factory.cpython-312.pyc | Bin 1293 -> 1325 bytes app/payment/__pycache__/zpay.cpython-312.pyc | Bin 6767 -> 6767 bytes app/payment/factory.py | 1 + app/routers/__init__.py | 23 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 675 bytes .../__pycache__/_digital_base.cpython-312.pyc | Bin 0 -> 12804 bytes .../__pycache__/cnomadcna.cpython-312.pyc | Bin 0 -> 354 bytes .../__pycache__/common.cpython-312.pyc | Bin 0 -> 3840 bytes .../__pycache__/digital.cpython-312.pyc | Bin 0 -> 344 bytes .../__pycache__/legacy.cpython-312.pyc | Bin 0 -> 380 bytes .../__pycache__/nomadlms.cpython-312.pyc | Bin 0 -> 349 bytes .../__pycache__/nomadvip.cpython-312.pyc | Bin 0 -> 10262 bytes app/routers/_digital_base.py | 213 ++++++++ app/routers/cnomadcna.py | 4 + app/routers/common.py | 65 +++ app/routers/digital.py | 4 + app/routers/legacy.py | 4 + app/routers/nomadlms.py | 4 + app/routers/nomadvip.py | 163 ++++++ app/sdk/__init__.py | 20 + app/sdk/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 624 bytes app/sdk/__pycache__/memos.cpython-312.pyc | Bin 0 -> 3844 bytes app/sdk/__pycache__/minio.cpython-312.pyc | Bin 0 -> 4213 bytes .../__pycache__/pocketbase.cpython-312.pyc | Bin 0 -> 2903 bytes app/sdk/memos.py | 95 ++++ app/sdk/minio.py | 92 ++++ app/sdk/payment/__init__.py | 15 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 511 bytes app/sdk/pocketbase.py | 69 +++ app/services/__init__.py | 16 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 529 bytes .../__pycache__/memos.cpython-312.pyc | Bin 0 -> 1030 bytes .../__pycache__/payment.cpython-312.pyc | Bin 0 -> 3423 bytes app/services/__pycache__/pb.cpython-312.pyc | Bin 0 -> 1028 bytes app/services/memos.py | 21 + app/services/minio.py | 51 ++ app/services/payment.py | 86 +++ app/services/pb.py | 24 + requirements.txt | 3 +- 手机微信支付调试说明.md | 44 ++ 47 files changed, 1154 insertions(+), 460 deletions(-) create mode 100644 app/routers/__init__.py create mode 100644 app/routers/__pycache__/__init__.cpython-312.pyc create mode 100644 app/routers/__pycache__/_digital_base.cpython-312.pyc create mode 100644 app/routers/__pycache__/cnomadcna.cpython-312.pyc create mode 100644 app/routers/__pycache__/common.cpython-312.pyc create mode 100644 app/routers/__pycache__/digital.cpython-312.pyc create mode 100644 app/routers/__pycache__/legacy.cpython-312.pyc create mode 100644 app/routers/__pycache__/nomadlms.cpython-312.pyc create mode 100644 app/routers/__pycache__/nomadvip.cpython-312.pyc create mode 100644 app/routers/_digital_base.py create mode 100644 app/routers/cnomadcna.py create mode 100644 app/routers/common.py create mode 100644 app/routers/digital.py create mode 100644 app/routers/legacy.py create mode 100644 app/routers/nomadlms.py create mode 100644 app/routers/nomadvip.py create mode 100644 app/sdk/__init__.py create mode 100644 app/sdk/__pycache__/__init__.cpython-312.pyc create mode 100644 app/sdk/__pycache__/memos.cpython-312.pyc create mode 100644 app/sdk/__pycache__/minio.cpython-312.pyc create mode 100644 app/sdk/__pycache__/pocketbase.cpython-312.pyc create mode 100644 app/sdk/memos.py create mode 100644 app/sdk/minio.py create mode 100644 app/sdk/payment/__init__.py create mode 100644 app/sdk/payment/__pycache__/__init__.cpython-312.pyc create mode 100644 app/sdk/pocketbase.py create mode 100644 app/services/__init__.py create mode 100644 app/services/__pycache__/__init__.cpython-312.pyc create mode 100644 app/services/__pycache__/memos.cpython-312.pyc create mode 100644 app/services/__pycache__/payment.cpython-312.pyc create mode 100644 app/services/__pycache__/pb.cpython-312.pyc create mode 100644 app/services/memos.py create mode 100644 app/services/minio.py create mode 100644 app/services/payment.py create mode 100644 app/services/pb.py create mode 100644 手机微信支付调试说明.md diff --git a/.env.example b/.env.example index 286db19..a34f803 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,16 @@ # ========== 本地调试 ========== -# BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost, +# BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost/内网, # 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io # 或直接使用生产地址测试回调(需 payjsapi 已部署到公网) +# nomadvip/digital/cnomadcna/nomadlms 的 zpay_notify 均依赖此地址,未正确配置会导致 PocketBase/Memos 不更新 +# 本地调试:ngrok 暴露 8700 后设置 BASE_URL=https://xxx.ngrok.io +# 线上部署:BASE_URL=https://api.hackrobot.cn # ========== 生产部署 ========== # BASE_URL 必须为 payjsapi 实际公网地址,ZPAY 异步通知会请求此地址 BASE_URL=https://api.hackrobot.cn -# 支付渠道:xorpay | zpay(默认 zpay) +# 支付渠道:zpay(已替代 xorpay)| xorpay PAYMENT_PROVIDER=zpay # XorPay @@ -23,6 +26,14 @@ PB_URL=https://pocketbase.hackrobot.cn PB_ADMIN_EMAIL=your@email.com PB_ADMIN_PASSWORD=your_password -# Memos +# Memos(仅 nomadvip 使用) MEMOS_API=https://qun.hackrobot.cn/api/v1/users MEMOS_TOKEN=your_token + +# MinIO 对象存储(可选) +MINIO_ENDPOINT=minioweb.hackrobot.cn +MINIO_PORT=443 +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET=hackrobot +MINIO_UPLOAD_PREFIX=joins diff --git a/README.md b/README.md index 58ee62d..3165fef 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,53 @@ # PayJS API -支付 API 服务,支持多支付渠道切换(XorPay、ZPAY)。 +支付 API 服务,支持多支付渠道(XorPay、ZPAY),按项目模块化拆分。 + +## 路由与项目对应 + +| 项目 | 路由前缀 | 说明 | +|------|----------|------| +| nomadvip | `/nomadvip/*` | 固定 ZPAY,独立实例 | +| digital | `/digital/*` | 使用 PAYMENT_PROVIDER | +| cnomadcna | `/cnomadcna/*` | 使用 PAYMENT_PROVIDER | +| nomadlms | `/nomadlms/*` | 使用 PAYMENT_PROVIDER | +| legacy | `/payh5`、`/zpay_notify` 等 | 向后兼容 | + +各项目业务互不影响,可单独配置。 + +## 环境 + +本地调试:`BASE_URL` 需为 ZPAY 可访问地址(如 ngrok 暴露 8700 端口) +线上部署:`BASE_URL=https://api.hackrobot.cn` + +## 模块结构 + +``` +app/ +├── config.py # 配置(环境变量) +├── main.py # FastAPI 入口,挂载路由 +├── sdk/ # SDK 组件化封装 +│ ├── payment/ # 支付网关(ZPAY/XorPay) +│ ├── pocketbase.py # PocketBase 数据库 +│ ├── minio.py # MinIO 对象存储 +│ └── memos.py # Memos 用户(仅 nomadvip 使用) +├── services/ # 共享业务逻辑(基于 SDK) +│ ├── pb.py # PocketBase 客户端 +│ ├── memos.py # Memos 用户创建 +│ ├── minio.py # MinIO 上传 +│ └── payment.py # 支付成功处理 +├── routers/ # 项目路由 +│ ├── nomadvip.py # /nomadvip/* +│ ├── digital.py # /digital/* +│ ├── cnomadcna.py # /cnomadcna/* +│ ├── nomadlms.py # /nomadlms/* +│ ├── legacy.py # /payh5 等(向后兼容) +│ └── common.py # /submit_meetup_application、/create_user +└── payment/ + ├── base.py # 支付抽象基类 + ├── xorpay.py # XorPay 实现 + ├── zpay.py # ZPAY 实现(易支付兼容) + └── factory.py # 渠道工厂 +``` ## 支付渠道 @@ -11,17 +58,28 @@ 切换渠道:设置环境变量 `PAYMENT_PROVIDER=xorpay` 或 `zpay`,参考 `.env.example`。 -## 模块结构 +## 本地调试 +```bash +# 复制配置 +cp .env.example .env + +# 本地调试时 BASE_URL 需 ZPAY 可访问,可用 ngrok: +# ngrok http 8700 -> 将 BASE_URL 设为 ngrok 提供的 https 地址 + +python run.py +# 或 uvicorn app.main:app --host 0.0.0.0 --port 8700 --reload ``` -app/ -├── config.py # 配置(环境变量) -├── main.py # FastAPI 入口 -└── payment/ - ├── base.py # 支付抽象基类 - ├── xorpay.py # XorPay 实现 - ├── zpay.py # ZPAY 实现(易支付兼容) - └── factory.py # 渠道工厂 + +## 线上部署 + +域名:`https://api.hackrobot.cn` + +```bash +# .env 中设置 +BASE_URL=https://api.hackrobot.cn +PAYMENT_PROVIDER=zpay +# 其他 ZPAY、PocketBase、Memos 配置见 .env.example ``` ## Getting started diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 97f31b83b86691b6496600896dd7e66085601e3e..2a3b8d9a214b9c80d5e93a35c845e0513751d4ba 100644 GIT binary patch delta 786 zcmZ3_cU4s5G%qg~0}$+gv_4apgMr~Ohyw%cP{!vo%o{btm?j2D3d*L)t!9D2IsMdeczV1la|p^{=i30}n%C748%I7mPlNk9T5ppv2rS0M=$U{*^}pO~Yg??ra*Gpg-KAz6;p+P=X92q4g1;tkS`o9?UZwW&*g$DTeJG#UN1i89-M%-e} z%FoOz)|8)Y%i88rBnAxlTg;hxB}L*uR*@u#kOmPlAVLmE+~SM}doez>;1(~GnN*sb zomx_)08*ng`2ed0w;_-Tio{}erp?T3s*Lpw93R*igrp}3O_!V~c|ltHg09PDLDvSp zo4f)OBq5?RY*vU~7SzAYYtX=TLs({l(R9m+mKS98Hwax8Ho3qcfK`lNqNAw4va@oA z+zRQ-{H6^&Hv}XnFiq#2$T>rQh1q2Rqw4||7X>V`E(=)gNV_QD(!hIzN3_GK-?P*6 zf|%L`4g1SH4h@_LO?oS|FAJCfRUtdTVu$Ar>jQ}=cn{QHFbuye9078TnEZsK>6sHV UFDRI9D8DRbf004BNFV5B09=&dcK`qY delta 95 zcmcaAx}HztG%qg~0}%9Rtj%1&%E0g##DM`8DC4sj(?$(3CYDMjP1(&qn13=cX^KqV g#oji#ox_w{0jP)(h>KMjHy`IvWn^#UD3Sy60fh+?d;kCd diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index d1f3593d0ca06399c7a3c4310af222f7d5029a5c..6783fafd61a7a129dac63adb3103d82cfc32313d 100755 GIT binary patch literal 2722 zcmai0|8LV)5Pyyn+jYJ`8z`Vs@PIOcAYn*M6WU;mQPDBJsNJS5Vv0;`QWrbUYzNl- zl+r=V#%QOpflAQ^jERZTvi-uhBL0B=0s@K1(j+G8wE2lt1+nqd?!|FZ##k-+y}Nt& z?(W_D+}XeT{Val~>(3GC34_o>%s3BSCZ<>yLMM=bqDUYFmrO(nm`T~CkWrGrGPmqj zJW&tO9)Xf6g^tn+6J?Z|XpO=~S;ZUmD!!k^7U565h4$h&gvk(BkM8}e<#G!;)7YUm#X)JT()%jwX zOt^+-I}sBIB7U{1<4_{)SbM1kGowb$j2i5W8r~T-e9viwoD)=Mq1Mq5>b^iu7NT=I zU7=w54FCk2KRz;c<0Q9fR|_|G^@ermyw!IO3-d?2te*z#)%?{T3titn8twg9)pqfn z53^m-U7J36G*wtK8Rd`{)lv>Y4 z95S+!!_R8YI(6L|9^kNl&=jPEWbpEOj*FDZvLa}X-Mlq^{K5-?!LE48YCOqifbZoh_SRTYAl-{9I^&4 zPh9Sv82JuT`Oc=cEwK+<-{%U0*|DqFOG)FbzLAOHTZI$DplO}XV&^{}jt0)$iU{E zt!>*SL6F4*ye1ZKO@=6QmUD?QO-&o3W(LZ4d^)A7ee9Do2-@d31Rm0TI#$1vzK5NUc0$&f|UD~NhrlTtX*CcR(O zbW9K{n?r8X&&#rUAf{>nzoc%~+ALg!ASMlomvu)&5sh|LsIcuKFMy%Uf~FO`9ycUK z4C#Q>rWJ~)>wE&ZOdViD0V`#Egk?2>W0J-FqHI!oRZZaylWFIZkYr7NMnvNHBAllN zh;DeNa0}dq2;A?X*9)ZJ#y#Ec*@Kk84w7x?5_y0KdDA{nwYtt**RSNyW%ECrH(lyJ zlYuZgbh!1*r_~w+S^K(G(~7brh`_NWO^h4US4t39H8^cdH+Unhzp!ZIKl53oa2@MT zA-`bUS@}!J{MnT-Cr@Ii;Do(~7?AG#n0R=GX_tWj#&W;xP6+{jk%@7<@hF z1NaxB%&dsKw30!8S|LNtFb;-Ix_k(;rh?$|Dnx2hc3qg1T@U~j%Fee*Lr2&z#YK}U zRh|9|(l98d;6=|CZ(hGUrKufoYrEmFrJLRjon^PeOUZDm({yX9YH0Z5xDj4`EesRr zuPiDuC^#<{Y=7aULRFNUJv!R?uKm#1G9-x zy-CMnf*Oy-Od1dkRg*C3z0f!SVH*B{Fz1!Iuu|zC;kc^l8n$85cIVJF3OEh_(P;3S zgEWkH4gdORUKmUgj$j6OL5NkYnBG0SF2y&i$-Pp-bhqqi-f4Q9C0X2R*O2LNGoXhm z4jYh?aXBrBPOBjkqMe9R`m`qOY&i_im^T$wNXy~|Z5^<<*Xgw|7D<92CS3u-J+%lC zvn4g~mgW#?`|aOw4=t&1w}VUXp=A~JU#f52L$6fV fFju6I%hNNjYhG`#d*P(ZPf$}Nas^6MDP{iv?wMBa literal 21237 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 F{{b$>Yh(Zb diff --git a/app/config.py b/app/config.py index 818d6c0..e7af361 100644 --- a/app/config.py +++ b/app/config.py @@ -7,7 +7,7 @@ from typing import Literal PaymentChannel = Literal["xorpay", "zpay"] -# 当前使用的支付渠道(默认 zpay,与 /join 报名支付流程一致) +# 当前使用的支付渠道(zpay 替代 xorpay,payjs2 接口业务保留) PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "zpay").lower() # XorPay 配置 @@ -33,3 +33,15 @@ MEMOS_TOKEN = os.getenv( # 回调地址(用于生成 notify_url) BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn") + +# MinIO 对象存储 +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn") +MINIO_PORT = int(os.getenv("MINIO_PORT", "443")) +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "hackrobot") +MINIO_USE_SSL = os.getenv("MINIO_USE_SSL", "true").lower() == "true" +_minio_ep = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn") +_minio_bucket = os.getenv("MINIO_BUCKET", "hackrobot") +MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", f"https://{_minio_ep}/{_minio_bucket}") +MINIO_UPLOAD_PREFIX = os.getenv("MINIO_UPLOAD_PREFIX", "joins") diff --git a/app/main.py b/app/main.py index 165eaec..943e7e8 100755 --- a/app/main.py +++ b/app/main.py @@ -1,32 +1,37 @@ """ 支付 API 主入口 支持多支付渠道(XorPay、ZPAY),通过 PAYMENT_PROVIDER 环境变量切换 + +路由模块化,按项目拆分: +- nomadvip: /nomadvip/* - 固定 ZPAY +- digital: /digital/* - 使用 PAYMENT_PROVIDER +- cnomadcna: /cnomadcna/* - 使用 PAYMENT_PROVIDER +- nomadlms: /nomadlms/* - 使用 PAYMENT_PROVIDER +- legacy: /payh5、/zpay_notify 等(向后兼容) +- common: /submit_meetup_application、/create_user + +本地调试:BASE_URL 需为 ZPAY 可访问地址(如 ngrok) +线上部署:https://api.hackrobot.cn """ import logging -import random -import string -import time -from typing import Dict, Any -import requests -from fastapi import FastAPI, Request, HTTPException +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from pocketbase import PocketBase -from .config import ( - BASE_URL, - PB_URL, - PB_ADMIN_EMAIL, - PB_ADMIN_PASSWORD, - MEMOS_API, - MEMOS_TOKEN, - ZPAY_PID, - ZPAY_KEY, +from .routers import ( + nomadvip_router, + digital_router, + cnomadcna_router, + nomadlms_router, + legacy_router, + common_router, ) -from .payment import get_payment_provider -app = FastAPI() +app = FastAPI( + title="PayJS API", + description="nomadvip / digital / cnomadcna / nomadlms 支付接口", + version="2.0", +) app.add_middleware( CORSMiddleware, @@ -46,434 +51,27 @@ logging.basicConfig( ], ) -# 已处理订单(防重复) -processed_orders: set[str] = set() +# 挂载路由 +app.include_router(nomadvip_router) +app.include_router(digital_router) +app.include_router(cnomadcna_router) +app.include_router(nomadlms_router) +app.include_router(legacy_router) +app.include_router(common_router) -def get_pb_client(): - client = PocketBase(PB_URL) - try: - client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD) - print("PocketBase admin 登录成功") - except Exception as e: - print("PocketBase admin 登录失败:", e) - raise e - return client - - -def create_memos_user(user_id: str, password: str = "123456"): - try: - if user_id.startswith("user_"): - cleaned_username = "user" + user_id.split("user_", 1)[1] - elif "_" in user_id: - cleaned_username = user_id.replace("_", "") - else: - cleaned_username = user_id - - username = cleaned_username - print(f"正在为用户创建 memos 账号: {username}") - - memos_data = { - "username": username, - "password": password, - "role": "USER", - "state": "NORMAL", - } - memos_headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {MEMOS_TOKEN}", - } - - response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10) - - if response.status_code in (200, 201): - result = response.json() - print(f"memos 用户创建成功: {username}") - logging.info(f"memos 用户创建成功: {username}") - return {"success": True, "data": result} - else: - error_text = response.text - print(f"memos 创建失败: {response.status_code} {error_text}") - logging.error(f"memos 创建失败 - username: {username}, status: {response.status_code}, response: {error_text}") - return {"success": False, "error": error_text} - - except Exception as e: - print(f"创建 memos 用户异常: {e}") - logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}") - return {"success": False, "error": str(e)} - - -def _parse_order_id(order_id: str) -> tuple[str, str]: - """从 order_id 解析 user_id 和 pay_type""" - if "_order_" not in order_id: - return "", "unknown" - prefix = order_id.split("_order_")[0] - if "_" in prefix: - parts = prefix.rsplit("_", 1) - return parts[0], parts[1].lower() - return prefix, "unknown" - - -def _handle_payment_success(order_id: str, pay_price: str, provider_name: str): - """支付成功后的统一业务处理""" - 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}") - - if pay_type == "vip": - create_memos_user(user_id) - - if pay_type == "meetup": - logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}") - - valid_types = ["vip", "book", "meetup", "video"] - if pay_type not in valid_types: - pay_type = "vip" - - amount_cents = int(float(pay_price or 0) * 100) - - try: - pb_client = get_pb_client() - if pay_type == "meetup": - solan = pb_client.collection("solan") - existing = solan.get_list(1, 1, { - "filter": f'user_id = "{user_id}"', - "sort": "-created", - }) - if existing.items: - record_id = existing.items[0].id - solan.update(record_id, { - "type": "meetup", - "order_id": order_id, - "amount": amount_cents, - }) - logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}") - else: - solan.create({ - "user_id": user_id, - "type": "meetup", - "order_id": order_id, - "amount": amount_cents, - }) - 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, - }) - except Exception as e: - print(f"PocketBase 操作失败: {e}") - logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") - finally: - processed_orders.add(order_id) - - -# ==================== 支付接口 ==================== - - -@app.post("/payh5") -async def payh5(item: dict): - """创建 H5 支付订单(根据 PAYMENT_PROVIDER 使用对应渠道)""" - print("payh5 item:", item) - - total_fee_yuan = f"{item['total_fee'] / 100:.2f}" - user_id = item.get("user_id", "unknown") - pay_type = item.get("type", "unknown").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, "商品支付") - - provider = get_payment_provider() - notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify" - notify_url = f"{BASE_URL.rstrip('/')}{notify_path}" - - # 支付方式:前端可传 channel=alipay|wxpay,ZPAY 使用;XorPay 固定 jsapi - 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"), - ) - - # 兼容旧版前端:保留 xorpay_params / xorpay_url 字段名 - if provider.name == "xorpay": - return { - "status": "ok", - "xorpay_params": result.get("params", {}), - "xorpay_url": result.get("pay_url", ""), - "provider": provider.name, - } - # ZPAY:确保所有参数为字符串,避免前端传参问题 - params = result.get("params", {}) - params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""} +@app.get("/") +async def root(): + """健康检查""" return { "status": "ok", - "params": params_str, - "pay_url": result.get("pay_url", ""), - "provider": provider.name, - "submit_method": "POST", + "service": "payjsapi", + "routes": { + "nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify", + "digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify", + "cnomadcna": "/cnomadcna/payh5, ...", + "nomadlms": "/nomadlms/payh5, ...", + "legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status", + "common": "/submit_meetup_application, /create_user", + }, } - - -@app.post("/payh5/redirect") -async def payh5_redirect(item: dict, request: Request): - """ - 创建支付订单,使用 ZPAY API接口支付(mapi.php),返回 302 重定向。 - 根据 device 区分:PC 跳转 payurl,H5/微信 跳转 payurl 或 payurl2(微信H5)。 - 文档:https://z-pay.cn/doc.html#d3 - """ - from fastapi.responses import RedirectResponse - - 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)) - 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") # pc | h5 | wechat,微信内传 wechat 走收银台 - - client_ip = item.get("client_ip") or request.client.host if request.client else "127.0.0.1" - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - client_ip = forwarded.split(",")[0].strip() - - random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) - timestamp = int(time.time()) - order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" - - type_map = { - "book": "购买书籍", - "meetup": "数字游民社区报名", - "video": "视频解锁", - "vip": "VIP会员充值", - } - name = type_map.get(pay_type, "商品支付") - notify_url = f"{BASE_URL.rstrip('/')}/zpay_notify" - - from .payment.zpay import ZPayProvider - - 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": - raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败")) - - payurl = result.get("payurl") - payurl2 = result.get("payurl2") - img = result.get("img") - - # PC 端:payurl 为 wap 链接,在 PC 浏览器会显示错误页面。支付宝/微信均需展示二维码 - if device == "pc" and img: - from fastapi.responses import JSONResponse - 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, - }, - ) - # 微信内:payurl2 为 checkmweb,会提示「请外微信外打开」。改用收银台表单(submit.php)由 Z-Pay 识别 UA - if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"): - from fastapi.responses import JSONResponse - return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id}) - # 返回 302 时附带 order_id,供 digital 写入 cookie 用于轮询检测支付状态(微信 H5 可能不跳转) - headers = {"X-Pay-Order-Id": order_id} - if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2: - return RedirectResponse(url=payurl2, status_code=302, headers=headers) - if payurl: - return RedirectResponse(url=payurl, status_code=302, headers=headers) - raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接") - - -@app.get("/zpay_order_status") -async def zpay_order_status(out_trade_no: str): - """ - 查询 ZPAY 订单支付状态,用于 PC 二维码页轮询。 - 文档:https://z-pay.cn/doc.html 查询单个订单 - """ - provider = get_payment_provider() - if provider.name != "zpay": - raise HTTPException(status_code=400, detail="仅 zpay 渠道支持") - url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}" - try: - resp = requests.get(url, timeout=10) - result = resp.json() if resp.ok else {} - except Exception as e: - logging.warning(f"ZPAY 订单查询失败: {e}") - return {"paid": False, "error": str(e)} - # ZPAY 错误时可能返回 code='error',需安全解析 - try: - code = result.get("code", 0) - code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0 - except (ValueError, TypeError): - code = 0 - if code != 1: - return {"paid": False, "msg": result.get("msg", "查询失败")} - try: - status = int(result.get("status", 0)) - except (ValueError, TypeError): - status = 0 - return {"paid": status == 1, "status": status} - - -@app.post("/xorpay_notify") -async def xorpay_notify(request: Request): - """XorPay 异步通知(POST form)""" - form_data = await request.form() - data = {k: v for k, v in form_data.items()} - - logging.info(f"XorPay 异步通知: {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") - - if not provider.verify_notify(data): - logging.error(f"XorPay 验签失败: {data}") - raise HTTPException(status_code=400, detail="sign error") - - parsed = provider.parse_notify(data) - if parsed.get("trade_status") != "success": - return "ok" - - _handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), "xorpay") - return provider.success_response() - - -@app.get("/zpay_notify") -@app.post("/zpay_notify") -async def zpay_notify(request: Request): - """ZPAY 异步通知(GET 或 POST)""" - 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"ZPAY 异步通知: {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") - - if not provider.verify_notify(data): - logging.error(f"ZPAY 验签失败: {data}") - raise HTTPException(status_code=400, detail="sign error") - - parsed = provider.parse_notify(data) - if parsed.get("trade_status") != "success": - return "success" - - _handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), "zpay") - return provider.success_response() - - -# ==================== 其他接口 ==================== - - -@app.post("/submit_meetup_application") -async def submit_meetup_application(item: dict): - print("submit_meetup_application item:", item) - - user_id = item.get("user_id") - if not user_id: - raise HTTPException(status_code=400, detail="missing user_id") - - try: - pb_client = get_pb_client() - solan = pb_client.collection("solan") - - pb_data = { - "user_id": user_id, - "nickname": item.get("nickname", ""), - "occupation": item.get("occupation", ""), - "reason": item.get("reason", ""), - "wechatId": item.get("wechatId", ""), - "gender": item.get("gender", ""), - "education": item.get("education", ""), - "gradYear": item.get("gradYear", ""), - "phone": item.get("phone", ""), - "calculated_age": item.get("calculated_age", 0), - "type": "", - "order_id": "", - "amount": 0, - } - - record = solan.create(pb_data) - 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(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}") - raise HTTPException(status_code=500, detail="submit failed") - - -class CreateUserRequest(BaseModel): - username: str | None = None - password: str | None = None - - -@app.post("/create_user") -async def create_user(item: CreateUserRequest): - timenew = str(int(time.time())) - memos_data = { - "username": item.username or f"user{timenew}", - "password": item.password or "123456", - "role": "USER", - "state": "NORMAL", - } - memos_headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {MEMOS_TOKEN}", - } - memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers) - memos_result = memos_resp.json() - print("memos_result-", memos_result) - return memos_result diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc index 822b2c64d392751519a0fba32f27b925c74e3bd5..5ecee05a116e06a729e3baf7edef8df62b46329e 100644 GIT binary patch delta 20 acmaFK{F0gbG%qg~0}wpDynZA1Jw^aT#0J#> delta 20 acmaFK{F0gbG%qg~0}#|HuieOfj}ZVp$pyRs diff --git a/app/payment/__pycache__/factory.cpython-312.pyc b/app/payment/__pycache__/factory.cpython-312.pyc index 3bf336acaff543ca67d37452f6e817e45202ddc4..090f9559e48ae478bf2178a471ad91efb61fa0e1 100644 GIT binary patch delta 83 zcmeC>TFb?InwOW00SL}sTc7DPk#`DX{KQ4ha=T}|ShwWK-u=&~tWc;bNUT(NHhce* n-HR0}@{54HjgLc_7)2*rGT&uXoh;6>gO!Dm)yu_unwOW00SM}p*JdhC&^vCPtCTk<51)RVJIW>|kYPWcki9 I`3s9B0CUX_bpQYW diff --git a/app/payment/__pycache__/zpay.cpython-312.pyc b/app/payment/__pycache__/zpay.cpython-312.pyc index 916fc7cf469682e54a79b5056420a7d5e8f37d38..4d86fc2bb7df65d757562a53f081bf1040c1f1a1 100644 GIT binary patch delta 20 acmaEF^4^5|G%qg~0}zBCUB8h#MG62%dj>`T delta 20 acmaEF^4^5|G%qg~0}xnguieOI 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 Ta}pG$!`sAkWamBdOvd{^#fld= literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/cnomadcna.cpython-312.pyc b/app/routers/__pycache__/cnomadcna.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ca5955d62c85489d8fb9ed1114df2433067c5ae GIT binary patch literal 354 zcmX@j%ge<81nCdgXKn)0k3k$5V1Y6|YXBM38B!Rc7*ZHhm~t3%nWC5&8JHMSnNpZj zSk|zvW`d}UVy+1Xw1uDyOgFblYe7Xx;{0b%Hahk+FFwA11h>U7u{)?@a?%xsOV zb>*Z%u~CVQLS$2@?5ZF+1&X6q3UX+aA4ICuKSS0Mn;S=M1#93>aeyCb+8;f4*1KLT zsITR7ALrb2@0@$ich3IJ>9iwgHTP~Pq79*YWRh)``N7kF!C(YQ$d4pSqC=FQGJD!j zL(hbmFzaW-7Qcled^W^|t$r(vEfN>v!#2Oo99u*7u*2^#$9$+D?DRXsF29RHG?H1# z7NoA(uTq&4{chRawIX|sgs4l6Q}IhQ z73OIw22DkIno4?TpC`T!G^9^|bbspV?0Z+zL%)A~>o;Lp*5fhngY$oSc;n-H{ciX?YSiM#!ip|r?kI?{SV#$o^l`uBx~Xn- zis?Yz)ODtt@-2(kg3j3wCeznPW^dk{ojmv8@~7!vPtBgWk{&+$@OKmGvmf*>e`!Q{ zNkFw=D+zo-k(6PGonCiX(KICz^v~EfN;m0B)6o-P9xh`ZY|F9Y8Vbn)6F9jHz+-DD8VthI0+L3K zM==d{vE?uwTg-EOR+Yq)X>x4K!MRFYA6sEAiMoj4b%_-)C2Qm~Z>D|c+tG3~rbfHq z+@mqEr%MxK%29yuqpF!jw4)icUl;4al`r*5^)=8)UR;a72R}sL_M>MUve*rK!$kWu zyFTUe40R564)-KoEBbeT>F~_BswWtuxpmsL%iwp-(G`|fYNlkx`v(SWslv*;g>`od z>&AbYEL=Na`A2zes-k9$zrc@IO&m^EG^MI)$LcTCk8hpyB&(WJWmO}c=Q>AEO?Z-J zYZgg9&#PQER&k+XykSC3R<2(pSz0-A9Bk5Mn$``D=d)s75XhP! zV7nlMqf$IX#sz|KGA@QP9JLwLVn)+d>=pzu5{UwiK_#IHg8DkJK15G}BDIN-4Ov3m zPb0W`8l|e2_wN}94DTH5O0vuIX4C9)mE76f)mzY*S3x~jpzt7L7AWoD%8LsWeZDwB zNGEY?wuK^NfqRNlIY=lunQZF2MrF%GQoMALvlcatm1v1ks5YeMlITKW(n*k+?L~<* zTjXR<%l1(8pckdmQuVyC(Ym@jGxCc$3gvV}mO zMJF(CHp!aEm19uOS=x}90{M77u+sw<)1ORv&4Lr^+KJhLj|q&AP2Hb*FS80|D1f<- z!B)T|A+ACK)1xQk2)4=*@fbj<+5mdu#3UIlvzPwx==9kKfB7S3iT`2lwe=gCU*G7X zO|LTjr`H)K6|SJlT0Eq$^%bb(4OCJQs^sSgW{*XqA@xUOzM0G!0@SRogyjV#xIE8! zgkwEs8BY9HT?JcwoC(L^#L@|DB|9T>civ_IVsf^|foukZO>f4IJf-T7fk~oL8wQdE zR7dfp&DZwcF1f9Iu}he47n0k4K5ZBKccdJyp)F^&qzcQ8%GHLu!QdNaTt(+sotH+G zbISWClAe`G*Q);ADR=Qu-(cSt?xyj&@$PZO@HX}D$t~88^DuK3-gVaBan?`Pe|E%h z)+e3&`*+Q-wt>dcjnnMPl%uGB-@nbPzrFpH?e&ze6k7#BiUuG)Uxllxtw6B#xCjY0 zrlQhbaUz(nws-F%(68iJHGcuCI{e0b27l zs!gN1uaWofsNtVB&uGP{ecHCFpPS3NP1HR>q<_jFx*@al qY&%6$-cj2#1l^o{e$&9ul2pwcZKpg&&Dtjjda`BNH)O@UGfNV46kaae`F!@eXH(Wc**)V~-_oc3i{I~=r4N;t2H7&E^7EeJ@epYI7 zNqk;nZmORq%Psc!_>}zQ`1o7AaC4Fpi&Kl3fp*+tgBZDz;WNmszqFjKVxanC3KA=` ziW3VmV-gDrVj#+jW1#lw6;%G>u*uC&Da}c>E8+!e1-ZCb97ud%W@Kc%$)J0e!QdgM S+=8qfl~*`D8`+AufpP#}YHkDo literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/legacy.cpython-312.pyc b/app/routers/__pycache__/legacy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34c4906edd5ce76e183c1025ce999831ee38ad17 GIT binary patch literal 380 zcmX@j%ge<81X&N)XPyVrk3k$5V1Y6|YXBM38B!Rc7*ZHhm~t3%nWC5&8JHMSnNpZj zSk|zvW`d}UVy3Kkw{#v7!0-lojvybiQN+ znykrqOEkGCHL)Z$9;!3GD8IBMwdfYZEw-H0^u**!O{QCH1x2Z8nH9Hq3X1ZxQj<&K z^AdAY{WMu_vB$@!W=|BJF7Uc zATuVhpdbdKtT+Z@onArZFAkgB{FKt1RJ$TRpiYoGi^YM&2WCb_#+wY$cNq*Ga>^}8 Q+E8(Y)1i^AhzBSK0H|n!I{*Lx literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/nomadlms.cpython-312.pyc b/app/routers/__pycache__/nomadlms.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07b217057c44127f1a2e8d5faa96599a2cea80cb GIT binary patch literal 349 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 literal 0 HcmV?d00001 diff --git a/app/routers/_digital_base.py b/app/routers/_digital_base.py new file mode 100644 index 0000000..b9b815a --- /dev/null +++ b/app/routers/_digital_base.py @@ -0,0 +1,213 @@ +""" +digital/cnomadcna/nomadlms 共享的支付逻辑 +根据 prefix 生成对应 notify_path,业务互不影响 +""" +import logging +import random +import string +import time + +import requests +from fastapi import APIRouter, Request, HTTPException + +from ..config import BASE_URL, ZPAY_PID, ZPAY_KEY +from ..payment import get_payment_provider +from ..payment.zpay import ZPayProvider +from ..services import handle_payment_success, processed_orders + + +def create_digital_router(prefix: str, project_name: str) -> APIRouter: + """ + 创建 digital/cnomadcna/nomadlms 的支付路由 + prefix: 如 "/digital", "/cnomadcna", "/nomadlms" 或 ""(legacy) + project_name: 用于日志,如 "digital", "cnomadcna", "nomadlms", "legacy" + """ + router = APIRouter(prefix=prefix, tags=[project_name]) + + def _notify_path(path: str) -> str: + base = BASE_URL.rstrip("/") + if prefix: + return f"{base}{prefix}{path}" + return f"{base}{path}" + + @router.post("/payh5") + async def payh5(item: dict): + """创建 H5 支付订单""" + print(f"{project_name}/payh5 item:", item) + total_fee_yuan = f"{item['total_fee'] / 100:.2f}" + user_id = item.get("user_id", "unknown") + pay_type = item.get("type", "unknown").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, "商品支付") + 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, + 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 payh5_redirect(item: dict, request: Request): + """ZPAY mapi 接口,返回 302 或二维码""" + 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)) + 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") + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) + timestamp = int(time.time()) + order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" + type_map = { + "book": "购买书籍", + "meetup": "数字游民社区报名", + "video": "视频解锁", + "vip": "VIP会员充值", + } + name = type_map.get(pay_type, "商品支付") + notify_url = _notify_path("/zpay_notify") + 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": + raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败")) + payurl = result.get("payurl") + payurl2 = result.get("payurl2") + img = result.get("img") + if device == "pc" and img: + return JSONResponse(status_code=200, content={ + "status": "ok", "qrcode_mode": True, "order_id": order_id, + "img": img, "payurl": payurl, "return_url": return_url, "channel": channel, + }) + if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"): + return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id}) + headers = {"X-Pay-Order-Id": order_id} + if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2: + return RedirectResponse(url=payurl2, status_code=302, headers=headers) + if payurl: + return RedirectResponse(url=payurl, status_code=302, headers=headers) + raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接") + + @router.get("/zpay_order_status") + async def zpay_order_status(out_trade_no: str): + """查询 ZPAY 订单支付状态""" + provider = get_payment_provider() + if provider.name != "zpay": + raise HTTPException(status_code=400, detail="仅 zpay 渠道支持") + url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}" + try: + resp = requests.get(url, timeout=10) + result = resp.json() if resp.ok else {} + except Exception as e: + logging.warning(f"ZPAY 订单查询失败: {e}") + return {"paid": False, "error": str(e)} + try: + code = result.get("code", 0) + code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0 + except (ValueError, TypeError): + code = 0 + if code != 1: + return {"paid": False, "msg": result.get("msg", "查询失败")} + try: + status = int(result.get("status", 0)) + except (ValueError, TypeError): + status = 0 + return {"paid": status == 1, "status": status} + + @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}") + 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") + if not provider.verify_notify(data): + logging.error(f"{project_name} XorPay 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + parsed = provider.parse_notify(data) + if parsed.get("trade_status") != "success": + return "ok" + handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), f"{project_name}-xorpay", use_memos=False) + return provider.success_response() + + @router.get("/zpay_notify") + @router.post("/zpay_notify") + async def zpay_notify(request: Request): + """ZPAY 异步通知""" + if request.method == "GET": + data = dict(request.query_params) + else: + form_data = await request.form() + data = {k: v for k, v in form_data.items()} + logging.info(f"{project_name} ZPAY 异步通知: {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") + if not provider.verify_notify(data): + logging.error(f"{project_name} ZPAY 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + parsed = provider.parse_notify(data) + if parsed.get("trade_status") != "success": + return "success" + handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), f"{project_name}-zpay", use_memos=False) + return provider.success_response() + + return router diff --git a/app/routers/cnomadcna.py b/app/routers/cnomadcna.py new file mode 100644 index 0000000..5f8034d --- /dev/null +++ b/app/routers/cnomadcna.py @@ -0,0 +1,4 @@ +"""cnomadcna 项目支付接口:/cnomadcna/*""" +from ._digital_base import create_digital_router + +router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna") diff --git a/app/routers/common.py b/app/routers/common.py new file mode 100644 index 0000000..69b426c --- /dev/null +++ b/app/routers/common.py @@ -0,0 +1,65 @@ +"""共享接口:meetup 申请、create_user 等""" +import logging +import time + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ..services import get_pb_client +from ..sdk.memos import MemosSDK +from ..config import MEMOS_API, MEMOS_TOKEN + +router = APIRouter(tags=["common"]) + + +@router.post("/submit_meetup_application") +async def submit_meetup_application(item: dict): + """数字游民社区报名表单""" + print("submit_meetup_application item:", item) + user_id = item.get("user_id") + if not user_id: + raise HTTPException(status_code=400, detail="missing user_id") + try: + pb_client = get_pb_client() + solan = pb_client.collection("solan") + pb_data = { + "user_id": user_id, + "nickname": item.get("nickname", ""), + "occupation": item.get("occupation", ""), + "reason": item.get("reason", ""), + "wechatId": item.get("wechatId", ""), + "gender": item.get("gender", ""), + "education": item.get("education", ""), + "gradYear": item.get("gradYear", ""), + "phone": item.get("phone", ""), + "calculated_age": item.get("calculated_age", 0), + "type": "", + "order_id": "", + "amount": 0, + } + record = solan.create(pb_data) + 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(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}") + raise HTTPException(status_code=500, detail="submit failed") + + +class CreateUserRequest(BaseModel): + username: str | None = None + password: str | None = None + + +@router.post("/create_user") +async def create_user(item: CreateUserRequest): + """创建 Memos 用户(基于 MemosSDK)""" + sdk = MemosSDK(api_url=MEMOS_API, token=MEMOS_TOKEN, enabled=bool(MEMOS_API and MEMOS_TOKEN)) + if not sdk.enabled: + return {"error": "Memos 未配置"} + timenew = str(int(time.time())) + username = item.username or f"user{timenew}" + password = item.password or "123456" + result = sdk.create_user_by_username(username=username, password=password) + print("memos_result-", result) + return result diff --git a/app/routers/digital.py b/app/routers/digital.py new file mode 100644 index 0000000..2c71aa7 --- /dev/null +++ b/app/routers/digital.py @@ -0,0 +1,4 @@ +"""digital 项目支付接口:/digital/*""" +from ._digital_base import create_digital_router + +router = create_digital_router(prefix="/digital", project_name="digital") diff --git a/app/routers/legacy.py b/app/routers/legacy.py new file mode 100644 index 0000000..5134a6d --- /dev/null +++ b/app/routers/legacy.py @@ -0,0 +1,4 @@ +"""向后兼容:/payh5、/zpay_notify 等(digital/cnomadcna/nomadlms 旧版调用)""" +from ._digital_base import create_digital_router + +router = create_digital_router(prefix="", project_name="legacy") diff --git a/app/routers/nomadlms.py b/app/routers/nomadlms.py new file mode 100644 index 0000000..5530aa4 --- /dev/null +++ b/app/routers/nomadlms.py @@ -0,0 +1,4 @@ +"""nomadlms 项目支付接口:/nomadlms/*""" +from ._digital_base import create_digital_router + +router = create_digital_router(prefix="/nomadlms", project_name="nomadlms") diff --git a/app/routers/nomadvip.py b/app/routers/nomadvip.py new file mode 100644 index 0000000..7b17aa3 --- /dev/null +++ b/app/routers/nomadvip.py @@ -0,0 +1,163 @@ +"""nomadvip 项目支付接口:/nomadvip/*,固定 ZPAY""" +import logging +import random +import string +import time + +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import RedirectResponse, JSONResponse + +import requests + +from ..config import BASE_URL, ZPAY_PID, ZPAY_KEY +from ..payment.zpay import ZPayProvider +from ..services import handle_payment_success, processed_orders + +router = APIRouter(prefix="/nomadvip", tags=["nomadvip"]) +_nomadvip_zpay = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY) + + +@router.post("/payh5") +async def nomadvip_payh5(item: dict): + """nomadvip 专用:固定 ZPAY,表单提交支付""" + print("nomadvip/payh5 item:", item) + total_fee = int(item.get("total_fee", 8800)) + 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 = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify" + channel = item.get("channel", "alipay") + result = _nomadvip_zpay.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"), + ) + 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": "zpay", + "submit_method": "POST", + } + + +@router.post("/payh5/redirect") +async def nomadvip_payh5_redirect(item: dict, request: Request): + """nomadvip 专用:ZPAY mapi 接口""" + total_fee = float(item.get("total_fee", 8800)) + 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" + result = _nomadvip_zpay.create_order_api( + order_id=order_id, + name=name, + total_fee_yuan=total_fee_yuan, + pay_type=channel, + notify_url=notify_url, + return_url=return_url, + client_ip=client_ip, + device=device, + ) + if result.get("status") != "ok": + raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败")) + payurl = result.get("payurl") + payurl2 = result.get("payurl2") + img = result.get("img") + if device == "pc" and img: + return JSONResponse(status_code=200, content={ + "status": "ok", "qrcode_mode": True, "order_id": order_id, + "img": img, "payurl": payurl, "return_url": return_url, "channel": channel, + }) + if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"): + return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id}) + headers = {"X-Pay-Order-Id": order_id} + if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2: + return RedirectResponse(url=payurl2, status_code=302, headers=headers) + if payurl: + return RedirectResponse(url=payurl, status_code=302, headers=headers) + raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接") + + +@router.get("/zpay_order_status") +async def nomadvip_zpay_order_status(out_trade_no: str): + """nomadvip 专用:查询 ZPAY 订单支付状态""" + url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}" + try: + resp = requests.get(url, timeout=10) + result = resp.json() if resp.ok else {} + except Exception as e: + logging.warning(f"nomadvip ZPAY 订单查询失败: {e}") + return {"paid": False, "error": str(e)} + try: + code = result.get("code", 0) + code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0 + except (ValueError, TypeError): + code = 0 + if code != 1: + return {"paid": False, "msg": result.get("msg", "查询失败")} + try: + status = int(result.get("status", 0)) + except (ValueError, TypeError): + status = 0 + return {"paid": status == 1, "status": status} + + +@router.get("/zpay_notify") +@router.post("/zpay_notify") +async def nomadvip_zpay_notify(request: Request): + """nomadvip 专用: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 _nomadvip_zpay.verify_notify(data): + logging.error(f"nomadvip ZPAY 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + parsed = _nomadvip_zpay.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 _nomadvip_zpay.success_response() diff --git a/app/sdk/__init__.py b/app/sdk/__init__.py new file mode 100644 index 0000000..bf9625d --- /dev/null +++ b/app/sdk/__init__.py @@ -0,0 +1,20 @@ +""" +PayJS API SDK 模块 +- payment: 支付网关(ZPAY/XorPay) +- pocketbase: PocketBase 数据库 +- minio: MinIO 对象存储 +- memos: Memos 用户(仅 nomadvip 使用) +""" +from .payment import get_payment_provider, ZPayProvider, XorPayProvider +from .pocketbase import PocketBaseSDK +from .minio import MinioSDK +from .memos import MemosSDK + +__all__ = [ + "get_payment_provider", + "ZPayProvider", + "XorPayProvider", + "PocketBaseSDK", + "MinioSDK", + "MemosSDK", +] diff --git a/app/sdk/__pycache__/__init__.cpython-312.pyc b/app/sdk/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64d5f8c6104ed2d3bf326d587f4d64bc0c334870 GIT binary patch literal 624 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 literal 0 HcmV?d00001 diff --git a/app/sdk/__pycache__/pocketbase.cpython-312.pyc b/app/sdk/__pycache__/pocketbase.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a316c6278cffcf7b857688b1e6ae913caaaa66e7 GIT binary patch literal 2903 zcmai0U2IfE6rTILcYkkzQnqvhOA+-7f&~&IV50&dDnSW6NK{6awtj2@~<2m>4Pur9@&7L`P=G>V% z-}&ZDe+mYD1e*Ta^~Bd6LVm|Vy@@qtb}J}DgpwXYIm#!w6yL*hs0&FUCH9CM;mICC z#Z!bzqrCf7kCc}+Pi{Mme6MEWZyDALG1G`1*!8ln^1*oJ+*tYhiTPq`j}@`gwS_=$$tCsXN^*dtNdko$P*0o_9tu=CUlxnQPdo+NaPKum|$0w!{uL!-ObvVK_z85-pE?Rw>@NJ9-@;qcoAD>hLvOKcq39U;>jFbY)}9f(hvQ@cH*GbKma`E z;#zYZt*to^)kW9pmlv*1NI!zOpus+MHqMy=Yhcc>rq(YE_mJ}+fUM;n1Lv9dZj=Wv zm2Z4AGxo*I^^#%+_0isT`CJi)>;o(fg zBFZp5w(m{Ds&Qi=+}sM&*qlL{n|nc=Ah-SM;EpppCj6^^?N~QLzdHQs;i-;I#a(Cj zmI5tLGtey^Ro(yar-#Wcz=j4{1KOUUk{o3U2*M1HhDyho`3ug)wN(WPM ze=!|P8Sw_0!Gwu)68;=0PQvHX0c_Yt-H!A((q1z_SK#W&c3{PbU0Cm3ey4oCsPU{7 zOtvqcNhX2jP;$V8UTirC6VDQL(P1k`R4afi|^bVo*5f2-}=BgS_&$Jc}--PZ(GE3G7OJ(fB=jT z4O@;gBW4+zkp9iaEktEdeS|ApoLAH-u~L0k+-%EO;f z;4Z`C_pw0XO}WC4)@XN3DoBe}8}`)VcIrW>7OODFWY~SNDkD<`6G^>Sgk}}V`@tJ4 zz=BrbYTmwP(m9g%Yk?dBTe=H=9x{^$omMtYL zBW2n=TnssvrNAl{1y3k$&TVw4)nFCF0Bps08@^1W-?Y8Vh{GL%TV-OR9lq5Wx7=M# zchA!x-rD)ZThwU-tYw}D0dH1_ELlFJUQkCoQ!S4drP8wSQ1^xIku@XxOX0O6dq$qQ z`us%O#!~C5`;wpm7ll@TQK>2<&^p-tZa2{MlhB8ukt35~Z92SRGQ6SqVkxi$Yvgb{ zayarxX=U`jM_d>Bn}|Ubp2*-*oGo>9HmAMPN|=^{szY!sh_5b|OkKBqx}M6=ToUzw zt{=(8lC>F+uG35$%z|k#7J_Kpmjdgan*rzNHxSun}^L=0$o+{ zNdA#{l|VOs!0CS6>2wqO54!!K*Jp9Iy0l$VZ!c-BO3=d=%i5|Q)D!|XFKRyG3;pE> z%{?z371^}~dRvC71@vHeL2X~Q@ehj&fD_fzFx8CofwIFzv72p!3FN&Q0Z|n=j=Rqb XocOmwxJa3-`A6Q)secj>&e#6|=uxyW literal 0 HcmV?d00001 diff --git a/app/sdk/memos.py b/app/sdk/memos.py new file mode 100644 index 0000000..2f89a47 --- /dev/null +++ b/app/sdk/memos.py @@ -0,0 +1,95 @@ +""" +Memos SDK +用户创建等操作,仅 nomadvip 使用 +""" +import logging +from typing import Optional + +import requests + + +class MemosSDK: + """Memos 用户管理 SDK""" + + def __init__( + self, + api_url: str, + token: str, + enabled: bool = True, + ): + self.api_url = api_url.rstrip("/") + self.token = token + self.enabled = enabled and bool(api_url and token) + + def create_user( + self, + user_id: str, + password: str = "123456", + ) -> dict: + """ + 创建 Memos 用户 + :param user_id: PocketBase 用户 ID,如 user1766043770158 + :return: {"success": bool, "data"?: dict, "error"?: str} + """ + if not self.enabled: + return {"success": False, "error": "Memos 未启用"} + + try: + if user_id.startswith("user_"): + cleaned = "user" + user_id.split("user_", 1)[1] + elif "_" in user_id: + cleaned = user_id.replace("_", "") + else: + cleaned = user_id + + username = cleaned + data = { + "username": username, + "password": password, + "role": "USER", + "state": "NORMAL", + } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + } + resp = requests.post( + self.api_url, + json=data, + headers=headers, + timeout=10, + ) + if resp.status_code in (200, 201): + logging.info(f"memos 用户创建成功: {username}") + return {"success": True, "data": resp.json()} + else: + logging.error(f"memos 创建失败 - username: {username}, status: {resp.status_code}, response: {resp.text}") + return {"success": False, "error": resp.text} + except Exception as e: + logging.error(f"创建 memos 用户异常 - user_id: {user_id}, error: {e}") + return {"success": False, "error": str(e)} + + def create_user_by_username( + self, + username: str, + password: str = "123456", + ) -> dict: + """按用户名直接创建(用于 /create_user 接口)""" + if not self.enabled: + return {"success": False, "error": "Memos 未启用"} + try: + data = { + "username": username, + "password": password, + "role": "USER", + "state": "NORMAL", + } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + } + resp = requests.post(self.api_url, json=data, headers=headers, timeout=10) + return resp.json() + except Exception as e: + logging.error(f"创建 memos 用户异常 - username: {username}, error: {e}") + return {"error": str(e)} diff --git a/app/sdk/minio.py b/app/sdk/minio.py new file mode 100644 index 0000000..35ab51e --- /dev/null +++ b/app/sdk/minio.py @@ -0,0 +1,92 @@ +""" +MinIO SDK +对象存储封装,支持配置注入 +""" +import os +import time +import random +import string +from typing import Optional + +try: + from minio import Minio + MINIO_AVAILABLE = True +except ImportError: + MINIO_AVAILABLE = False + Minio = None + + +class MinioSDK: + """MinIO 对象存储 SDK""" + + def __init__( + self, + end_point: str, + access_key: str, + secret_key: str, + bucket: str = "hackrobot", + use_ssl: bool = True, + port: Optional[int] = None, + region: str = "china", + public_url: Optional[str] = None, + upload_prefix: str = "joins", + ): + if not MINIO_AVAILABLE: + raise ImportError("minio 未安装,请执行: pip install minio") + self.end_point = end_point + self.port = port or (443 if use_ssl else 80) + self.access_key = access_key + self.secret_key = secret_key + self.bucket = bucket + self.use_ssl = use_ssl + self.region = region + self.public_url = public_url or f"https://{end_point}/{bucket}" + self.upload_prefix = upload_prefix.rstrip("/") + self._client: Optional[Minio] = None + + def get_client(self) -> "Minio": + """获取 MinIO 客户端""" + if self._client is None: + endpoint = f"{self.end_point}:{self.port}" if self.port not in (80, 443) else self.end_point + self._client = Minio( + endpoint, + access_key=self.access_key, + secret_key=self.secret_key, + secure=self.use_ssl, + region=self.region, + ) + return self._client + + def generate_object_key(self, ext: str) -> str: + """生成上传用的对象键""" + ext = ext.lstrip(".") + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + return f"{self.upload_prefix}/{int(time.time() * 1000)}_{suffix}.{ext}" + + def upload_buffer( + self, + data: bytes, + object_key: str, + content_type: str = "application/octet-stream", + ) -> dict: + """ + 上传文件到 MinIO + :return: {"url": str, "object_key": str} + """ + client = self.get_client() + from io import BytesIO + stream = BytesIO(data) + client.put_object( + self.bucket, + object_key, + stream, + len(data), + content_type=content_type, + ) + url = f"{self.public_url.rstrip('/')}/{object_key}" + return {"url": url, "object_key": object_key} + + @property + def enabled(self) -> bool: + """是否启用(需配置 access_key/secret_key)""" + return bool(self.end_point and self.access_key and self.secret_key) diff --git a/app/sdk/payment/__init__.py b/app/sdk/payment/__init__.py new file mode 100644 index 0000000..8f06589 --- /dev/null +++ b/app/sdk/payment/__init__.py @@ -0,0 +1,15 @@ +""" +支付网关 SDK +支持 ZPAY、XorPay,通过配置切换 +""" +from app.payment.base import PaymentProvider +from app.payment.zpay import ZPayProvider +from app.payment.xorpay import XorPayProvider +from app.payment.factory import get_payment_provider + +__all__ = [ + "PaymentProvider", + "ZPayProvider", + "XorPayProvider", + "get_payment_provider", +] diff --git a/app/sdk/payment/__pycache__/__init__.cpython-312.pyc b/app/sdk/payment/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c74dc619d2ffe2137de1e29173bf4c5cb34ae820 GIT binary patch literal 511 zcmX@j%ge<81lEt%XJ#@oFgylvV1NnA`0M~=OlL@8h+;@#Okv7l%w>vVVg#|7bC`2k zqFBIemK@exwkS3*n>B|$mm`XUks+NSg>4aI6lW#7CP%3a&={^~Q`SG(J>&V_iBDTM zD+IfEg9M)SG%7>|I7U8hXpG1&3P`Mczo+MA!>kwk+h4Z!KHt0UX-E6B-bGw5LGICH zyu}Yxk(-)V5>S+1mYI@T1Qg#3SkB?8uPmYhjC6HK9pa<2bmy}qX3f6%tTm_T|n{i7BS#?Ez5l8}L?k!oPC%vKQb{hvV7%WVAQ?K Wpm~=8NSb_NVdZ9OWG~_assRA#qo9=l literal 0 HcmV?d00001 diff --git a/app/sdk/pocketbase.py b/app/sdk/pocketbase.py new file mode 100644 index 0000000..deb8ef0 --- /dev/null +++ b/app/sdk/pocketbase.py @@ -0,0 +1,69 @@ +""" +PocketBase SDK +数据库操作封装,支持配置注入 +""" +from typing import Optional + +from pocketbase import PocketBase + + +class PocketBaseSDK: + """PocketBase 客户端 SDK""" + + def __init__( + self, + url: str, + admin_email: str, + admin_password: str, + ): + self.url = url + self.admin_email = admin_email + self.admin_password = admin_password + self._client: Optional[PocketBase] = None + + def get_client(self) -> PocketBase: + """获取已认证的 PocketBase 客户端""" + if self._client is None: + self._client = PocketBase(self.url) + self._client.admins.auth_with_password(self.admin_email, self.admin_password) + return self._client + + def collection(self, name: str): + """获取集合""" + return self.get_client().collection(name) + + def create_payment(self, user_id: str, pay_type: str, order_id: str, amount: int) -> dict: + """创建支付记录""" + return self.collection("payments").create({ + "user_id": user_id, + "type": pay_type, + "order_id": order_id, + "amount": amount, + }) + + def update_meetup_payment( + self, + user_id: str, + order_id: str, + amount: int, + ) -> None: + """更新或创建 meetup 支付记录""" + solan = self.collection("solan") + existing = solan.get_list(1, 1, { + "filter": f'user_id = "{user_id}"', + "sort": "-created", + }) + if existing.items: + record_id = existing.items[0].id + solan.update(record_id, { + "type": "meetup", + "order_id": order_id, + "amount": amount, + }) + else: + solan.create({ + "user_id": user_id, + "type": "meetup", + "order_id": order_id, + "amount": amount, + }) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..7d62c10 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,16 @@ +""" +共享业务逻辑,基于 SDK 封装 +- PocketBase、Memos 通过 app.sdk 使用 +- 仅 nomadvip 涉及 Memos +""" +from .pb import get_pb_client +from .memos import create_memos_user +from .payment import handle_payment_success, parse_order_id, processed_orders + +__all__ = [ + "get_pb_client", + "create_memos_user", + "handle_payment_success", + "parse_order_id", + "processed_orders", +] diff --git a/app/services/__pycache__/__init__.cpython-312.pyc b/app/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08b55070a9635b46996f5fbbf9701ddb717a55b1 GIT binary patch literal 529 zcmZ8dJ!=#}7@pbBbLB#8EOiT;1Cnf zUZ`*}+L`ZdheU`KMK{Cx-QKtRPqnn(orL{@6S|d&@ayZF&7Eo3I)a6M4~lTrU1ueT zHAwS}^-JA2kxE8N2HXsWfnqYUtCKfKpXLk%8FDQHHAqvYRJA0CR16A9nS_j1%R(0B zg3-S{6}i&ZLDDhc8t@GG8m>k`^}txvb6R8`NnHKWvQg ePs_p1?*QRbpU|1lsP_@|emUN8YwXJGD6#r&+W@mQB)ihmN#8|x85DIBUga}bWv8jOtu`CSZe6zB;J2QMUYgryV z7_1S|Ay6pj(m^q(TcAeWyKH#~K8&K)qLapY^3?lg*U1O-zBljx`@Q!u9#;_1!ym8n zIUb>(?B|fghETf=!V`3zr-WFj2x9accZZdUx|tb>l(Gc8Ug`$-AlQyZ7L`ySey>^{DYQHZnXiHm+Z~agC~V@#a{1 zcvR!49HfREqg2t0tc>pAX&NP#Um~{2;txp%Ap&2`1-69zLp_>5?K6Eqz7R^$P>O|8 zXuZpkD2M#eXN{}-yyDgxxr`$f#$kw2EkY&3&FdvnprY?gTQ-#}+n6X=SQ81$MA%LU zizloQ6($^~u!?FRqXX^o#jH!5Da-V;t}#31L9VRfx>?U6GkMeUvcdR$?ksKACoTU! z8sr$nv+2EFVDqRdwN|9$c1PECd)Iaw@DtCIpOgJB?{Bpa>`OvIDJ%O5YD!du4$=$} zR4PFYJ&&pE8lHE@A=rd#3W@a^0LxYlz&4;jXszKg6~Y_FpgA5};@Ky-r{NftuV> zJHMzspVXc=(uY$OwP#CBSA=vhFx*x2O#i^ybLTacO1h36Q`e~iVE37hoy$)i*-@Nn z;pF;)V)YHOUD6g^H*DMS4L|SLp03v$k9a;ItQit#jKLf9d>{`N9b76{L!=9I3_@=R z*j|L=IEse8q28~k`}@i6d8K^yiN83ug}V2mGM8AEYY3jd37)&oEqi;2y({K^@CNz+ E0Y%*Zr2qf` literal 0 HcmV?d00001 diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7985b9acef8d089a6156d3636c2ef5768e14d7f GIT binary patch literal 3423 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 MemosSDK: + global _memos_sdk + if _memos_sdk is None: + _memos_sdk = MemosSDK( + api_url=MEMOS_API, + token=MEMOS_TOKEN, + enabled=bool(MEMOS_API and MEMOS_TOKEN), + ) + return _memos_sdk + + +def create_memos_user(user_id: str, password: str = "123456"): + """创建 Memos 用户""" + return _get_memos_sdk().create_user(user_id=user_id, password=password) diff --git a/app/services/minio.py b/app/services/minio.py new file mode 100644 index 0000000..f66ecaa --- /dev/null +++ b/app/services/minio.py @@ -0,0 +1,51 @@ +"""MinIO 对象存储(基于 SDK)""" +from app.config import ( + MINIO_ENDPOINT, + MINIO_PORT, + MINIO_ACCESS_KEY, + MINIO_SECRET_KEY, + MINIO_BUCKET, + MINIO_USE_SSL, + MINIO_PUBLIC_URL, + MINIO_UPLOAD_PREFIX, +) +from app.sdk.minio import MinioSDK + +_minio_sdk: MinioSDK | None = None + + +def _get_minio_sdk() -> MinioSDK: + global _minio_sdk + if _minio_sdk is None: + _minio_sdk = MinioSDK( + end_point=MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + bucket=MINIO_BUCKET, + use_ssl=MINIO_USE_SSL, + port=MINIO_PORT, + public_url=MINIO_PUBLIC_URL, + upload_prefix=MINIO_UPLOAD_PREFIX, + ) + return _minio_sdk + + +def get_minio_client(): + """获取 MinIO 客户端(未配置时返回 None)""" + sdk = _get_minio_sdk() + if not sdk.enabled: + return None + return sdk.get_client() + + +def upload_buffer(data: bytes, object_key: str, content_type: str = "application/octet-stream"): + """上传文件到 MinIO(未配置时抛出异常)""" + sdk = _get_minio_sdk() + if not sdk.enabled: + raise RuntimeError("MinIO 未配置,请设置 MINIO_ACCESS_KEY、MINIO_SECRET_KEY") + return sdk.upload_buffer(data, object_key, content_type) + + +def generate_object_key(ext: str) -> str: + """生成上传用的对象键""" + return _get_minio_sdk().generate_object_key(ext) diff --git a/app/services/payment.py b/app/services/payment.py new file mode 100644 index 0000000..83ba4cd --- /dev/null +++ b/app/services/payment.py @@ -0,0 +1,86 @@ +"""支付成功后的统一业务处理(基于 SDK)""" +import logging + +from .pb import get_pb_client +from .memos import create_memos_user + +# 已处理订单(防重复) +processed_orders: set[str] = set() + + +def parse_order_id(order_id: str) -> tuple[str, str]: + """从 order_id 解析 user_id 和 pay_type""" + if "_order_" not in order_id: + return "", "unknown" + prefix = order_id.split("_order_")[0] + if "_" in prefix: + parts = prefix.rsplit("_", 1) + return parts[0], parts[1].lower() + return prefix, "unknown" + + +def handle_payment_success( + order_id: str, + pay_price: str, + provider_name: str, + *, + use_memos: bool = False, +): + """ + 支付成功后的统一业务处理 + :param use_memos: 是否创建 Memos 用户,仅 nomadvip 为 True + """ + 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}") + + if pay_type == "vip" and use_memos: + create_memos_user(user_id) + + if pay_type == "meetup": + logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}") + + valid_types = ["vip", "book", "meetup", "video"] + if pay_type not in valid_types: + pay_type = "vip" + + amount_cents = int(float(pay_price or 0) * 100) + + try: + pb_client = get_pb_client() + if pay_type == "meetup": + solan = pb_client.collection("solan") + existing = solan.get_list(1, 1, { + "filter": f'user_id = "{user_id}"', + "sort": "-created", + }) + if existing.items: + record_id = existing.items[0].id + solan.update(record_id, { + "type": "meetup", + "order_id": order_id, + "amount": amount_cents, + }) + logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}") + else: + solan.create({ + "user_id": user_id, + "type": "meetup", + "order_id": order_id, + "amount": amount_cents, + }) + 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, + }) + processed_orders.add(order_id) + except Exception as e: + print(f"PocketBase 操作失败: {e}") + logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") + raise diff --git a/app/services/pb.py b/app/services/pb.py new file mode 100644 index 0000000..6b9e076 --- /dev/null +++ b/app/services/pb.py @@ -0,0 +1,24 @@ +"""PocketBase 客户端(基于 SDK)""" +from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD +from app.sdk.pocketbase import PocketBaseSDK + +_pb_sdk: PocketBaseSDK | None = None + + +def _get_pb_sdk() -> PocketBaseSDK: + global _pb_sdk + if _pb_sdk is None: + _pb_sdk = PocketBaseSDK( + url=PB_URL, + admin_email=PB_ADMIN_EMAIL, + admin_password=PB_ADMIN_PASSWORD, + ) + return _pb_sdk + + +def get_pb_client(): + """获取已认证的 PocketBase 客户端""" + sdk = _get_pb_sdk() + client = sdk.get_client() + print("PocketBase admin 登录成功") + return client diff --git a/requirements.txt b/requirements.txt index 08c6506..dcec336 100755 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ sqlalchemy pydantic python-multipart pocketbase -requests \ No newline at end of file +requests +minio \ No newline at end of file diff --git a/手机微信支付调试说明.md b/手机微信支付调试说明.md new file mode 100644 index 0000000..c99c778 --- /dev/null +++ b/手机微信支付调试说明.md @@ -0,0 +1,44 @@ +# 手机微信支付调试说明 + +前端(vipgroup 等)与 payjsapi 同机时,支付请求由服务端发起,应使用 `127.0.0.1:8700`。 + +## 1. 同机部署(vipgroup + payjsapi 都在本机) + +**无需配置**。默认使用 `http://127.0.0.1:8700`。 + +若 `.env.local` 中有 `PAYMENT_API_URL=http://0.0.0.0:8700`,请删除或改为: +```bash +PAYMENT_API_URL=http://127.0.0.1:8700 +``` +(0.0.0.0 无法作为连接目标,会导致立即失败) + +## 2. 确认 payjsapi 已启动 + +```bash +cd C:\project\payjsapi +python run.py +``` + +应监听 `0.0.0.0:8700`,可从局域网访问。 + +## 3. 确认防火墙放行 8700 端口 + +Windows 防火墙可能阻止手机访问 8700。若 vipgroup 与 payjsapi 同机,请求是服务端内部调用,一般无问题。 + +## 4. 查看控制台日志 + +支付失败时,vipgroup 的 Next.js 控制台会输出: + +``` +[pay] 请求失败: ... apiUrl: http://xxx:8700 requestHost: xxx +``` + +- 若 `apiUrl` 为 `http://0.0.0.0:8700` → 在 `.env.local` 中设置 `PAYMENT_API_URL=http://192.168.41.222:8700` +- 若 `apiUrl` 正确但仍失败 → 检查 payjsapi 是否运行、网络是否可达 + +## 5. vipgroup 需包含的配置逻辑 + +若 vipgroup 与 lms/nomadlms 结构不同,需确保: + +1. 调用 `/api/pay` 或直接调用 payjsapi 时,使用正确的 `PAYMENT_API_URL` +2. 本地开发时 `PAYMENT_API_URL` 指向 `http://{你的局域网IP}:8700`