15 Commits
group ... cpay

Author SHA1 Message Date
eric
ae575f1828 's' 2026-03-12 03:58:06 -05:00
eric
7469869311 's' 2026-03-12 03:55:36 -05:00
eric
21d5b375cc ;'s' 2026-03-10 08:15:21 -05:00
eric
a3c2edd837 s'' 2026-03-10 08:11:14 -05:00
eric
82edb2dd7f s'' 2026-03-10 07:46:31 -05:00
eric
192097be0f s'' 2026-03-10 07:13:54 -05:00
eric
ddbf30652c 's' 2026-03-10 07:05:35 -05:00
eric
65d7047534 's' 2026-03-10 05:33:56 -05:00
eric
e5c797f5de 's' 2026-03-10 05:25:08 -05:00
eric
68aefbcee6 's' 2026-03-10 04:58:11 -05:00
eric
ab720d312b 's' 2026-03-10 04:55:15 -05:00
eric
7dbee54fc4 's' 2026-03-10 04:53:08 -05:00
eric
adeb111282 s'' 2026-03-10 04:42:05 -05:00
eric
d77f90cb8c 's' 2026-03-08 06:43:29 -05:00
eric
f725104c8a 's' 2026-03-08 01:24:59 -06:00
57 changed files with 2087 additions and 289 deletions

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# ========== 本地调试 ==========
# 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
# ========== 生产部署Ubuntu 等)==========
# 线上部署必须正确配置,否则会显示「支付失败」:
# 1. BASE_URL 必须为 payjsapi 公网地址ZPAY 需能访问此地址做异步回调
# 错误示例http://127.0.0.1:8700、http://localhostZPAY 无法访问)
# 正确示例https://api.hackrobot.cn域名需解析到本机并配置 nginx 反向代理)
# 2. 服务器需能访问 zpayz.cn检查防火墙、DNS可访问 /health?check_zpay=1 自检
BASE_URL=https://api.hackrobot.cn
# 支付渠道zpay已替代 xorpay| xorpay
PAYMENT_PROVIDER=zpay
# XorPay
XORPAY_AID=8220
XORPAY_SECRET=your_secret
# ZPAY网关: https://zpayz.cn/
ZPAY_PID=2025121809351743
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
# PocketBase
PB_URL=https://pocketbase.hackrobot.cn
PB_ADMIN_EMAIL=your@email.com
PB_ADMIN_PASSWORD=your_password
# 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

14
.gitignore vendored
View File

@@ -1,7 +1,13 @@
# 日志文件(不产生,避免 git 本地/线上不一致)
*.log
payment_notify.log
xorpay_notify.log
# Python bytecode
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*$py.class
*.py[cod]
venv/
.venv/
.env
.env.local

View File

@@ -1,6 +1,96 @@
# androidh5api
# PayJS API
支付 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 # 渠道工厂
```
## 支付渠道
| 渠道 | 环境变量 | 说明 |
|------|----------|------|
| XorPay | `PAYMENT_PROVIDER=xorpay` | 默认 |
| ZPAY | `PAYMENT_PROVIDER=zpay` | 需配置 `ZPAY_PID``ZPAY_KEY` |
切换渠道:设置环境变量 `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
```
## 线上部署
域名:`https://api.hackrobot.cn`
```bash
# .env 中设置
BASE_URL=https://api.hackrobot.cn
PAYMENT_PROVIDER=zpay
# 其他 ZPAY、PocketBase、Memos 配置见 .env.example
```
## 线上「支付失败」排查
本地正常、Ubuntu 部署后显示支付失败,常见原因:
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700``http://localhost`。正确:`https://api.hackrobot.cn`域名需解析到本机nginx 反向代理 8700 端口)。
2. **服务器无法访问 zpayz.cn**防火墙、DNS 或网络限制。访问 `https://你的域名/health?check_zpay=1` 自检,若 `zpayz_reachable: false` 则需开放出站或检查网络。
3. **启动时控制台有 BASE_URL 警告**:说明配置了内网地址,需修改 `.env` 中的 `BASE_URL` 为公网地址后重启。
## Getting started

Binary file not shown.

Binary file not shown.

Binary file not shown.

47
app/config.py Normal file
View File

@@ -0,0 +1,47 @@
"""
应用配置
支付渠道可通过 PAYMENT_PROVIDER 切换xorpay | zpay
"""
import os
from typing import Literal
PaymentChannel = Literal["xorpay", "zpay"]
# 当前使用的支付渠道zpay 替代 xorpaypayjs2 接口业务保留)
PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
# XorPay 配置
XORPAY_AID = os.getenv("XORPAY_AID", "8220")
XORPAY_SECRET = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
XORPAY_CASHIER_URL = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier")
# ZPAY 配置(网关: https://zpayz.cn/
ZPAY_PID = os.getenv("ZPAY_PID", "2025121809351743")
ZPAY_KEY = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
# PocketBase
PB_URL = os.getenv("PB_URL", "https://pocketbase.hackrobot.cn")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "xiaoshuang.eric@gmail.com")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "Xiao4669805@")
# Memos
MEMOS_API = os.getenv("MEMOS_API", "https://qun.hackrobot.cn/api/v1/users")
MEMOS_TOKEN = os.getenv(
"MEMOS_TOKEN",
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
)
# 回调地址(用于生成 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")

View File

