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 97f31b8..2a3b8d9 100644 Binary files a/app/__pycache__/config.cpython-312.pyc and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index d1f3593..6783faf 100755 Binary files a/app/__pycache__/main.cpython-312.pyc and b/app/__pycache__/main.cpython-312.pyc differ 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 822b2c6..5ecee05 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 3bf336a..090f955 100644 Binary files a/app/payment/__pycache__/factory.cpython-312.pyc and b/app/payment/__pycache__/factory.cpython-312.pyc differ diff --git a/app/payment/__pycache__/zpay.cpython-312.pyc b/app/payment/__pycache__/zpay.cpython-312.pyc index 916fc7c..4d86fc2 100644 Binary files a/app/payment/__pycache__/zpay.cpython-312.pyc and b/app/payment/__pycache__/zpay.cpython-312.pyc differ diff --git a/app/payment/factory.py b/app/payment/factory.py index 4bdcbd7..8db89d9 100644 --- a/app/payment/factory.py +++ b/app/payment/factory.py @@ -1,6 +1,7 @@ """ 支付渠道工厂 根据配置返回对应的支付 Provider +默认使用 zpay 替代 xorpay """ from ..config import ( PAYMENT_PROVIDER, diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..1ab6d60 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,23 @@ +""" +项目路由模块 +- nomadvip: /nomadvip/* +- digital: /digital/* +- cnomadcna: /cnomadcna/* +- nomadlms: /nomadlms/* +- legacy: /payh5, /zpay_notify 等(向后兼容 digital/cnomadcna) +""" +from .nomadvip import router as nomadvip_router +from .digital import router as digital_router +from .cnomadcna import router as cnomadcna_router +from .nomadlms import router as nomadlms_router +from .legacy import router as legacy_router +from .common import router as common_router + +__all__ = [ + "nomadvip_router", + "digital_router", + "cnomadcna_router", + "nomadlms_router", + "legacy_router", + "common_router", +] diff --git a/app/routers/__pycache__/__init__.cpython-312.pyc b/app/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..fc737c6 Binary files /dev/null and b/app/routers/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/routers/__pycache__/_digital_base.cpython-312.pyc b/app/routers/__pycache__/_digital_base.cpython-312.pyc new file mode 100644 index 0000000..9df9190 Binary files /dev/null and b/app/routers/__pycache__/_digital_base.cpython-312.pyc differ diff --git a/app/routers/__pycache__/cnomadcna.cpython-312.pyc b/app/routers/__pycache__/cnomadcna.cpython-312.pyc new file mode 100644 index 0000000..6ca5955 Binary files /dev/null and b/app/routers/__pycache__/cnomadcna.cpython-312.pyc differ diff --git a/app/routers/__pycache__/common.cpython-312.pyc b/app/routers/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000..488ec7a Binary files /dev/null and b/app/routers/__pycache__/common.cpython-312.pyc differ diff --git a/app/routers/__pycache__/digital.cpython-312.pyc b/app/routers/__pycache__/digital.cpython-312.pyc new file mode 100644 index 0000000..a5279a2 Binary files /dev/null and b/app/routers/__pycache__/digital.cpython-312.pyc differ diff --git a/app/routers/__pycache__/legacy.cpython-312.pyc b/app/routers/__pycache__/legacy.cpython-312.pyc new file mode 100644 index 0000000..34c4906 Binary files /dev/null and b/app/routers/__pycache__/legacy.cpython-312.pyc differ diff --git a/app/routers/__pycache__/nomadlms.cpython-312.pyc b/app/routers/__pycache__/nomadlms.cpython-312.pyc new file mode 100644 index 0000000..07b2170 Binary files /dev/null and b/app/routers/__pycache__/nomadlms.cpython-312.pyc differ diff --git a/app/routers/__pycache__/nomadvip.cpython-312.pyc b/app/routers/__pycache__/nomadvip.cpython-312.pyc new file mode 100644 index 0000000..4c71459 Binary files /dev/null and b/app/routers/__pycache__/nomadvip.cpython-312.pyc differ 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 0000000..64d5f8c Binary files /dev/null and b/app/sdk/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/sdk/__pycache__/memos.cpython-312.pyc b/app/sdk/__pycache__/memos.cpython-312.pyc new file mode 100644 index 0000000..aa04cfc Binary files /dev/null and b/app/sdk/__pycache__/memos.cpython-312.pyc differ diff --git a/app/sdk/__pycache__/minio.cpython-312.pyc b/app/sdk/__pycache__/minio.cpython-312.pyc new file mode 100644 index 0000000..26baa09 Binary files /dev/null and b/app/sdk/__pycache__/minio.cpython-312.pyc differ diff --git a/app/sdk/__pycache__/pocketbase.cpython-312.pyc b/app/sdk/__pycache__/pocketbase.cpython-312.pyc new file mode 100644 index 0000000..a316c62 Binary files /dev/null and b/app/sdk/__pycache__/pocketbase.cpython-312.pyc differ 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 0000000..c74dc61 Binary files /dev/null and b/app/sdk/payment/__pycache__/__init__.cpython-312.pyc differ 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 0000000..08b5507 Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/services/__pycache__/memos.cpython-312.pyc b/app/services/__pycache__/memos.cpython-312.pyc new file mode 100644 index 0000000..a3e0ce6 Binary files /dev/null and b/app/services/__pycache__/memos.cpython-312.pyc differ diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc new file mode 100644 index 0000000..d7985b9 Binary files /dev/null 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 new file mode 100644 index 0000000..f7649a5 Binary files /dev/null and b/app/services/__pycache__/pb.cpython-312.pyc differ diff --git a/app/services/memos.py b/app/services/memos.py new file mode 100644 index 0000000..2003790 --- /dev/null +++ b/app/services/memos.py @@ -0,0 +1,21 @@ +"""Memos 用户创建(基于 SDK,仅 nomadvip 使用)""" +from app.config import MEMOS_API, MEMOS_TOKEN +from app.sdk.memos import MemosSDK + +_memos_sdk: MemosSDK | None = None + + +def _get_memos_sdk() -> 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`