@@ -1,16 +1,40 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import hashlib
import time
import random
import string
import requests
from fastapi import Request, HTTPException
import logging
from pocketbase import PocketBase
"""
支付 API 主入口
支持多支付渠道XorPay、ZPAY通过 PAYMENT_PROVIDER 环境变量切换
app = FastAPI()
路由模块化,按项目拆分:
- 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
线上部署BASE_URL=https://api.hackrobot.cn必须为 ZPAY 可公网访问的地址)
"""
import logging
import requests
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import BASE_URL
from .routers import (
nomadvip_router,
digital_router,
cnomadcna_router,
nomadlms_router,
legacy_router,
common_router,
)
app = FastAPI(
title="PayJS API",
description="nomadvip / digital / cnomadcna / nomadlms 支付接口",
version="2.0",
)
app.add_middleware(
CORSMiddleware,
@@ -20,291 +44,75 @@ app.add_middleware(
allow_headers=["*"],
)
# ==================== PocketBase 配置 ====================
PB_URL = "https://pocketbase.hackrobot.cn"
PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com"
PB_ADMIN_PASSWORD = "Xiao4669805@"
# ========================================================
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
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""捕获未处理异常,记录日志后返回 500HTTPException 交由 FastAPI 默认处理)"""
if isinstance(exc, HTTPException):
raise exc
logging.error(f"未处理异常: {request.url.path} - {exc}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": str(exc)})
# 配置日志(添加控制台输出,便于调试
# 日志(控制台,不写文件,避免 git 本地/线上不一致
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(message)s',
handlers=[
logging.FileHandler('xorpay_notify.log'),
logging.StreamHandler() # 新增:同时输出到控制台
]
format="%(asctime)s - %(message)s",
handlers=[logging.StreamHandler()],
)
# 全局已处理订单
processed_orders = set()
# 启动时校验 BASE_URL线上部署必须为公网可访问地址否则 ZPAY 无法回调)
def _check_base_url():
url = (BASE_URL or "").strip().lower()
if not url:
logging.warning("BASE_URL 未设置,将使用默认值。线上部署请务必设置 BASE_URL 为公网地址")
return
if "localhost" in url or "127.0.0.1" in url or url.startswith("http://192.168.") or url.startswith("http://10."):
logging.warning(
"BASE_URL=%s 疑似内网地址ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://api.hackrobot.cn",
BASE_URL,
)
# ==================== XorPay 配置 ====================
AID = "8220"
SECRET = "afcacd99570945f88de62624aaa3578e"
# ====================================================
# memos 配置
MEMOS_API = "https://qun.hackrobot.cn/api/v1/users"
MEMOS_TOKEN = "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg"
@app.on_event("startup")
async def startup_event():
_check_base_url()
def xorpay_sign(name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str:
raw = name + pay_type + price + order_id + notify_url + SECRET
return hashlib.md5(raw.encode('utf-8')).hexdigest().lower()
def verify_xorpay_notify(data: dict) -> bool:
received_sign = data.get("sign", "")
aoid = data.get("aoid", "")
order_id = data.get("order_id", "")
pay_price = data.get("pay_price", "")
pay_time = data.get("pay_time", "")
raw = aoid + order_id + pay_price + pay_time + SECRET
calculated_sign = hashlib.md5(raw.encode('utf-8')).hexdigest().lower()
return calculated_sign == received_sign
@app.get("/health")
async def health(check_zpay: str = ""):
"""健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性"""
out = {"status": "ok", "service": "payjsapi", "base_url": BASE_URL}
if str(check_zpay).lower() in ("1", "true", "yes"):
try:
r = requests.get("https://zpayz.cn", timeout=5)
out["zpayz_reachable"] = r.status_code in (200, 301, 302, 404)
except Exception as e:
out["zpayz_reachable"] = False
out["zpayz_error"] = str(e)
return out
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}")
# 挂载路由
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)
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)}
@app.post("/payh5")
async def payh5(item: dict):
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, "商品支付")
notify_url = "https://api.hackrobot.cn/xorpay_notify"
params = {
"name": name,
"pay_type": "jsapi",
"price": total_fee_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
params["sign"] = xorpay_sign(
params["name"],
params["pay_type"],
params["price"],
params["order_id"],
params["notify_url"]
)
print("生成的参数:", params)
@app.get("/")
async def root():
"""健康检查"""
return {
"status": "ok",
"xorpay_params": params,
"xorpay_url": f"https://xorpay.com/api/cashier/{AID}"
"service": "payjsapi",
"routes": {
"nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify",
"digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify",
"cnomadcna": "/cnomadcna/payh5, ...",
"nomadlms": "/nomadlms/payh5, ...",
"legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status",
"common": "/submit_meetup_application, /create_user",
},
}
@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")
@app.post("/xorpay_notify")
async def xorpay_notify(request: Request):
form_data = await request.form()
data = {key: value for key, value in form_data.items()}
logging.info(f"XorPay 异步通知原始数据: {data}")
print("XorPay 异步通知:", data)
order_id = data.get("order_id", "")
if order_id in processed_orders:
print(f"订单 {order_id} 已处理过,跳过")
return "ok"
if not verify_xorpay_notify(data):
print("验签失败")
logging.error(f"验签失败: {data}")
raise HTTPException(status_code=400, detail="sign error")
if "_order_" not in order_id:
raise HTTPException(status_code=400, detail="invalid order_id format")
prefix = order_id.split("_order_")[0]
if "_" in prefix:
parts = prefix.rsplit("_", 1)
user_id = parts[0]
pay_type = parts[1].lower()
else:
user_id = prefix
pay_type = "unknown"
print(f"支付成功用户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}")
try:
pb_client = get_pb_client()
valid_types = ["vip", "book", "meetup", "video"]
if pay_type not in valid_types:
print(f"无效的 pay_type: {pay_type},强制设为 vip")
pay_type = "vip"
amount_cents = int(float(data.get("pay_price", 0)) * 100)
if pay_type == "meetup":
solan = pb_client.collection("solan")
# 修复 filter 语法:添加空格和 '=' 操作符PocketBase 要求)
# 同时添加 sort 以取最新记录(防止同一 user_id 多条)
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}")
processed_orders.add(order_id) # 防止重复通知
return "ok"
class CreateUserRequest(BaseModel):
username: str = None
password: str = 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

17
app/payment/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
支付模块
支持多支付渠道,可通过配置切换
"""
from .base import PaymentProvider
from .xorpay import XorPayProvider
from .zpay import ZPayProvider
from .factory import get_payment_provider, get_payment_provider_by_name, reset_provider
__all__ = [
"PaymentProvider",
"XorPayProvider",
"ZPayProvider",
"get_payment_provider",
"get_payment_provider_by_name",
"reset_provider",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

59
app/payment/base.py Normal file
View File

@@ -0,0 +1,59 @@
"""
支付渠道抽象基类
定义统一的支付接口便于切换不同支付渠道XorPay、ZPAY 等)
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
class PaymentProvider(ABC):
"""支付渠道抽象基类"""
@property
@abstractmethod
def name(self) -> str:
"""支付渠道名称"""
pass
@abstractmethod
def create_order(
self,
*,
order_id: str,
name: str,
total_fee_yuan: str,
pay_type: str,
notify_url: str,
return_url: Optional[str] = None,
client_ip: Optional[str] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
创建支付订单
:return: 包含支付跳转信息等,格式由各渠道自定义
"""
pass
@abstractmethod
def verify_notify(self, data: Dict[str, Any]) -> bool:
"""验证异步通知签名"""
pass
@abstractmethod
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
解析异步通知,提取统一字段
:return: {
"order_id": str, # 商户订单号
"trade_no": str, # 渠道订单号
"pay_price": str, # 实付金额(元)
"pay_time": str, # 支付时间
"trade_status": str, # 支付状态
}
"""
pass
@abstractmethod
def success_response(self) -> str:
"""异步通知成功时需返回的字符串(如 'ok''success'"""
pass

51
app/payment/factory.py Normal file
View File

@@ -0,0 +1,51 @@
"""
支付渠道工厂
根据配置返回对应的支付 Provider
默认使用 zpay 替代 xorpay
"""
from ..config import (
PAYMENT_PROVIDER,
XORPAY_AID,
XORPAY_SECRET,
XORPAY_CASHIER_URL,
ZPAY_PID,
ZPAY_KEY,
)
from .base import PaymentProvider
from .xorpay import XorPayProvider
from .zpay import ZPayProvider
_provider_instance: PaymentProvider | None = None
def get_payment_provider() -> PaymentProvider:
"""获取当前配置的支付渠道实例(单例)"""
global _provider_instance
if _provider_instance is not None:
return _provider_instance
if PAYMENT_PROVIDER == "zpay":
_provider_instance = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY)
else:
_provider_instance = XorPayProvider(
aid=XORPAY_AID,
secret=XORPAY_SECRET,
cashier_url=XORPAY_CASHIER_URL,
)
return _provider_instance
def get_payment_provider_by_name(name: str) -> PaymentProvider:
"""按名称获取 Provider用于 per-request 覆盖)"""
if name == "xorpay":
return XorPayProvider(
aid=XORPAY_AID,
secret=XORPAY_SECRET,
cashier_url=XORPAY_CASHIER_URL,
)
return ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY)
def reset_provider():
"""重置 Provider用于测试或切换渠道"""
global _provider_instance
_provider_instance = None

82
app/payment/xorpay.py Normal file
View File

@@ -0,0 +1,82 @@
"""
XorPay 支付渠道实现
文档: https://xorpay.com
"""
import hashlib
from typing import Any, Dict, Optional
from .base import PaymentProvider
class XorPayProvider(PaymentProvider):
"""XorPay 支付"""
def __init__(self, aid: str, secret: str, cashier_url: str = "https://xorpay.com/api/cashier"):
self.aid = aid
self.secret = secret
self.cashier_url = cashier_url.rstrip("/")
@property
def name(self) -> str:
return "xorpay"
def _sign(self, name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str:
raw = name + pay_type + price + order_id + notify_url + self.secret
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
def create_order(
self,
*,
order_id: str,
name: str,
total_fee_yuan: str,
pay_type: str,
notify_url: str,
return_url: Optional[str] = None,
client_ip: Optional[str] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
# XorPay 使用 jsapi 表示 H5/JSAPI 支付
channel_pay_type = "jsapi"
params = {
"name": name,
"pay_type": channel_pay_type,
"price": total_fee_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
params["sign"] = self._sign(
params["name"],
params["pay_type"],
params["price"],
params["order_id"],
params["notify_url"],
)
return {
"status": "ok",
"provider": self.name,
"params": params,
"pay_url": f"{self.cashier_url}/{self.aid}",
}
def verify_notify(self, data: Dict[str, Any]) -> bool:
received_sign = data.get("sign", "")
aoid = data.get("aoid", "")
order_id = data.get("order_id", "")
pay_price = data.get("pay_price", "")
pay_time = data.get("pay_time", "")
raw = aoid + order_id + pay_price + pay_time + self.secret
calculated = hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
return calculated == received_sign
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
return {
"order_id": data.get("order_id", ""),
"trade_no": data.get("aoid", ""),
"pay_price": data.get("pay_price", ""),
"pay_time": data.get("pay_time", ""),
"trade_status": "success", # XorPay 验签通过即视为成功
}
def success_response(self) -> str:
return "ok"

207
app/payment/zpay.py Normal file
View File

@@ -0,0 +1,207 @@
"""
ZPAY 支付渠道实现
文档: https://z-pay.cn/doc.html
支持页面跳转支付、API 接口支付、异步通知验签
"""
import hashlib
import json
from typing import Any, Dict, Optional
import requests
from .base import PaymentProvider
class ZPayProvider(PaymentProvider):
"""ZPAY 支付(易支付兼容接口)"""
# API 地址
SUBMIT_URL = "https://zpayz.cn/submit.php" # 页面跳转
MAPI_URL = "https://zpayz.cn/mapi.php" # API 接口
def __init__(self, pid: str, key: str):
self.pid = pid
self.key = key
@property
def name(self) -> str:
return "zpay"
def _md5_sign(self, params: Dict[str, Any], exclude: Optional[set] = None) -> str:
"""
ZPAY MD5 签名算法:
1. 参数按 ASCII 排序sign/sign_type/空值不参与
2. 拼接为 a=b&c=d
3. sign = md5(拼接串 + KEY),小写
"""
exclude = exclude or {"sign", "sign_type"}
filtered = {k: v for k, v in params.items() if k not in exclude and v is not None and v != ""}
sorted_keys = sorted(filtered.keys())
parts = [f"{k}={filtered[k]}" for k in sorted_keys]
raw = "&".join(parts) + self.key
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
def create_order(
self,
*,
order_id: str,
name: str,
total_fee_yuan: str,
pay_type: str,
notify_url: str,
return_url: Optional[str] = None,
client_ip: Optional[str] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
# ZPAY: alipay / wxpaypay_type 可为业务类型或支付方式,此处按支付方式解析)
channel_type = "wxpay" if str(pay_type).lower() in ("wx", "wechat", "wxpay") else "alipay"
params = {
"pid": self.pid,
"type": channel_type,
"out_trade_no": order_id,
"notify_url": notify_url,
"name": name,
"money": total_fee_yuan,
"return_url": return_url or notify_url,
}
if client_ip:
params["clientip"] = client_ip
if extra and extra.get("param"):
params["param"] = str(extra["param"])
params["sign_type"] = "MD5"
params["sign"] = self._md5_sign(params)
# 页面跳转方式:返回 form 提交 URL 和参数
return {
"status": "ok",
"provider": self.name,
"params": params,
"pay_url": self.SUBMIT_URL,
"submit_method": "POST",
}
def create_order_api(
self,
*,
order_id: str,
name: str,
total_fee_yuan: str,
pay_type: str,
notify_url: str,
return_url: Optional[str] = None,
client_ip: str = "127.0.0.1",
device: str = "pc",
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
ZPAY API 接口支付(返回 payurl/qrcode 等)
适用于需要二维码或 H5 跳转的场景
"""
channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay"
params = {
"pid": self.pid,
"type": channel_type,
"out_trade_no": order_id,
"notify_url": notify_url,
"name": name,
"money": total_fee_yuan,
"clientip": client_ip or "127.0.0.1",
"device": device,
}
if return_url:
params["return_url"] = return_url
if extra and extra.get("param"):
params["param"] = str(extra["param"])
params["sign_type"] = "MD5"
params["sign"] = self._md5_sign(params)
try:
resp = requests.post(self.MAPI_URL, data=params, timeout=15)
except requests.RequestException as e:
return {
"status": "error",
"provider": self.name,
"msg": f"连接 zpayz.cn 失败: {e}。请检查服务器网络、防火墙或 DNS。",
}
try:
result = resp.json()
except Exception:
result = {}
# ZPAY 可能返回二次编码的 JSONbody 为 JSON 字符串),需再次解析
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
result = {}
if not isinstance(result, dict):
result = {}
# 部分环境下 ZPAY 将成功数据放在 msg 中msg 为 JSON 字符串)
msg_val = result.get("msg")
if isinstance(msg_val, str) and msg_val.strip().startswith("{"):
try:
inner = json.loads(msg_val)
if isinstance(inner, dict) and int(float(inner.get("code", 0))) == 1:
result = inner
except Exception:
pass
try:
code = int(float(result.get("code", 0)))
except (TypeError, ValueError):
code = 0
if code == 1:
return {
"status": "ok",
"provider": self.name,
"payurl": result.get("payurl"),
"payurl2": result.get("payurl2"), # 微信 H5手机浏览器打开可唤醒微信
"qrcode": result.get("qrcode"),
"img": result.get("img"),
"O_id": result.get("O_id"),
"trade_no": result.get("trade_no"),
}
return {
"status": "error",
"provider": self.name,
"msg": result.get("msg", resp.text),
}
def verify_notify(self, data: Dict[str, Any]) -> bool:
received = data.get("sign", "")
calculated = self._md5_sign(data)
return calculated == received
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
# ZPAY 通知trade_status 为 TRADE_SUCCESS 表示成功
status = "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending"
return {
"order_id": data.get("out_trade_no", ""),
"trade_no": data.get("trade_no", ""),
"pay_price": data.get("money", ""),
"pay_time": "", # ZPAY 通知未明确返回
"trade_status": status,
}
def success_response(self) -> str:
return "success"
def query_order_status(self, out_trade_no: str, timeout: int = 15) -> Dict[str, Any]:
"""查询 ZPAY 订单支付状态(供 nomadvip/digital/cnomadcna/nomadlms 共用)"""
url = f"https://zpayz.cn/api.php?act=order&pid={self.pid}&key={self.key}&out_trade_no={out_trade_no}"
try:
resp = requests.get(url, timeout=timeout)
result = resp.json() if resp.ok else {}
except Exception as 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}

23
app/routers/__init__.py Normal file
View File

@@ -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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,210 @@
"""
digital/cnomadcna/nomadlms 共享的支付逻辑
根据 prefix 生成对应 notify_path业务互不影响
"""
import logging
import random
import string
import time
from fastapi import APIRouter, Request, HTTPException
from ..config import BASE_URL
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, site_id: str | None = None) -> APIRouter:
"""
创建 digital/cnomadcna/nomadlms 的支付路由
prefix: 如 "/digital", "/cnomadcna", "/nomadlms"""legacy
project_name: 用于日志,如 "digital", "cnomadcna", "nomadlms", "legacy"
site_id: 站点标识,用于 site_vipdigital/meetup/vip
"""
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 not isinstance(provider, ZPayProvider):
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
result = provider.query_order_status(out_trade_no, timeout=15)
if result.get("error"):
logging.warning(f"{project_name} ZPAY 订单查询失败: {result.get('error')}")
return result
@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,
site_id=site_id,
)
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,
site_id=site_id,
)
return provider.success_response()
return router

4
app/routers/cnomadcna.py Normal file
View File

@@ -0,0 +1,4 @@
"""cnomadcna 项目支付接口:/cnomadcna/*"""
from ._digital_base import create_digital_router
router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna", site_id="meetup")

65
app/routers/common.py Normal file
View File

@@ -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

4
app/routers/digital.py Normal file
View File

@@ -0,0 +1,4 @@
"""digital 项目支付接口:/digital/*"""
from ._digital_base import create_digital_router
router = create_digital_router(prefix="/digital", project_name="digital", site_id="digital")

4
app/routers/legacy.py Normal file
View File

@@ -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")

4
app/routers/nomadlms.py Normal file
View File

@@ -0,0 +1,4 @@
"""nomadlms 项目支付接口:/nomadlms/*"""
from ._digital_base import create_digital_router
router = create_digital_router(prefix="/nomadlms", project_name="nomadlms")

224
app/routers/nomadvip.py Normal file
View File

@@ -0,0 +1,224 @@
"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay/zpayPAYMENT_PROVIDER"""
import logging
import random
import string
import time
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
from ..config import BASE_URL
from ..payment import get_payment_provider, get_payment_provider_by_name
from ..payment.zpay import ZPayProvider
from ..services import handle_payment_success, processed_orders
router = APIRouter(prefix="/nomadvip", tags=["nomadvip"])
def _get_notify_path(provider_name: str) -> str:
return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify" if provider_name == "xorpay" else f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
@router.post("/payh5")
async def nomadvip_payh5(item: dict):
"""nomadvip 专用:创建支付订单,支持 per-request provider 覆盖(微信用 xorpayPC/H5 用 zpay"""
print("nomadvip/payh5 item:", item)
req_provider = (item.get("provider") or "").strip().lower()
provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider()
total_fee = int(item.get("total_fee", 880))
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 = _get_notify_path(provider.name)
channel = item.get("channel", "alipay")
result = provider.create_order(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
notify_url=notify_url,
return_url=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 nomadvip_payh5_redirect(item: dict, request: Request):
"""nomadvip 专用ZPAY mapi 接口xorpay 无需 redirect直接 payh5 即可)"""
req_provider = (item.get("provider") or "").strip().lower()
provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider()
if not isinstance(provider, ZPayProvider):
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpayxorpay 请直接使用 payh5")
logging.info(f"nomadvip payh5/redirect 请求: item={item}")
try:
total_fee = float(item.get("total_fee", 880))
except (TypeError, ValueError):
logging.warning(f"nomadvip payh5/redirect 无效 total_fee: {item.get('total_fee')}")
total_fee = 880.0
total_fee_yuan = f"{total_fee / 100:.2f}"
user_id = item.get("user_id", "unknown")
pay_type = (item.get("type") or "vip").lower()
channel = item.get("channel", "alipay")
return_url = item.get("return_url")
device = item.get("device", "pc")
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
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"
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":
err_msg = result.get("msg", "ZPAY mapi 创建订单失败")
logging.error(f"nomadvip payh5/redirect ZPAY 创建订单失败: {err_msg}, result={result}")
raise HTTPException(status_code=500, detail=err_msg)
payurl = result.get("payurl")
payurl2 = result.get("payurl2")
img = result.get("img")
if device == "pc" and img:
return JSONResponse(status_code=200, content={
"status": "ok", "qrcode_mode": True, "order_id": order_id,
"img": img, "payurl": payurl, "return_url": return_url, "channel": channel,
})
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)
logging.error(f"nomadvip payh5/redirect ZPAY 未返回支付链接: device={device}, channel={channel}, result keys={list(result.keys())}")
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
@router.get("/zpay_order_status")
async def nomadvip_zpay_order_status(out_trade_no: str):
"""nomadvip 专用:查询 ZPAY 订单支付状态xorpay 无此接口)"""
provider = get_payment_provider()
if not isinstance(provider, ZPayProvider):
raise HTTPException(status_code=400, detail="zpay_order_status 仅支持 zpay")
result = provider.query_order_status(out_trade_no, timeout=15)
if result.get("error"):
logging.warning(f"nomadvip ZPAY 订单查询失败: {result.get('error')}")
return result
@router.get("/order_status")
async def nomadvip_order_status(order_id: str):
"""nomadvip 通用查询订单支付状态。zpay 先查 ZPAY 实时xorpay 或失败时查 PocketBase"""
zpay = get_payment_provider_by_name("zpay")
if isinstance(zpay, ZPayProvider):
result = zpay.query_order_status(order_id, timeout=15)
if not result.get("error") and result.get("paid"):
return result
# zpay 查不到或 xorpay查 PocketBasenotify 后写入)
try:
from ..services.pb import get_pb_client
pb = get_pb_client()
records = pb.collection("payments").get_list(1, 1, {"filter": f'order_id = "{order_id}"'})
return {"paid": len(records.items) > 0}
except Exception as e:
logging.warning(f"nomadvip PocketBase 查询失败: {e}")
return {"paid": False, "error": str(e)}
@router.post("/xorpay_notify")
async def nomadvip_xorpay_notify(request: Request):
"""nomadvip 专用XorPay 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER"""
form_data = await request.form()
data = {k: v for k, v in form_data.items()}
logging.info(f"nomadvip XorPay 异步通知: {data}")
order_id = data.get("order_id", "")
if order_id in processed_orders:
logging.info(f"nomadvip 订单已处理,跳过: {order_id}")
return "ok"
provider = get_payment_provider_by_name("xorpay")
if not provider.verify_notify(data):
logging.error(f"nomadvip XorPay 验签失败: {data}")
raise HTTPException(status_code=400, detail="sign error")
parsed = provider.parse_notify(data)
if parsed.get("trade_status") != "success":
return "ok"
pay_price = parsed.get("pay_price", data.get("pay_price", ""))
try:
handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True, site_id="vip")
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
except Exception as e:
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
raise
return provider.success_response()
@router.get("/zpay_notify")
@router.post("/zpay_notify")
async def nomadvip_zpay_notify(request: Request):
"""nomadvip 专用ZPAY 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER"""
provider = get_payment_provider_by_name("zpay")
if request.method == "GET":
data = dict(request.query_params)
else:
form_data = await request.form()
data = {k: v for k, v in form_data.items()}
logging.info(f"nomadvip ZPAY 异步通知: {data}")
order_id = data.get("out_trade_no", "")
if order_id in processed_orders:
logging.info(f"nomadvip 订单已处理,跳过: {order_id}")
return "success"
if not provider.verify_notify(data):
logging.error(f"nomadvip ZPAY 验签失败: {data}")
raise HTTPException(status_code=400, detail="sign error")
parsed = provider.parse_notify(data)
trade_status = parsed.get("trade_status") or data.get("trade_status", "")
if trade_status != "success" and trade_status != "TRADE_SUCCESS":
logging.info(f"nomadvip 非成功状态,跳过: trade_status={trade_status}")
return "success"
pay_price = parsed.get("pay_price") or data.get("money", "")
try:
handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True, site_id="vip")
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
except Exception as e:
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
raise
return provider.success_response()

270
app/routers/wxh5.py Normal file
View File

@@ -0,0 +1,270 @@
"""wxH5 项目支付接口:/wxh5/*,支持 xorpay/zpay支付成功后同步 Supabase wxgzh_vip_users"""
import logging
import random
import string
import time
from datetime import datetime, timedelta
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
from ..config import BASE_URL
from ..payment import get_payment_provider, get_payment_provider_by_name
from ..payment.zpay import ZPayProvider
from ..services import handle_payment_success, processed_orders
router = APIRouter(prefix="/wxh5", tags=["wxh5"])
# 套餐金额(分)与有效期映射
PLAN_MAP = {
"month": (2900, 30), # 月卡 29 元30 天
"year": (19900, 365), # 年卡 199 元365 天
"lifetime": (39900, 36500), # 终身 399 元100 年
}
def _infer_plan_from_amount(amount_cents: int) -> str:
"""根据支付金额(分)推断套餐"""
if amount_cents >= 39900:
return "lifetime"
if amount_cents >= 19900:
return "year"
return "month"
def _get_notify_path(provider_name: str) -> str:
base = BASE_URL.rstrip("/")
return f"{base}/wxh5/xorpay_notify" if provider_name == "xorpay" else f"{base}/wxh5/zpay_notify"
def _sync_wxh5_vip_to_supabase(user_id: str, plan: str):
"""支付成功后同步 VIP 到 Supabase wxgzh_vip_users"""
try:
from supabase import create_client
from ..config import SUPABASE_URL, SUPABASE_SERVICE_KEY
url = SUPABASE_URL
key = SUPABASE_SERVICE_KEY
if not url or not key:
logging.warning("wxh5: SUPABASE_URL/SUPABASE_SERVICE_KEY 未配置,跳过 Supabase 同步")
return
days = PLAN_MAP.get(plan, PLAN_MAP["year"])[1]
expire_at = (datetime.utcnow() + timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S+00:00")
client = create_client(url, key)
# upsert: 存在则更新 expire_at不存在则插入
client.table("wxgzh_vip_users").upsert(
{"user_id": user_id, "expire_at": expire_at},
on_conflict="user_id",
).execute()
logging.info(f"wxh5 Supabase VIP 同步成功: user_id={user_id}, plan={plan}")
except Exception as e:
logging.error(f"wxh5 Supabase 同步失败: {e}", exc_info=True)
@router.post("/payh5")
async def wxh5_payh5(item: dict):
"""wxH5 专用:创建支付订单,支持 per-request provider 覆盖"""
req_provider = (item.get("provider") or "").strip().lower()
provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider()
plan = (item.get("plan") or "year").lower()
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
total_fee = int(item.get("total_fee", total_fee))
total_fee_yuan = f"{total_fee / 100:.2f}"
user_id = item.get("user_id", "unknown")
pay_type = (item.get("type") or "vip").lower()
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
timestamp = int(time.time())
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
return_url = item.get("return_url", "")
if return_url and "order_id=" not in return_url:
sep = "&" if "?" in return_url else "?"
return_url = f"{return_url}{sep}order_id={order_id}"
type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"}
name = type_map.get(pay_type, "VIP会员充值")
notify_url = _get_notify_path(provider.name)
channel = item.get("channel", "alipay")
result = provider.create_order(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
notify_url=notify_url,
return_url=return_url,
client_ip=item.get("client_ip"),
)
if provider.name == "xorpay":
return {
"status": "ok",
"xorpay_params": result.get("params", {}),
"xorpay_url": result.get("pay_url", ""),
"provider": provider.name,
"order_id": order_id,
}
params = result.get("params", {})
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
return {
"status": "ok",
"params": params_str,
"pay_url": result.get("pay_url", ""),
"provider": provider.name,
"submit_method": "POST",
"order_id": order_id,
}
@router.post("/payh5/redirect")
async def wxh5_payh5_redirect(item: dict, request: Request):
"""wxH5 专用ZPAY mapi 接口xorpay 无需 redirect"""
req_provider = (item.get("provider") or "").strip().lower()
provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider()
if not isinstance(provider, ZPayProvider):
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
plan = (item.get("plan") or "year").lower()
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
try:
total_fee = int(item.get("total_fee", total_fee))
except (TypeError, ValueError):
total_fee = PLAN_MAP["year"][0]
total_fee_yuan = f"{total_fee / 100:.2f}"
user_id = item.get("user_id", "unknown")
pay_type = (item.get("type") or "vip").lower()
channel = item.get("channel", "alipay")
device = item.get("device", "pc")
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{random_suffix}"
return_url_raw = item.get("return_url", "")
if return_url_raw and "order_id=" not in return_url_raw:
sep = "&" if "?" in return_url_raw else "?"
return_url = f"{return_url_raw}{sep}order_id={order_id}"
else:
return_url = return_url_raw
type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"}
name = type_map.get(pay_type, "VIP会员充值")
notify_url = f"{BASE_URL.rstrip('/')}/wxh5/zpay_notify"
result = provider.create_order_api(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
notify_url=notify_url,
return_url=return_url,
client_ip=client_ip,
device=device,
)
if result.get("status") != "ok":
err_msg = result.get("msg", "ZPAY mapi 创建订单失败")
raise HTTPException(status_code=500, detail=err_msg)
payurl = result.get("payurl")
payurl2 = result.get("payurl2")
img = result.get("img")
if device == "pc" and img:
return JSONResponse(status_code=200, content={
"status": "ok", "qrcode_mode": True, "order_id": order_id,
"img": img, "payurl": payurl, "return_url": return_url, "channel": channel,
})
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
headers = {"X-Pay-Order-Id": order_id}
prefer_json = str(item.get("prefer_json", "")).lower() in ("1", "true", "yes")
if prefer_json:
target = payurl2 if (device == "h5" and channel.lower() in ("wxpay", "wx", "wechat")) else payurl
if target:
return JSONResponse(status_code=200, content={
"status": "ok", "redirect_url": target, "order_id": order_id, "provider": "zpay",
})
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
if payurl:
return RedirectResponse(url=payurl, status_code=302, headers=headers)
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
def _parse_wxh5_user_id(order_id: str) -> str:
"""从 order_id 解析 user_id格式: user_id_vip_order_ts"""
if "_order_" not in order_id:
return ""
prefix = order_id.split("_order_")[0]
if "_" in prefix:
return prefix.rsplit("_", 1)[0]
return prefix
def _handle_wxh5_payment_success(order_id: str, pay_price: str, provider_name: str):
"""wxH5 支付成功PocketBase + Supabase"""
user_id = _parse_wxh5_user_id(order_id)
if not user_id:
return
handle_payment_success(order_id, pay_price, provider_name, use_memos=False)
amount_cents = int(float(pay_price or 0) * 100)
plan = _infer_plan_from_amount(amount_cents)
_sync_wxh5_vip_to_supabase(user_id, plan)
@router.get("/order_status")
async def wxh5_order_status(order_id: str):
"""wxH5 通用:查询订单支付状态"""
zpay = get_payment_provider_by_name("zpay")
if isinstance(zpay, ZPayProvider):
result = zpay.query_order_status(order_id, timeout=15)
if not result.get("error") and result.get("paid"):
return result
try:
from ..services.pb import get_pb_client
pb = get_pb_client()
records = pb.collection("payments").get_list(1, 1, {"filter": f'order_id = "{order_id}"'})
return {"paid": len(records.items) > 0}
except Exception as e:
logging.warning(f"wxh5 PocketBase 查询失败: {e}")
return {"paid": False, "error": str(e)}
@router.post("/xorpay_notify")
async def wxh5_xorpay_notify(request: Request):
"""wxH5 XorPay 异步通知"""
form_data = await request.form()
data = {k: v for k, v in form_data.items()}
order_id = data.get("order_id", "")
if order_id in processed_orders:
return "ok"
provider = get_payment_provider_by_name("xorpay")
if not provider.verify_notify(data):
raise HTTPException(status_code=400, detail="sign error")
parsed = provider.parse_notify(data)
if parsed.get("trade_status") != "success":
return "ok"
try:
_handle_wxh5_payment_success(order_id, parsed.get("pay_price", ""), "wxh5-xorpay")
except Exception as e:
logging.error(f"wxh5 业务处理异常: {e}", exc_info=True)
raise
return provider.success_response()
@router.get("/zpay_notify")
@router.post("/zpay_notify")
async def wxh5_zpay_notify(request: Request):
"""wxH5 ZPAY 异步通知"""
provider = get_payment_provider_by_name("zpay")
if request.method == "GET":
data = dict(request.query_params)
else:
form_data = await request.form()
data = {k: v for k, v in form_data.items()}
order_id = data.get("out_trade_no", "")
if order_id in processed_orders:
return "success"
if not provider.verify_notify(data):
raise HTTPException(status_code=400, detail="sign error")
parsed = provider.parse_notify(data)
trade_status = parsed.get("trade_status") or data.get("trade_status", "")
if trade_status not in ("success", "TRADE_SUCCESS"):
return "success"
try:
_handle_wxh5_payment_success(order_id, parsed.get("pay_price") or data.get("money", ""), "wxh5-zpay")
except Exception as e:
logging.error(f"wxh5 业务处理异常: {e}", exc_info=True)
raise
return provider.success_response()

20
app/sdk/__init__.py Normal file
View File

@@ -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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

95
app/sdk/memos.py Normal file
View File

@@ -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)}

92
app/sdk/minio.py Normal file
View File

@@ -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)

View File

@@ -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",
]

Binary file not shown.

69
app/sdk/pocketbase.py Normal file
View File

@@ -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,
})

16
app/services/__init__.py Normal file
View File

@@ -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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
app/services/memos.py Normal file
View File

@@ -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)

51
app/services/minio.py Normal file
View File

@@ -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)

114
app/services/payment.py Normal file
View File

@@ -0,0 +1,114 @@
"""支付成功后的统一业务处理(基于 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,
site_id: str | None = None,
):
"""
支付成功后的统一业务处理
:param use_memos: 是否创建 Memos 用户,仅 nomadvip 为 True
:param site_id: 站点标识digital/meetup/vip用于写入 site_vip
"""
user_id, pay_type = parse_order_id(order_id)
if not user_id:
return
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {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,
})
# 写入 site_vip站点独立 VIP。meetup 类型统一写 site_id=meetup
effective_site_id = "meetup" if pay_type == "meetup" else site_id
if effective_site_id:
try:
site_vip = pb_client.collection("site_vip")
existing_vip = site_vip.get_list(1, 1, {
"filter": f'user_id = "{user_id}" && site_id = "{effective_site_id}"',
})
if existing_vip.items:
site_vip.update(existing_vip.items[0].id, {
"order_id": order_id,
"expires_at": "",
})
logging.info(f"site_vip 更新成功 - user_id: {user_id}, site_id: {effective_site_id}")
else:
site_vip.create({
"user_id": user_id,
"site_id": effective_site_id,
"order_id": order_id,
"expires_at": "",
})
logging.info(f"site_vip 创建成功 - user_id: {user_id}, site_id: {effective_site_id}")
except Exception as e:
logging.warning(f"site_vip 写入失败(不影响主流程): {e}")
processed_orders.add(order_id)
except Exception as e:
print(f"PocketBase 操作失败: {e}")
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
raise

24
app/services/pb.py Normal file
View File

@@ -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

17
payment_notify.log Normal file
View File

@@ -0,0 +1,17 @@
2026-03-08 03:04:04,172 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:25:51,919 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:28:42,212 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:31:18,405 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:39:41,805 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:40:22,362 - ZPAY 땐데꿴璂呵겨: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
2026-03-10 05:01:08,564 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773135567826', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'pc', 'client_ip': '192.168.41.222'}
2026-03-10 05:02:23,523 - nomadvip ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-10 05:02:46,901 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773136912337', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'wechat', 'client_ip': '192.168.41.34'}
2026-03-10 05:03:26,325 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773136912337', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'wechat', 'client_ip': '192.168.41.34'}
2026-03-10 05:04:54,606 - nomadvip ZPAY 땐데꿴璂呵겨: ('Connection aborted.', ConnectionResetError(10054, 'An existing connection was forcibly closed by the remote host', None, 10054, None))
2026-03-10 05:06:39,639 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773136912337', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'wechat', 'client_ip': '192.168.41.34'}
2026-03-10 05:07:03,035 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773136912337', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'wechat', 'client_ip': '192.168.41.34'}
2026-03-10 05:07:37,981 - nomadvip ZPAY 땐데꿴璂呵겨: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
2026-03-10 05:08:31,484 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773136912337', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'wechat', 'client_ip': '192.168.41.34'}
2026-03-10 05:15:55,977 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773136912337', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://192.168.41.222:3000/paid', 'device': 'wechat', 'client_ip': '192.168.41.34'}
2026-03-10 05:22:16,735 - nomadvip payh5/redirect 헝헹: item={'user_id': 'user1773131395284', 'total_fee': 18800, 'type': 'vip', 'channel': 'wxpay', 'return_url': 'http://localhost:3000/paid', 'device': 'pc', 'client_ip': '127.0.0.1'}

View File

@@ -3,4 +3,6 @@ uvicorn
sqlalchemy
pydantic
python-multipart
pocketbase
pocketbase
requests
minio

View File

@@ -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`