'init'
This commit is contained in:
23
.env
Normal file
23
.env
Normal file
@@ -0,0 +1,23 @@
|
||||
# ========== 生产环境配置 ==========
|
||||
|
||||
BASE_URL=https://api.hackrobot.cn
|
||||
PAYMENT_PROVIDER=zpay
|
||||
|
||||
# ZPAY(请填入实际值)
|
||||
ZPAY_PID=2025121809351743
|
||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||
|
||||
# PocketBase(check-user、ensure-user 必需,否则报「服务配置错误」)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||
|
||||
# Memos(nomadvip 支付成功后创建账号)
|
||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
|
||||
|
||||
# MinIO(可选)
|
||||
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
||||
MINIO_PORT=443
|
||||
MINIO_BUCKET=hackrobot
|
||||
MINIO_UPLOAD_PREFIX=joins
|
||||
35
.env.example
Normal file
35
.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# ========== 本地调试 ==========
|
||||
# PORT: 默认 8007,与前端 PAYMENT_API_URL 一致
|
||||
# BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost/内网,
|
||||
# 可用 ngrok 等隧道:BASE_URL=https://xxx.ngrok.io
|
||||
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
|
||||
PORT=8007
|
||||
BASE_URL=https://api.hackrobot.cn
|
||||
|
||||
# 支付渠道:zpay | xorpay
|
||||
PAYMENT_PROVIDER=zpay
|
||||
|
||||
# XorPay
|
||||
XORPAY_AID=8220
|
||||
XORPAY_SECRET=your_secret
|
||||
|
||||
# ZPAY(网关: https://zpayz.cn/)
|
||||
ZPAY_PID=2025121809351743
|
||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||
|
||||
# PocketBase(check-user、ensure-user、支付回调、nomadvip/digital/cnomadcna 共用)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||
|
||||
# Memos(nomadvip 支付成功后创建账号,与 payjsapi 同源)
|
||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
|
||||
|
||||
# MinIO 对象存储(可选)
|
||||
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
||||
MINIO_PORT=443
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_BUCKET=hackrobot
|
||||
MINIO_UPLOAD_PREFIX=joins
|
||||
17
.env.local
Normal file
17
.env.local
Normal file
@@ -0,0 +1,17 @@
|
||||
# 本地调试 - 端口 8007、zpay 渠道
|
||||
PORT=8007
|
||||
PAYMENT_PROVIDER=zpay
|
||||
BASE_URL=https://api.hackrobot.cn
|
||||
|
||||
# PocketBase(支付回调、nomadvip/digital/cnomadcna 共用)
|
||||
PB_URL=https://pocketbase.hackrobot.cn
|
||||
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||
|
||||
# Memos(nomadvip 支付成功后创建账号)
|
||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
|
||||
|
||||
# ZPAY
|
||||
ZPAY_PID=2025121809351743
|
||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# 日志文件(不产生,避免 git 本地/线上不一致)
|
||||
*.log
|
||||
payment_notify.log
|
||||
xorpay_notify.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
venv/
|
||||
.venv/
|
||||
# .env
|
||||
# .env.local
|
||||
102
README.md
102
README.md
@@ -1,6 +1,102 @@
|
||||
# salonapi
|
||||
# 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 部署后 api.hackrobot.cn 调用支付失败,常见原因:
|
||||
|
||||
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700`、`http://localhost`。正确:`https://api.hackrobot.cn`(域名需解析到本机,nginx 反向代理对应端口)。
|
||||
|
||||
2. **服务器无法访问 zpayz.cn**:防火墙、DNS 或网络限制。访问 `https://api.hackrobot.cn/health?check_zpay=1` 自检:
|
||||
- `zpayz_reachable: false` → 开放出站或检查网络
|
||||
- `zpayz_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启
|
||||
|
||||
3. **client_ip 内网导致 ZPAY 风控拒单**:前端(digital/cnomadcna/nomadvip)通过 nginx 代理时,需配置 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`,否则 payjsapi 收到 127.0.0.1 会触发风控。已做兼容:内网 IP 时自动省略 clientip 参数。
|
||||
|
||||
4. **启动时控制台有 BASE_URL 警告**:说明配置了内网地址,需修改 `.env` 中的 `BASE_URL` 为公网地址后重启。
|
||||
|
||||
5. **查看详细错误**:支付失败时,payjsapi 会输出 `ZPAY create_order_api 失败` 日志,含 status/code/msg,便于定位。
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -15,14 +111,14 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin http://gitlab.yidooplanet.com/gitlab-instance-0a899031/salonapi.git
|
||||
git remote add origin http://gitlab.yidooplanet.com/gitlab-instance-0a899031/androidh5api.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
|
||||
- [ ] [Set up project integrations](http://gitlab.yidooplanet.com/gitlab-instance-0a899031/salonapi/-/settings/integrations)
|
||||
- [ ] [Set up project integrations](http://gitlab.yidooplanet.com/gitlab-instance-0a899031/androidh5api/-/settings/integrations)
|
||||
|
||||
## Collaborate with your team
|
||||
|
||||
|
||||
1
androidh5api.sh
Normal file
1
androidh5api.sh
Normal file
@@ -0,0 +1 @@
|
||||
python3 run.py
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
59
app/config.py
Normal file
59
app/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
应用配置
|
||||
支付渠道可通过 PAYMENT_PROVIDER 切换:xorpay | zpay
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 强制从 payjsapi 项目根目录加载 .env、.env.local,避免 cwd 不同导致加载失败
|
||||
_root = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(_root / ".env")
|
||||
load_dotenv(_root / ".env.local", override=True)
|
||||
|
||||
PaymentChannel = Literal["xorpay", "zpay"]
|
||||
|
||||
# 当前使用的支付渠道(zpay 替代 xorpay,payjs2 接口业务保留)
|
||||
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_ADMIN_* 或 POCKETBASE_*,空值时用默认)
|
||||
PB_URL = os.getenv("PB_URL") or os.getenv("POCKETBASE_URL") or "https://pocketbase.hackrobot.cn"
|
||||
PB_ADMIN_EMAIL = (
|
||||
os.getenv("PB_ADMIN_EMAIL") or os.getenv("POCKETBASE_EMAIL") or "xiaoshuang.eric@gmail.com"
|
||||
)
|
||||
PB_ADMIN_PASSWORD = (
|
||||
os.getenv("PB_ADMIN_PASSWORD") or os.getenv("POCKETBASE_PASSWORD") or "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")
|
||||
188
app/main.py
Normal file
188
app/main.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
支付 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)
|
||||
线上部署:BASE_URL=https://api.hackrobot.cn(必须为 ZPAY 可公网访问的地址)
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
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,
|
||||
salon_router,
|
||||
legacy_router,
|
||||
common_router,
|
||||
meetup_router,
|
||||
salon_meetup_router,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="PayJS API",
|
||||
description="nomadvip / digital / cnomadcna / nomadlms 支付接口",
|
||||
version="2.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class _SafeStderrProxy:
|
||||
def write(self, message: str) -> int:
|
||||
try:
|
||||
return sys.stderr.write(message)
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
def flush(self) -> None:
|
||||
try:
|
||||
sys.stderr.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_log(level: int, message: str, *args, **kwargs) -> None:
|
||||
try:
|
||||
logging.log(level, message, *args, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""捕获未处理异常,记录日志后返回 500(HTTPException 交由 FastAPI 默认处理)"""
|
||||
if isinstance(exc, HTTPException):
|
||||
raise exc
|
||||
_safe_log(logging.ERROR, "未处理异常: %s - %s", request.url.path, exc, exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
|
||||
logging.raiseExceptions = False
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[logging.StreamHandler(_SafeStderrProxy())],
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def _safe_stderr_write(message: str) -> None:
|
||||
try:
|
||||
sys.stderr.write(message)
|
||||
sys.stderr.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _should_trace_request(path: str) -> bool:
|
||||
return any(
|
||||
segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms")
|
||||
)
|
||||
|
||||
# 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调)
|
||||
def _check_base_url():
|
||||
url = (BASE_URL or "").strip().lower()
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
_check_base_url()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health(check_zpay: str = ""):
|
||||
"""健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性(含 SSL、mapi 接口)"""
|
||||
out = {"status": "ok", "service": "payjsapi", "base_url": BASE_URL}
|
||||
if str(check_zpay).lower() in ("1", "true", "yes"):
|
||||
# 1) 检测 zpayz.cn 首页
|
||||
try:
|
||||
r = requests.get("https://zpayz.cn", timeout=5)
|
||||
out["zpayz_reachable"] = r.status_code in (200, 301, 302, 404)
|
||||
except requests.exceptions.SSLError as e:
|
||||
out["zpayz_reachable"] = False
|
||||
out["zpayz_error"] = f"SSL 错误: {e}(Ubuntu 请执行: apt install ca-certificates)"
|
||||
except requests.RequestException as e:
|
||||
out["zpayz_reachable"] = False
|
||||
out["zpayz_error"] = str(e)
|
||||
# 2) 检测 mapi 接口(实际支付调用地址)
|
||||
if out.get("zpayz_reachable"):
|
||||
try:
|
||||
r2 = requests.post("https://zpayz.cn/mapi.php", data={"pid": "test"}, timeout=5)
|
||||
out["zpayz_mapi_reachable"] = True
|
||||
out["zpayz_mapi_status"] = r2.status_code
|
||||
except requests.exceptions.SSLError as e:
|
||||
out["zpayz_mapi_reachable"] = False
|
||||
out["zpayz_mapi_error"] = f"SSL: {e}"
|
||||
except requests.RequestException as e:
|
||||
out["zpayz_mapi_reachable"] = False
|
||||
out["zpayz_mapi_error"] = str(e)
|
||||
return out
|
||||
|
||||
|
||||
# 请求日志中间件:每个请求都打印到 stderr,确保终端可见
|
||||
@app.middleware("http")
|
||||
async def log_requests(request, call_next):
|
||||
path = request.url.path
|
||||
if _should_trace_request(path):
|
||||
_safe_stderr_write(f"[payjsapi] >>> {request.method} {path}\n")
|
||||
resp = await call_next(request)
|
||||
if _should_trace_request(path):
|
||||
_safe_stderr_write(f"[payjsapi] <<< {request.method} {path} {resp.status_code}\n")
|
||||
return resp
|
||||
|
||||
# 挂载路由
|
||||
app.include_router(nomadvip_router)
|
||||
app.include_router(digital_router)
|
||||
app.include_router(cnomadcna_router)
|
||||
app.include_router(nomadlms_router)
|
||||
app.include_router(salon_router)
|
||||
app.include_router(legacy_router)
|
||||
app.include_router(common_router)
|
||||
app.include_router(meetup_router)
|
||||
app.include_router(salon_meetup_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""健康检查"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "payjsapi",
|
||||
"routes": {
|
||||
"nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/check_vip, /nomadvip/check_vip_by_email, /nomadvip/order_status, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify",
|
||||
"digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify",
|
||||
"cnomadcna": "/cnomadcna/payh5, ...",
|
||||
"nomadlms": "/nomadlms/payh5, ...",
|
||||
"legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status",
|
||||
"common": "/submit_meetup_application, /create_user",
|
||||
},
|
||||
}
|
||||
25
app/payment/__init__.py
Normal file
25
app/payment/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
支付模块
|
||||
支持多支付渠道,可通过配置切换
|
||||
"""
|
||||
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,
|
||||
resolve_provider,
|
||||
resolve_channel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PaymentProvider",
|
||||
"XorPayProvider",
|
||||
"ZPayProvider",
|
||||
"get_payment_provider",
|
||||
"get_payment_provider_by_name",
|
||||
"reset_provider",
|
||||
"resolve_provider",
|
||||
"resolve_channel",
|
||||
]
|
||||
59
app/payment/base.py
Normal file
59
app/payment/base.py
Normal 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
|
||||
79
app/payment/factory.py
Normal file
79
app/payment/factory.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
支付渠道工厂
|
||||
根据配置返回对应的支付 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
|
||||
|
||||
|
||||
def resolve_provider(
|
||||
device: str | None = None,
|
||||
req_provider: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> PaymentProvider:
|
||||
"""
|
||||
根据设备解析支付渠道:
|
||||
- 微信内(device=wechat 或 UA 含 MicroMessenger) → xorpay
|
||||
- PC/手机浏览器 → zpay
|
||||
显式传入的 provider 优先。
|
||||
"""
|
||||
req = (req_provider or "").strip().lower()
|
||||
if req in ("xorpay", "zpay"):
|
||||
return get_payment_provider_by_name(req)
|
||||
dev = (device or "pc").lower()
|
||||
if dev == "wechat":
|
||||
return get_payment_provider_by_name("xorpay")
|
||||
ua = (user_agent or "").lower()
|
||||
if "micromessenger" in ua:
|
||||
return get_payment_provider_by_name("xorpay")
|
||||
return get_payment_provider_by_name("zpay")
|
||||
|
||||
|
||||
def resolve_channel(item_channel: str | None) -> str:
|
||||
"""支付方式固定为微信支付,支付宝暂不提供"""
|
||||
return "wxpay"
|
||||
108
app/payment/xorpay.py
Normal file
108
app/payment/xorpay.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
XorPay 支付渠道实现
|
||||
文档: https://xorpay.com
|
||||
"""
|
||||
import hashlib
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
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 _query2_sign(self, order_id: str) -> str:
|
||||
raw = order_id + self.secret
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
*,
|
||||
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"
|
||||
|
||||
def query_order_status(self, order_id: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
url = f"https://xorpay.com/api/query2/{self.aid}"
|
||||
params = {
|
||||
"order_id": order_id,
|
||||
"sign": self._query2_sign(order_id),
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json() or {}
|
||||
status = str(data.get("status", "")).strip().lower()
|
||||
return {
|
||||
"paid": status in ("payed", "success"),
|
||||
"status": status,
|
||||
"pay_price": data.get("pay_price") or data.get("price") or "",
|
||||
"raw": data,
|
||||
}
|
||||
except Exception as error:
|
||||
return {"paid": False, "error": str(error)}
|
||||
249
app/payment/zpay.py
Normal file
249
app/payment/zpay.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
ZPAY 支付渠道实现
|
||||
文档: https://z-pay.cn/doc.html
|
||||
支持:页面跳转支付、API 接口支付、异步通知验签
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import PaymentProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_internal_ip(ip: Optional[str]) -> bool:
|
||||
"""判断是否为内网 IP,ZPAY 可能拒收导致 Ubuntu 部署失败"""
|
||||
if not ip or not ip.strip():
|
||||
return True
|
||||
ip = ip.strip()
|
||||
if ip in ("127.0.0.1", "localhost", "::1"):
|
||||
return True
|
||||
if ip.startswith("10.") or ip.startswith("192.168.") or ip.startswith("172.16."):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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 / wxpay(pay_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 and not _is_internal_ip(client_ip):
|
||||
params["clientip"] = client_ip
|
||||
elif client_ip and _is_internal_ip(client_ip):
|
||||
logger.warning("ZPAY create_order 跳过内网 client_ip=%s", 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"
|
||||
effective_ip = (client_ip or "").strip() or "127.0.0.1"
|
||||
# 内网 IP(127.0.0.1/10.x/192.168.x)可能导致 ZPAY 风控拒单,Ubuntu 部署时常见
|
||||
if _is_internal_ip(effective_ip):
|
||||
logger.warning(
|
||||
"ZPAY create_order_api client_ip=%s 为内网地址,已省略 clientip 参数(避免风控拒单)",
|
||||
effective_ip,
|
||||
)
|
||||
effective_ip = "" # 不传 clientip,由 ZPAY 从连接获取
|
||||
params = {
|
||||
"pid": self.pid,
|
||||
"type": channel_type,
|
||||
"out_trade_no": order_id,
|
||||
"notify_url": notify_url,
|
||||
"name": name,
|
||||
"money": total_fee_yuan,
|
||||
"device": device,
|
||||
}
|
||||
if effective_ip:
|
||||
params["clientip"] = effective_ip
|
||||
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.exceptions.SSLError as e:
|
||||
logger.error("ZPAY SSL 错误(Ubuntu 可能 CA 证书不全): %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.name,
|
||||
"msg": f"连接 zpayz.cn SSL 失败: {e}。Ubuntu 请安装 ca-certificates: apt install ca-certificates",
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
logger.error("ZPAY 连接失败: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.name,
|
||||
"msg": f"连接 zpayz.cn 失败: {e}。请检查服务器网络、防火墙或 DNS。",
|
||||
}
|
||||
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
result = {}
|
||||
# ZPAY 可能返回二次编码的 JSON(body 为 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"),
|
||||
}
|
||||
err_msg = result.get("msg", resp.text)
|
||||
logger.error(
|
||||
"ZPAY create_order_api 失败 status=%s code=%s msg=%s resp_text=%s",
|
||||
resp.status_code,
|
||||
code,
|
||||
err_msg,
|
||||
resp.text[:500] if resp.text else "",
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.name,
|
||||
"msg": err_msg,
|
||||
}
|
||||
|
||||
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}
|
||||
30
app/routers/__init__.py
Normal file
30
app/routers/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
项目路由模块
|
||||
- nomadvip: /nomadvip/*
|
||||
- digital: /digital/*
|
||||
- cnomadcna: /cnomadcna/*
|
||||
- nomadlms: /nomadlms/*
|
||||
- salon: /salon/*(克隆 cnomadcna)
|
||||
- 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 .salon import router as salon_router
|
||||
from .legacy import router as legacy_router
|
||||
from .common import router as common_router
|
||||
from .meetup import router as meetup_router
|
||||
from .salon_meetup import router as salon_meetup_router
|
||||
|
||||
__all__ = [
|
||||
"nomadvip_router",
|
||||
"digital_router",
|
||||
"cnomadcna_router",
|
||||
"nomadlms_router",
|
||||
"salon_router",
|
||||
"legacy_router",
|
||||
"common_router",
|
||||
"meetup_router",
|
||||
"salon_meetup_router",
|
||||
]
|
||||
311
app/routers/_digital_base.py
Normal file
311
app/routers/_digital_base.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
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,
|
||||
get_payment_provider_by_name,
|
||||
resolve_provider,
|
||||
resolve_channel,
|
||||
)
|
||||
from ..payment.zpay import ZPayProvider
|
||||
from ..services import handle_payment_success, processed_orders
|
||||
|
||||
|
||||
def _safe_log(level: int, message: str, *args, **kwargs) -> None:
|
||||
try:
|
||||
logging.log(level, message, *args, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter:
|
||||
"""
|
||||
创建 digital/cnomadcna/nomadlms 的支付路由
|
||||
prefix: 如 "/digital", "/cnomadcna", "/nomadlms" 或 ""(legacy)
|
||||
project_name: 用于日志,如 "digital", "cnomadcna", "nomadlms", "legacy"
|
||||
site_id: 站点标识,用于 site_vip(digital/meetup/vip)
|
||||
"""
|
||||
router = APIRouter(prefix=prefix, tags=[project_name])
|
||||
|
||||
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, request: Request):
|
||||
"""创建 H5 支付订单。默认微信支付,PC/手机用 zpay,微信内用 xorpay,支付宝暂不提供"""
|
||||
req_provider = (item.get("provider") or "").strip().lower() or None
|
||||
device = (item.get("device") or "pc").lower()
|
||||
ua = (request.headers.get("user-agent") or "").strip()
|
||||
provider = resolve_provider(device, req_provider, ua)
|
||||
channel = resolve_channel(item.get("channel"))
|
||||
user_id = item.get("user_id", "unknown")
|
||||
pay_type = (item.get("type") or "unknown").lower()
|
||||
_safe_log(
|
||||
logging.INFO,
|
||||
"[payjsapi] %s/payh5 user_id=%s type=%s provider=%s channel=%s",
|
||||
project_name,
|
||||
user_id,
|
||||
pay_type,
|
||||
provider.name,
|
||||
channel,
|
||||
)
|
||||
try:
|
||||
total_fee = int(item.get("total_fee", 80))
|
||||
except (TypeError, ValueError):
|
||||
total_fee = 80
|
||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||
timestamp = int(time.time())
|
||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||
return_url = item.get("return_url")
|
||||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||||
sep = "&" if "?" in return_url else "?"
|
||||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
"video": "视频解锁",
|
||||
"vip": "VIP会员充值",
|
||||
}
|
||||
name = type_map.get(pay_type, "商品支付")
|
||||
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
||||
notify_url = _notify_path(notify_path)
|
||||
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"),
|
||||
)
|
||||
_safe_log(
|
||||
logging.INFO,
|
||||
"[payjsapi] %s/payh5 created order_id=%s provider=%s notify_url=%s return_url=%s",
|
||||
project_name,
|
||||
order_id,
|
||||
provider.name,
|
||||
notify_url,
|
||||
return_url or "",
|
||||
)
|
||||
if provider.name == "xorpay":
|
||||
return {
|
||||
"status": "ok",
|
||||
"order_id": order_id,
|
||||
"xorpay_params": result.get("params", {}),
|
||||
"xorpay_url": result.get("pay_url", ""),
|
||||
"provider": provider.name,
|
||||
}
|
||||
params = result.get("params", {})
|
||||
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
|
||||
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 或二维码。PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
|
||||
provider = get_payment_provider_by_name("zpay")
|
||||
channel = resolve_channel(item.get("channel"))
|
||||
_safe_log(
|
||||
logging.INFO,
|
||||
"[payjsapi] %s/payh5/redirect user_id=%s type=%s device=%s channel=%s",
|
||||
project_name,
|
||||
item.get("user_id", ""),
|
||||
item.get("type", ""),
|
||||
item.get("device", "pc"),
|
||||
channel,
|
||||
)
|
||||
try:
|
||||
total_fee = float(item.get("total_fee", 80))
|
||||
except (TypeError, ValueError):
|
||||
total_fee = 80.0
|
||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||
user_id = item.get("user_id", "unknown")
|
||||
pay_type = item.get("type", "unknown").lower()
|
||||
return_url = item.get("return_url")
|
||||
device = item.get("device", "pc")
|
||||
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}"
|
||||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||||
sep = "&" if "?" in return_url else "?"
|
||||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||||
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()
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
"video": "视频解锁",
|
||||
"vip": "VIP会员充值",
|
||||
}
|
||||
name = type_map.get(pay_type, "商品支付")
|
||||
notify_url = _notify_path("/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":
|
||||
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
||||
_safe_log(
|
||||
logging.INFO,
|
||||
"[payjsapi] %s/payh5/redirect created order_id=%s device=%s channel=%s",
|
||||
project_name,
|
||||
order_id,
|
||||
device,
|
||||
channel,
|
||||
)
|
||||
payurl = result.get("payurl")
|
||||
payurl2 = result.get("payurl2")
|
||||
img = result.get("img")
|
||||
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"):
|
||||
_safe_log(logging.WARNING, "%s ZPAY 订单查询失败: %s", project_name, result.get("error"))
|
||||
return result
|
||||
|
||||
@router.get("/order_status")
|
||||
async def order_status(order_id: str = "", out_trade_no: str = ""):
|
||||
order_id = (order_id or out_trade_no or "").strip()
|
||||
if not order_id:
|
||||
return {"paid": False, "error": "missing order_id"}
|
||||
|
||||
try:
|
||||
from ..services.pb import get_pb_client
|
||||
|
||||
pb = get_pb_client()
|
||||
records = pb.collection("payments").get_list(
|
||||
1, 1, {"filter": f'order_id = "{order_id}"'}
|
||||
)
|
||||
if records.items:
|
||||
return {"paid": True, "source": "pocketbase"}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
||||
|
||||
try:
|
||||
xorpay = get_payment_provider_by_name("xorpay")
|
||||
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
||||
if xorpay_result.get("paid"):
|
||||
return {
|
||||
"paid": True,
|
||||
"source": "xorpay",
|
||||
"status": xorpay_result.get("status", ""),
|
||||
}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
||||
|
||||
try:
|
||||
zpay = get_payment_provider_by_name("zpay")
|
||||
if isinstance(zpay, ZPayProvider):
|
||||
zpay_result = zpay.query_order_status(order_id, timeout=8)
|
||||
if zpay_result.get("paid"):
|
||||
return {
|
||||
"paid": True,
|
||||
"source": "zpay",
|
||||
"status": zpay_result.get("status", ""),
|
||||
}
|
||||
except Exception as error:
|
||||
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
||||
|
||||
return {"paid": False}
|
||||
|
||||
@router.post("/xorpay_notify")
|
||||
async def xorpay_notify(request: Request):
|
||||
"""XorPay 异步通知"""
|
||||
form_data = await request.form()
|
||||
data = {k: v for k, v in form_data.items()}
|
||||
_safe_log(logging.INFO, "%s XorPay 异步通知: %s", project_name, data)
|
||||
order_id = data.get("order_id", "")
|
||||
if order_id in processed_orders:
|
||||
return "ok"
|
||||
provider = get_payment_provider_by_name("xorpay")
|
||||
if not provider.verify_notify(data):
|
||||
_safe_log(logging.ERROR, "%s XorPay 验签失败: %s", project_name, data)
|
||||
raise HTTPException(status_code=400, detail="sign error")
|
||||
parsed = provider.parse_notify(data)
|
||||
if parsed.get("trade_status") != "success":
|
||||
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()}
|
||||
_safe_log(logging.INFO, "%s ZPAY 异步通知: %s", project_name, data)
|
||||
order_id = data.get("out_trade_no", "")
|
||||
if order_id in processed_orders:
|
||||
return "success"
|
||||
provider = get_payment_provider_by_name("zpay")
|
||||
if not provider.verify_notify(data):
|
||||
_safe_log(logging.ERROR, "%s ZPAY 验签失败: %s", project_name, data)
|
||||
raise HTTPException(status_code=400, detail="sign error")
|
||||
parsed = provider.parse_notify(data)
|
||||
if parsed.get("trade_status") != "success":
|
||||
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
4
app/routers/cnomadcna.py
Normal 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
65
app/routers/common.py
Normal 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):
|
||||
"""数字游民社区报名表单"""
|
||||
logging.info("submit_meetup_application item: %s", item)
|
||||
user_id = item.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="missing user_id")
|
||||
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:
|
||||
logging.error("meetup 申请提交失败: %s", 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 "12345678"
|
||||
result = sdk.create_user_by_username(username=username, password=password)
|
||||
logging.info("memos_result- %s", result)
|
||||
return result
|
||||
4
app/routers/digital.py
Normal file
4
app/routers/digital.py
Normal 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
4
app/routers/legacy.py
Normal 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")
|
||||
328
app/routers/meetup.py
Normal file
328
app/routers/meetup.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
meetup 加入流程:check-user、ensure-user、complete-order
|
||||
供 cnomadcna 等前端调用,当 /api/* 代理到 payjsapi 时使用
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||
|
||||
router = APIRouter(prefix="/api/meetup", tags=["meetup"])
|
||||
DEFAULT_PASSWORD = "12345678" # PocketBase 默认要求至少 8 位
|
||||
|
||||
# Admin token 缓存,避免每次请求都向 PocketBase 认证(PB 远程时易超时)
|
||||
_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
|
||||
_TOKEN_CACHE_TTL = 300 # 5 分钟
|
||||
|
||||
|
||||
def _get_admin_token() -> tuple[str | None, str | None]:
|
||||
"""返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因。带 5 分钟缓存。"""
|
||||
global _ADMIN_TOKEN_CACHE
|
||||
now = time.time()
|
||||
if _ADMIN_TOKEN_CACHE and _ADMIN_TOKEN_CACHE[1] > now:
|
||||
return _ADMIN_TOKEN_CACHE[0], None
|
||||
|
||||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||
return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置"
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
# PocketBase v0.23+ 使用 _superusers,旧版用 admins
|
||||
for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base}{path}",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
token = data.get("token")
|
||||
if token:
|
||||
_ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL)
|
||||
return token, None
|
||||
if r.status_code == 404:
|
||||
continue # 尝试下一个路径
|
||||
try:
|
||||
msg = r.json().get("message", r.text[:100])
|
||||
except Exception:
|
||||
msg = r.text[:100]
|
||||
logging.warning(f"PocketBase admin auth failed: status={r.status_code}, msg={msg}")
|
||||
return None, f"PocketBase 管理员认证失败: {msg}"
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logging.error(f"PocketBase 连接失败: {e}")
|
||||
return None, f"无法连接 {PB_URL},请检查网络或 PB_URL"
|
||||
except Exception as e:
|
||||
logging.warning(f"PocketBase auth try {path}: {e}")
|
||||
return None, "PocketBase 管理员认证失败: 请确认 PB_URL 及管理员账号密码正确"
|
||||
|
||||
|
||||
class CheckUserRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class EnsureUserRequest(BaseModel):
|
||||
email: str
|
||||
password: str | None = None
|
||||
|
||||
|
||||
def _check_site_vip(user_id: str, site_id: str = "meetup", token: str | None = None) -> bool:
|
||||
"""检查用户是否有有效 site_vip(未过期)。可传入 token 避免重复认证。"""
|
||||
if not token:
|
||||
token, _ = _get_admin_token()
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
r = requests.get(
|
||||
f"{PB_URL}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'user_id = "{user_id}" && site_id = "{site_id}"',
|
||||
"perPage": 1,
|
||||
"sort": "-expires_at",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return False
|
||||
data = r.json()
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return False
|
||||
rec = items[0]
|
||||
exp = rec.get("expires_at")
|
||||
if not exp:
|
||||
return True
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone
|
||||
|
||||
exp_dt = _dt.fromisoformat(exp.replace("Z", "+00:00"))
|
||||
now = _dt.now(timezone.utc)
|
||||
if exp_dt.tzinfo is None:
|
||||
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||
return exp_dt > now
|
||||
except Exception:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/check-user")
|
||||
async def check_user(item: CheckUserRequest):
|
||||
"""检查邮箱是否已在 users 表存在,以及 VIP 状态(新/老用户判断)"""
|
||||
email = (item.email or "").strip()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="请输入邮箱")
|
||||
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
try:
|
||||
filter_val = f'email="{email}"'
|
||||
r = requests.get(
|
||||
f"{PB_URL}/api/collections/users/records",
|
||||
params={"filter": filter_val, "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="查询失败")
|
||||
data = r.json()
|
||||
items = data.get("items") or []
|
||||
exists = len(items) > 0
|
||||
user_id = items[0].get("id") if items else None
|
||||
vip = _check_site_vip(user_id, "meetup", token) if user_id else False
|
||||
return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"check-user error: {e}")
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
|
||||
|
||||
@router.post("/ensure-user")
|
||||
async def ensure_user(item: EnsureUserRequest):
|
||||
"""确保用户存在:老用户验证密码,新用户用默认密码 12345678 创建"""
|
||||
email = (item.email or "").strip()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="请输入邮箱")
|
||||
|
||||
password = (item.password or "").strip() or DEFAULT_PASSWORD
|
||||
|
||||
try:
|
||||
# 尝试用密码登录
|
||||
login_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": password},
|
||||
timeout=10,
|
||||
)
|
||||
if login_r.status_code == 200:
|
||||
data = login_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": data.get("record", {}).get("id"),
|
||||
"token": data.get("token"),
|
||||
"record": data.get("record"),
|
||||
"is_new": False,
|
||||
}
|
||||
|
||||
try:
|
||||
err_data = login_r.json()
|
||||
except Exception:
|
||||
err_data = {}
|
||||
is_not_found = login_r.status_code == 400 and (
|
||||
"Invalid" in str(err_data.get("message", ""))
|
||||
or "identity" in str(err_data.get("message", "")).lower()
|
||||
)
|
||||
if not is_not_found and item.password:
|
||||
raise HTTPException(status_code=400, detail="密码错误")
|
||||
|
||||
# 新用户:创建
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
create_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/records",
|
||||
json={
|
||||
"email": email,
|
||||
"password": DEFAULT_PASSWORD,
|
||||
"passwordConfirm": DEFAULT_PASSWORD,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if create_r.status_code != 200:
|
||||
try:
|
||||
create_err = create_r.json()
|
||||
except Exception:
|
||||
create_err = {}
|
||||
data = create_err.get("data", {})
|
||||
if (
|
||||
create_r.status_code == 400
|
||||
and "already" in str(data.get("email", {}).get("message", "")).lower()
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="该邮箱已注册,请输入密码登录")
|
||||
msg = create_err.get("message", "创建账号失败")
|
||||
logging.warning(f"ensure-user create failed: status={create_r.status_code}, msg={msg}, data={data}")
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
|
||||
# 登录新用户
|
||||
final_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": DEFAULT_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if final_r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="登录失败")
|
||||
|
||||
auth_data = final_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": auth_data.get("record", {}).get("id"),
|
||||
"token": auth_data.get("token"),
|
||||
"record": auth_data.get("record"),
|
||||
"is_new": True,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"ensure-user error: {e}")
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
|
||||
|
||||
def _decode_pending_email(user_id: str) -> str | None:
|
||||
if not user_id.startswith("pending_"):
|
||||
return None
|
||||
try:
|
||||
return bytes.fromhex(user_id[8:]).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _query_zpay_paid(order_id: str) -> bool:
|
||||
"""查询 ZPAY 订单是否已支付"""
|
||||
try:
|
||||
from ..payment import get_payment_provider
|
||||
from ..payment.zpay import ZPayProvider
|
||||
provider = get_payment_provider()
|
||||
if not isinstance(provider, ZPayProvider):
|
||||
return False
|
||||
result = provider.query_order_status(order_id, timeout=10)
|
||||
return bool(result.get("paid"))
|
||||
except Exception as e:
|
||||
logging.warning(f"complete-order zpay query failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/complete-order")
|
||||
async def complete_order(item: dict):
|
||||
"""
|
||||
支付成功后完成订单:验证订单、为新用户同步 session。
|
||||
支持 pending_ 订单(新用户)和已存在用户订单(user_id 为 PB id)。
|
||||
异步通知可能晚于用户回跳,故在 site_vip 未找到时轮询重试(ZPAY 已确认支付时)。
|
||||
"""
|
||||
order_id = (item.get("order_id") or "").strip()
|
||||
if not order_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 order_id")
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
from ..services.payment import parse_order_id
|
||||
|
||||
user_id, pay_type = parse_order_id(order_id)
|
||||
if not user_id or pay_type != "meetup":
|
||||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||||
|
||||
# 验证订单:site_vip 中需存在该 order_id(异步通知可能晚于用户回跳,ZPAY 已支付时轮询重试)
|
||||
max_retries = 4
|
||||
for attempt in range(max_retries):
|
||||
r = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={"filter": f'order_id = "{order_id}"', "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
total = (r.json().get("totalItems") or 0) if r.status_code == 200 else 0
|
||||
if total > 0:
|
||||
break
|
||||
if attempt < max_retries - 1 and _query_zpay_paid(order_id):
|
||||
time.sleep(2)
|
||||
continue
|
||||
resp_preview = r.text[:200] if r.ok else f"status={r.status_code}"
|
||||
logging.warning(f"complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}, pb_resp={resp_preview}")
|
||||
raise HTTPException(status_code=400, detail="订单不存在或未支付成功")
|
||||
|
||||
# pending_ 新用户:用邮箱+默认密码登录返回 token
|
||||
if user_id.startswith("pending_"):
|
||||
email = _decode_pending_email(user_id)
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||||
login_r = requests.post(
|
||||
f"{base}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": DEFAULT_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if login_r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="登录失败,请稍后重试")
|
||||
auth_data = login_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"token": auth_data.get("token"),
|
||||
"record": auth_data.get("record"),
|
||||
"is_new": True,
|
||||
}
|
||||
|
||||
# 已存在用户(PB user_id):订单已验证,返回 ok,前端已有 session 则无需 token
|
||||
return {"ok": True, "token": None, "record": None, "is_new": False}
|
||||
4
app/routers/nomadlms.py
Normal file
4
app/routers/nomadlms.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""nomadlms 项目支付接口:/nomadlms/*"""
|
||||
from ._digital_base import create_digital_router
|
||||
|
||||
router = create_digital_router(prefix="/nomadlms", project_name="nomadlms")
|
||||
883
app/routers/nomadvip.py
Normal file
883
app/routers/nomadvip.py
Normal file
@@ -0,0 +1,883 @@
|
||||
"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay / zpay。"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from ..config import BASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
|
||||
from ..payment import (
|
||||
get_payment_provider,
|
||||
get_payment_provider_by_name,
|
||||
resolve_provider,
|
||||
resolve_channel,
|
||||
)
|
||||
from ..payment.zpay import ZPayProvider
|
||||
from ..services import handle_payment_success, processed_orders
|
||||
|
||||
router = APIRouter(prefix="/nomadvip", tags=["nomadvip"])
|
||||
|
||||
|
||||
def _get_notify_path(provider_name: str) -> str:
|
||||
if provider_name == "xorpay":
|
||||
return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify"
|
||||
return f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
||||
|
||||
|
||||
@router.post("/payh5")
|
||||
async def nomadvip_payh5(item: dict, request: Request):
|
||||
"""创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||
req_provider = (item.get("provider") or "").strip().lower() or None
|
||||
device = (item.get("device") or "pc").lower()
|
||||
ua = (request.headers.get("user-agent") or "").strip()
|
||||
provider = resolve_provider(device, req_provider, ua)
|
||||
channel = resolve_channel(item.get("channel"))
|
||||
logging.info(
|
||||
"[payjsapi] payh5 user_id=%s type=%s provider=%s channel=%s",
|
||||
item.get("user_id", ""),
|
||||
item.get("type", ""),
|
||||
provider.name,
|
||||
channel,
|
||||
)
|
||||
|
||||
total_fee = int(item.get("total_fee", 880))
|
||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||
user_id = item.get("user_id", "unknown")
|
||||
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)
|
||||
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"),
|
||||
)
|
||||
logging.info(
|
||||
"[payjsapi] payh5 created order_id=%s provider=%s notify_url=%s return_url=%s",
|
||||
order_id,
|
||||
provider.name,
|
||||
notify_url,
|
||||
item.get("return_url") or "",
|
||||
)
|
||||
|
||||
if provider.name == "xorpay":
|
||||
return {
|
||||
"status": "ok",
|
||||
"order_id": order_id,
|
||||
"xorpay_params": result.get("params", {}),
|
||||
"xorpay_url": result.get("pay_url", ""),
|
||||
"provider": provider.name,
|
||||
}
|
||||
|
||||
params = result.get("params", {})
|
||||
params_str = {
|
||||
k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""
|
||||
}
|
||||
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):
|
||||
"""ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||
provider = get_payment_provider_by_name("zpay")
|
||||
channel = resolve_channel(item.get("channel"))
|
||||
|
||||
logging.info("nomadvip payh5/redirect request: item=%s", item)
|
||||
try:
|
||||
total_fee = float(item.get("total_fee", 880))
|
||||
except (TypeError, ValueError):
|
||||
logging.warning(
|
||||
"nomadvip payh5/redirect invalid total_fee=%s", item.get("total_fee")
|
||||
)
|
||||
total_fee = 880.0
|
||||
|
||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||
user_id = item.get("user_id", "unknown")
|
||||
pay_type = (item.get("type") or "vip").lower()
|
||||
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}"
|
||||
|
||||
# 追加 order_id,供 paid 页在 cookie 丢失时恢复 user_id。
|
||||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||||
sep = "&" if "?" in return_url else "?"
|
||||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||||
|
||||
type_map = {
|
||||
"book": "购买书籍",
|
||||
"meetup": "数字游民社区报名",
|
||||
"video": "视频解锁",
|
||||
"vip": "VIP会员充值",
|
||||
}
|
||||
name = type_map.get(pay_type, "VIP会员充值")
|
||||
notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
||||
|
||||
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(
|
||||
"nomadvip payh5/redirect ZPAY create failed: %s result=%s",
|
||||
err_msg,
|
||||
result,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=err_msg)
|
||||
|
||||
payurl = result.get("payurl")
|
||||
payurl2 = result.get("payurl2")
|
||||
img = result.get("img")
|
||||
if device == "pc" and img:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": "ok",
|
||||
"qrcode_mode": True,
|
||||
"order_id": order_id,
|
||||
"img": img,
|
||||
"payurl": payurl,
|
||||
"return_url": return_url,
|
||||
"channel": channel,
|
||||
},
|
||||
)
|
||||
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(
|
||||
"nomadvip payh5/redirect ZPAY missing pay url device=%s channel=%s keys=%s",
|
||||
device,
|
||||
channel,
|
||||
list(result.keys()),
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
||||
|
||||
|
||||
@router.get("/zpay_order_status")
|
||||
async def nomadvip_zpay_order_status(out_trade_no: str):
|
||||
"""查询 ZPAY 订单支付状态。"""
|
||||
provider = get_payment_provider()
|
||||
if not isinstance(provider, ZPayProvider):
|
||||
raise HTTPException(status_code=400, detail="zpay_order_status 仅支持 zpay")
|
||||
result = provider.query_order_status(out_trade_no, timeout=15)
|
||||
if result.get("error"):
|
||||
logging.warning(
|
||||
"nomadvip ZPAY order status failed: %s", result.get("error")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _get_linked_email_for_user(pb, user_id: str) -> str | None:
|
||||
"""按 user_id 查询 vip_email_link 关联邮箱。"""
|
||||
try:
|
||||
link_rec = pb.collection("vip_email_link").get_list(
|
||||
1, 1, {"filter": f'anonymous_user_id = "{user_id}"'}
|
||||
)
|
||||
if link_rec.items:
|
||||
return link_rec.items[0].get("email") or ""
|
||||
|
||||
if user_id and user_id.startswith("user") and user_id[4:].isdigit():
|
||||
link_rec = pb.collection("vip_email_link").get_list(
|
||||
1, 1, {"filter": f'user_id = "{user_id}"'}
|
||||
)
|
||||
if link_rec.items:
|
||||
return link_rec.items[0].get("email") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _uid_has_vip(pb, uid: str, site_id: str = "vip") -> bool:
|
||||
if not uid:
|
||||
return False
|
||||
|
||||
sv = pb.collection("site_vip").get_list(
|
||||
1, 1, {"filter": f'user_id = "{uid}" && site_id = "{site_id}"'}
|
||||
)
|
||||
if sv.items:
|
||||
rec = sv.items[0]
|
||||
expires = rec.get("expires_at") or ""
|
||||
if not expires:
|
||||
return True
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
||||
now = datetime.now(exp_dt.tzinfo) if exp_dt.tzinfo else datetime.now()
|
||||
return exp_dt.timestamp() >= now.timestamp()
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
pm = pb.collection("payments").get_list(
|
||||
1,
|
||||
1,
|
||||
{"filter": f'user_id = "{uid}" && type = "vip"', "sort": "-created"},
|
||||
)
|
||||
return bool(pm.items)
|
||||
|
||||
|
||||
@router.get("/check_vip")
|
||||
async def nomadvip_check_vip(user_id: str = ""):
|
||||
"""Return vip status for a user_id."""
|
||||
user_id = (user_id or "").strip()
|
||||
logging.info("[payjsapi] check_vip user_id=%s", user_id or "(empty)")
|
||||
if not user_id:
|
||||
return {"vip": False, "error": "missing user_id"}
|
||||
|
||||
try:
|
||||
from ..services.pb import get_pb_client
|
||||
|
||||
pb = get_pb_client()
|
||||
if not _uid_has_vip(pb, user_id):
|
||||
logging.info("[payjsapi] check_vip miss user_id=%s vip=False", user_id)
|
||||
return {"vip": False}
|
||||
|
||||
email = _get_linked_email_for_user(pb, user_id)
|
||||
if not email:
|
||||
admin_token = _get_pb_admin_token()
|
||||
if admin_token and user_id.startswith("user") and user_id[4:].isdigit():
|
||||
linked_member = _pb_find_linked_member_from_order(admin_token, user_id)
|
||||
if linked_member:
|
||||
linked_user_id, linked_email = linked_member
|
||||
email = linked_email
|
||||
_pb_upsert_email_link(admin_token, linked_email, linked_user_id, user_id)
|
||||
|
||||
logging.info(
|
||||
"[payjsapi] check_vip hit user_id=%s vip=True email=%s",
|
||||
user_id,
|
||||
email or "(none)",
|
||||
)
|
||||
return {"vip": True, "email": email}
|
||||
except Exception as error:
|
||||
logging.warning("[payjsapi] check_vip failed: %s", error)
|
||||
return {"vip": False, "error": str(error)}
|
||||
|
||||
def _get_pb_admin_token() -> str | None:
|
||||
"""获取 PocketBase 管理员 token。"""
|
||||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||
return None
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
for path in [
|
||||
"/api/collections/_superusers/auth-with-password",
|
||||
"/api/admins/auth-with-password",
|
||||
]:
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{base}{path}",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("token")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _pb_escape(value: str) -> str:
|
||||
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _pb_list_email_links(admin_token: str, email: str) -> list[dict]:
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
for candidate in [email, email.lower()]:
|
||||
safe_email = _pb_escape(candidate)
|
||||
response = requests.get(
|
||||
f"{base}/api/collections/vip_email_link/records",
|
||||
params={"filter": f'email = "{safe_email}"', "perPage": 1},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
|
||||
items = (response.json() or {}).get("items") or []
|
||||
if items:
|
||||
return items
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _pb_upsert_email_link(
|
||||
admin_token: str,
|
||||
email: str,
|
||||
pb_user_id: str,
|
||||
anonymous_user_id: str | None = None,
|
||||
):
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
items = _pb_list_email_links(admin_token, email)
|
||||
payload = {"email": email.lower(), "user_id": pb_user_id}
|
||||
if anonymous_user_id:
|
||||
payload["anonymous_user_id"] = anonymous_user_id
|
||||
|
||||
if items:
|
||||
record_id = items[0].get("id")
|
||||
if record_id:
|
||||
requests.patch(
|
||||
f"{base}/api/collections/vip_email_link/records/{record_id}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
return
|
||||
|
||||
requests.post(
|
||||
f"{base}/api/collections/vip_email_link/records",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def _pb_move_site_vip(
|
||||
admin_token: str,
|
||||
from_user_id: str,
|
||||
to_user_id: str,
|
||||
site_id: str = "vip",
|
||||
):
|
||||
if not from_user_id or not to_user_id or from_user_id == to_user_id:
|
||||
return
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
safe_from_user_id = _pb_escape(from_user_id)
|
||||
safe_site_id = _pb_escape(site_id)
|
||||
response = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"',
|
||||
"perPage": 500,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
items = (response.json() or {}).get("items") or []
|
||||
for item in items:
|
||||
record_id = item.get("id")
|
||||
if not record_id:
|
||||
continue
|
||||
requests.patch(
|
||||
f"{base}/api/collections/site_vip/records/{record_id}",
|
||||
json={"user_id": to_user_id},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def _pb_latest_vip_payment(admin_token: str, user_id: str) -> dict | None:
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
safe_user_id = _pb_escape(user_id)
|
||||
response = requests.get(
|
||||
f"{base}/api/collections/payments/records",
|
||||
params={
|
||||
"filter": f'user_id = "{safe_user_id}" && type = "vip"',
|
||||
"perPage": 1,
|
||||
"sort": "-created",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
items = (response.json() or {}).get("items") or []
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def _pb_list_site_vip_by_order(
|
||||
admin_token: str,
|
||||
order_id: str,
|
||||
site_id: str = "vip",
|
||||
) -> list[dict]:
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
safe_order_id = _pb_escape(order_id)
|
||||
safe_site_id = _pb_escape(site_id)
|
||||
response = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'order_id = "{safe_order_id}" && site_id = "{safe_site_id}"',
|
||||
"perPage": 500,
|
||||
"sort": "-created",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
|
||||
return (response.json() or {}).get("items") or []
|
||||
|
||||
|
||||
def _pb_get_user_email(admin_token: str, user_id: str) -> str | None:
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
response = requests.get(
|
||||
f"{base}/api/collections/users/records/{user_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
email = (response.json() or {}).get("email") or ""
|
||||
return email.strip().lower() or None
|
||||
|
||||
|
||||
def _pb_find_linked_member_from_order(
|
||||
admin_token: str,
|
||||
anonymous_user_id: str,
|
||||
) -> tuple[str, str] | None:
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
safe_user_id = _pb_escape(anonymous_user_id)
|
||||
response = requests.get(
|
||||
f"{base}/api/collections/payments/records",
|
||||
params={
|
||||
"filter": f'user_id = "{safe_user_id}" && type = "vip"',
|
||||
"perPage": 20,
|
||||
"sort": "-created",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
items = (response.json() or {}).get("items") or []
|
||||
seen_orders = set()
|
||||
for item in items:
|
||||
order_id = (item.get("order_id") or "").strip()
|
||||
if not order_id or order_id in seen_orders:
|
||||
continue
|
||||
seen_orders.add(order_id)
|
||||
|
||||
for site_vip in _pb_list_site_vip_by_order(admin_token, order_id):
|
||||
linked_user_id = (site_vip.get("user_id") or "").strip()
|
||||
if (
|
||||
not linked_user_id
|
||||
or linked_user_id == anonymous_user_id
|
||||
or (linked_user_id.startswith("user") and linked_user_id[4:].isdigit())
|
||||
):
|
||||
continue
|
||||
|
||||
email = _pb_get_user_email(admin_token, linked_user_id)
|
||||
if email:
|
||||
return linked_user_id, email
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _pb_ensure_site_vip(
|
||||
admin_token: str,
|
||||
from_user_id: str,
|
||||
to_user_id: str,
|
||||
site_id: str = "vip",
|
||||
):
|
||||
if not from_user_id or not to_user_id or from_user_id == to_user_id:
|
||||
return
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
safe_from_user_id = _pb_escape(from_user_id)
|
||||
safe_to_user_id = _pb_escape(to_user_id)
|
||||
safe_site_id = _pb_escape(site_id)
|
||||
source_records = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"',
|
||||
"perPage": 500,
|
||||
"sort": "-created",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
to_records = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'user_id = "{safe_to_user_id}" && site_id = "{safe_site_id}"',
|
||||
"perPage": 500,
|
||||
"sort": "-created",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
payment = _pb_latest_vip_payment(admin_token, from_user_id)
|
||||
order_id = (payment or {}).get("order_id") or ""
|
||||
order_records = (
|
||||
_pb_list_site_vip_by_order(admin_token, order_id, site_id=site_id)
|
||||
if order_id
|
||||
else []
|
||||
)
|
||||
source_items = (source_records.json() or {}).get("items") or [] if source_records.status_code == 200 else []
|
||||
target_items = (to_records.json() or {}).get("items") or [] if to_records.status_code == 200 else []
|
||||
canonical = (target_items or source_items or order_records or [None])[0]
|
||||
headers = {
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if canonical and canonical.get("id"):
|
||||
patch_body = {"user_id": to_user_id, "site_id": site_id}
|
||||
next_order_id = order_id or (canonical.get("order_id") or "").strip()
|
||||
if next_order_id:
|
||||
patch_body["order_id"] = next_order_id
|
||||
requests.patch(
|
||||
f"{base}/api/collections/site_vip/records/{canonical['id']}",
|
||||
json=patch_body,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
duplicate_ids = set()
|
||||
for record in [*target_items, *source_items, *order_records]:
|
||||
record_id = record.get("id")
|
||||
if record_id and record_id != canonical["id"]:
|
||||
duplicate_ids.add(record_id)
|
||||
for record_id in duplicate_ids:
|
||||
requests.delete(
|
||||
f"{base}/api/collections/site_vip/records/{record_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
return
|
||||
|
||||
if not order_id:
|
||||
return
|
||||
|
||||
requests.post(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
json={"user_id": to_user_id, "site_id": site_id, "order_id": order_id},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check_vip_by_email")
|
||||
async def nomadvip_check_vip_by_email(item: dict):
|
||||
"""Recover VIP by email/password, optionally with anonymous user_id."""
|
||||
email = (item.get("email") or "").strip().lower()
|
||||
password = (item.get("password") or "").strip()
|
||||
anonymous_user_id = (
|
||||
item.get("user_id") or item.get("anonymousUserId") or ""
|
||||
).strip()
|
||||
logging.info(
|
||||
"[payjsapi] check_vip_by_email email=%s user_id=%s",
|
||||
email or "(empty)",
|
||||
anonymous_user_id or "(empty)",
|
||||
)
|
||||
|
||||
if not email or not password:
|
||||
return {"vip": False, "error": "missing email or password"}
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
login_res = requests.post(
|
||||
f"{base}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": password},
|
||||
timeout=10,
|
||||
)
|
||||
if login_res.status_code != 200:
|
||||
try:
|
||||
error = login_res.json() or {}
|
||||
except Exception:
|
||||
error = {}
|
||||
logging.info(
|
||||
"[payjsapi] check_vip_by_email login failed status=%s message=%s",
|
||||
login_res.status_code,
|
||||
error.get("message", ""),
|
||||
)
|
||||
return {
|
||||
"vip": False,
|
||||
"error": error.get("message") or "invalid email or password",
|
||||
}
|
||||
|
||||
login_data = login_res.json() or {}
|
||||
token = login_data.get("token")
|
||||
record = login_data.get("record") or {}
|
||||
pb_user_id = (record.get("id") or "").strip()
|
||||
if not token or not pb_user_id:
|
||||
return {"vip": False, "error": "login failed"}
|
||||
|
||||
admin_token = _get_pb_admin_token()
|
||||
if not admin_token:
|
||||
return {"vip": False, "error": "PocketBase admin auth failed"}
|
||||
|
||||
try:
|
||||
from ..services.pb import get_pb_client
|
||||
|
||||
pb = get_pb_client()
|
||||
matched_user_id = None
|
||||
recovered_anonymous_user_id = (
|
||||
anonymous_user_id
|
||||
if anonymous_user_id.startswith("user") and anonymous_user_id[4:].isdigit()
|
||||
else None
|
||||
)
|
||||
recovery_source = None
|
||||
|
||||
if recovered_anonymous_user_id and _uid_has_vip(pb, recovered_anonymous_user_id):
|
||||
matched_user_id = recovered_anonymous_user_id
|
||||
recovery_source = "request_user_id"
|
||||
else:
|
||||
items = _pb_list_email_links(admin_token, email)
|
||||
logging.info(
|
||||
"[payjsapi] check_vip_by_email vip_email_link email=%s count=%s",
|
||||
email,
|
||||
len(items),
|
||||
)
|
||||
for link_record in items:
|
||||
linked_ids = []
|
||||
linked_pb_user_id = (link_record.get("user_id") or "").strip()
|
||||
linked_anonymous_user_id = (
|
||||
link_record.get("anonymous_user_id") or ""
|
||||
).strip()
|
||||
if linked_pb_user_id:
|
||||
linked_ids.append(linked_pb_user_id)
|
||||
if linked_anonymous_user_id and linked_anonymous_user_id not in linked_ids:
|
||||
linked_ids.append(linked_anonymous_user_id)
|
||||
|
||||
for linked_uid in linked_ids:
|
||||
if not _uid_has_vip(pb, linked_uid):
|
||||
continue
|
||||
matched_user_id = linked_uid
|
||||
if linked_anonymous_user_id.startswith("user") and linked_anonymous_user_id[4:].isdigit():
|
||||
recovered_anonymous_user_id = linked_anonymous_user_id
|
||||
elif linked_uid.startswith("user") and linked_uid[4:].isdigit():
|
||||
recovered_anonymous_user_id = linked_uid
|
||||
recovery_source = "vip_email_link"
|
||||
break
|
||||
|
||||
if matched_user_id:
|
||||
break
|
||||
|
||||
if not matched_user_id and _uid_has_vip(pb, pb_user_id):
|
||||
matched_user_id = pb_user_id
|
||||
recovered_anonymous_user_id = None
|
||||
recovery_source = "pb_user_id"
|
||||
|
||||
if not matched_user_id:
|
||||
logging.info("[payjsapi] check_vip_by_email no vip match for email=%s", email)
|
||||
return {
|
||||
"vip": False,
|
||||
"error": "no linked vip record found; provide the paid user_id if this is a historical anonymous order",
|
||||
}
|
||||
|
||||
if recovered_anonymous_user_id:
|
||||
_pb_ensure_site_vip(admin_token, recovered_anonymous_user_id, pb_user_id)
|
||||
_pb_upsert_email_link(
|
||||
admin_token,
|
||||
email,
|
||||
pb_user_id,
|
||||
recovered_anonymous_user_id,
|
||||
)
|
||||
else:
|
||||
_pb_upsert_email_link(admin_token, email, pb_user_id)
|
||||
|
||||
logging.info(
|
||||
"[payjsapi] check_vip_by_email recovered by %s matched_user_id=%s effectiveUserId=%s anonymousUserId=%s",
|
||||
recovery_source or "unknown",
|
||||
matched_user_id,
|
||||
pb_user_id,
|
||||
recovered_anonymous_user_id or "(none)",
|
||||
)
|
||||
return {
|
||||
"vip": True,
|
||||
"token": token,
|
||||
"record": record,
|
||||
"effectiveUserId": pb_user_id,
|
||||
"anonymousUserId": recovered_anonymous_user_id,
|
||||
}
|
||||
except Exception as error:
|
||||
logging.warning("[payjsapi] check_vip_by_email failed: %s", error)
|
||||
return {"vip": False, "error": str(error)}
|
||||
|
||||
@router.get("/order_status")
|
||||
async def nomadvip_order_status(order_id: str = "", out_trade_no: str = ""):
|
||||
"""查询订单支付状态,优先查 ZPAY,失败时回退 PocketBase。"""
|
||||
order_id = (order_id or out_trade_no or "").strip()
|
||||
logging.info("[payjsapi] order_status order_id=%s", order_id or "(空)")
|
||||
if not order_id:
|
||||
return {"paid": False, "error": "缺少 order_id 或 out_trade_no"}
|
||||
|
||||
try:
|
||||
from ..services.pb import get_pb_client
|
||||
|
||||
pb = get_pb_client()
|
||||
records = pb.collection("payments").get_list(
|
||||
1, 1, {"filter": f'order_id = "{order_id}"'}
|
||||
)
|
||||
paid = len(records.items) > 0
|
||||
logging.info("[payjsapi] order_status PocketBase paid=%s", paid)
|
||||
if paid:
|
||||
return {"paid": True, "source": "pocketbase"}
|
||||
except Exception as error:
|
||||
logging.warning("[payjsapi] order_status PocketBase failed: %s", error)
|
||||
|
||||
xorpay = get_payment_provider_by_name("xorpay")
|
||||
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
||||
if xorpay_result.get("error"):
|
||||
logging.warning(
|
||||
"[payjsapi] order_status XorPay query failed order_id=%s error=%s",
|
||||
order_id,
|
||||
xorpay_result.get("error"),
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"[payjsapi] order_status XorPay status=%s paid=%s",
|
||||
xorpay_result.get("status", ""),
|
||||
xorpay_result.get("paid", False),
|
||||
)
|
||||
if xorpay_result.get("paid"):
|
||||
return {
|
||||
"paid": True,
|
||||
"source": "xorpay",
|
||||
"status": xorpay_result.get("status"),
|
||||
"pay_price": xorpay_result.get("pay_price") or "",
|
||||
}
|
||||
|
||||
zpay = get_payment_provider_by_name("zpay")
|
||||
if isinstance(zpay, ZPayProvider):
|
||||
result = zpay.query_order_status(order_id, timeout=8)
|
||||
if not result.get("error") and result.get("paid"):
|
||||
logging.info("[payjsapi] order_status ZPAY paid=True")
|
||||
return result
|
||||
|
||||
return {"paid": False}
|
||||
|
||||
|
||||
@router.post("/xorpay_notify")
|
||||
async def nomadvip_xorpay_notify(request: Request):
|
||||
"""XorPay 异步通知。"""
|
||||
form_data = await request.form()
|
||||
data = {k: v for k, v in form_data.items()}
|
||||
logging.info("nomadvip XorPay notify: %s", data)
|
||||
order_id = data.get("order_id", "")
|
||||
if order_id in processed_orders:
|
||||
logging.info("nomadvip order already processed, skip: %s", order_id)
|
||||
return "ok"
|
||||
|
||||
provider = get_payment_provider_by_name("xorpay")
|
||||
if not provider.verify_notify(data):
|
||||
logging.error("nomadvip XorPay verify failed: %s", data)
|
||||
raise HTTPException(status_code=400, detail="sign error")
|
||||
|
||||
parsed = provider.parse_notify(data)
|
||||
if parsed.get("trade_status") != "success":
|
||||
return "ok"
|
||||
|
||||
pay_price = parsed.get("pay_price", data.get("pay_price", ""))
|
||||
try:
|
||||
handle_payment_success(
|
||||
order_id,
|
||||
pay_price,
|
||||
"nomadvip-xorpay",
|
||||
use_memos=True,
|
||||
site_id="vip",
|
||||
)
|
||||
logging.info("nomadvip business done: order_id=%s", order_id)
|
||||
except Exception as error:
|
||||
logging.error(
|
||||
"nomadvip PocketBase/Memos failed: %s", error, exc_info=True
|
||||
)
|
||||
raise
|
||||
return provider.success_response()
|
||||
|
||||
|
||||
@router.get("/zpay_notify")
|
||||
@router.post("/zpay_notify")
|
||||
async def nomadvip_zpay_notify(request: Request):
|
||||
"""ZPAY 异步通知。"""
|
||||
provider = get_payment_provider_by_name("zpay")
|
||||
if request.method == "GET":
|
||||
data = dict(request.query_params)
|
||||
else:
|
||||
form_data = await request.form()
|
||||
data = {k: v for k, v in form_data.items()}
|
||||
|
||||
logging.info("nomadvip ZPAY notify: %s", data)
|
||||
order_id = data.get("out_trade_no", "")
|
||||
if order_id in processed_orders:
|
||||
logging.info("nomadvip order already processed, skip: %s", order_id)
|
||||
return "success"
|
||||
|
||||
if not provider.verify_notify(data):
|
||||
logging.error("nomadvip ZPAY verify failed: %s", data)
|
||||
raise HTTPException(status_code=400, detail="sign error")
|
||||
|
||||
parsed = provider.parse_notify(data)
|
||||
trade_status = parsed.get("trade_status") or data.get("trade_status", "")
|
||||
if trade_status not in ("success", "TRADE_SUCCESS"):
|
||||
logging.info("nomadvip non-success trade status, skip: %s", trade_status)
|
||||
return "success"
|
||||
|
||||
pay_price = parsed.get("pay_price") or data.get("money", "")
|
||||
try:
|
||||
handle_payment_success(
|
||||
order_id,
|
||||
pay_price,
|
||||
"nomadvip-zpay",
|
||||
use_memos=True,
|
||||
site_id="vip",
|
||||
)
|
||||
logging.info("nomadvip business done: order_id=%s", order_id)
|
||||
except Exception as error:
|
||||
logging.error(
|
||||
"nomadvip PocketBase/Memos failed: %s", error, exc_info=True
|
||||
)
|
||||
raise
|
||||
return provider.success_response()
|
||||
4
app/routers/salon.py
Normal file
4
app/routers/salon.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""salon 项目接口:克隆 cnomadcna 的支付与 meetup 流程,使用 site_id=salon"""
|
||||
from ._digital_base import create_digital_router
|
||||
|
||||
router = create_digital_router(prefix="/salon", project_name="salon", site_id="salon")
|
||||
371
app/routers/salon_meetup.py
Normal file
371
app/routers/salon_meetup.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
salon 加入流程:check-user、ensure-user、complete-order
|
||||
克隆 meetup 逻辑,使用 site_id=salon
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||
|
||||
SITE_ID = "salon"
|
||||
router = APIRouter(prefix="/api/salon", tags=["salon"])
|
||||
DEFAULT_PASSWORD = "12345678"
|
||||
|
||||
_SALON_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
|
||||
_TOKEN_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _get_admin_token() -> tuple[str | None, str | None]:
|
||||
global _SALON_ADMIN_TOKEN_CACHE
|
||||
now = time.time()
|
||||
if _SALON_ADMIN_TOKEN_CACHE and _SALON_ADMIN_TOKEN_CACHE[1] > now:
|
||||
return _SALON_ADMIN_TOKEN_CACHE[0], None
|
||||
|
||||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||
return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置"
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base}{path}",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
token = data.get("token")
|
||||
if token:
|
||||
_SALON_ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL)
|
||||
return token, None
|
||||
if r.status_code == 404:
|
||||
continue
|
||||
try:
|
||||
msg = r.json().get("message", r.text[:100])
|
||||
except Exception:
|
||||
msg = r.text[:100]
|
||||
logging.warning(f"PocketBase admin auth failed: status={r.status_code}, msg={msg}")
|
||||
return None, f"PocketBase 管理员认证失败: {msg}"
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logging.error(f"PocketBase 连接失败: {e}")
|
||||
return None, f"无法连接 {PB_URL},请检查网络或 PB_URL"
|
||||
except Exception as e:
|
||||
logging.warning(f"PocketBase auth try {path}: {e}")
|
||||
return None, "PocketBase 管理员认证失败: 请确认 PB_URL 及管理员账号密码正确"
|
||||
|
||||
|
||||
class CheckUserRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class EnsureUserRequest(BaseModel):
|
||||
email: str
|
||||
password: str | None = None
|
||||
|
||||
|
||||
def _check_site_vip(user_id: str, token: str | None = None) -> bool:
|
||||
if not token:
|
||||
token, _ = _get_admin_token()
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{PB_URL}/api/collections/site_vip/records",
|
||||
params={
|
||||
"filter": f'user_id = "{user_id}" && site_id = "{SITE_ID}"',
|
||||
"perPage": 1,
|
||||
"sort": "-expires_at",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return False
|
||||
data = r.json()
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return False
|
||||
rec = items[0]
|
||||
exp = rec.get("expires_at")
|
||||
if not exp:
|
||||
return True
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone
|
||||
exp_dt = _dt.fromisoformat(exp.replace("Z", "+00:00"))
|
||||
now = _dt.now(timezone.utc)
|
||||
if exp_dt.tzinfo is None:
|
||||
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||
return exp_dt > now
|
||||
except Exception:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/check-user")
|
||||
async def check_user(item: CheckUserRequest):
|
||||
email = (item.email or "").strip()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="请输入邮箱")
|
||||
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
try:
|
||||
filter_val = f'email="{email}"'
|
||||
r = requests.get(
|
||||
f"{PB_URL}/api/collections/users/records",
|
||||
params={"filter": filter_val, "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=4,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="查询失败")
|
||||
data = r.json()
|
||||
items = data.get("items") or []
|
||||
exists = len(items) > 0
|
||||
user_id = items[0].get("id") if items else None
|
||||
vip = _check_site_vip(user_id, token) if user_id else False
|
||||
return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"salon check-user error: {e}")
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
|
||||
|
||||
@router.post("/ensure-user")
|
||||
async def ensure_user(item: EnsureUserRequest):
|
||||
email = (item.email or "").strip()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="请输入邮箱")
|
||||
|
||||
password = (item.password or "").strip() or DEFAULT_PASSWORD
|
||||
|
||||
try:
|
||||
login_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": password},
|
||||
timeout=10,
|
||||
)
|
||||
if login_r.status_code == 200:
|
||||
data = login_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": data.get("record", {}).get("id"),
|
||||
"token": data.get("token"),
|
||||
"record": data.get("record"),
|
||||
"is_new": False,
|
||||
}
|
||||
|
||||
try:
|
||||
err_data = login_r.json()
|
||||
except Exception:
|
||||
err_data = {}
|
||||
is_not_found = login_r.status_code == 400 and (
|
||||
"Invalid" in str(err_data.get("message", ""))
|
||||
or "identity" in str(err_data.get("message", "")).lower()
|
||||
)
|
||||
if not is_not_found and item.password:
|
||||
raise HTTPException(status_code=400, detail="密码错误")
|
||||
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
create_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/records",
|
||||
json={
|
||||
"email": email,
|
||||
"password": DEFAULT_PASSWORD,
|
||||
"passwordConfirm": DEFAULT_PASSWORD,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if create_r.status_code != 200:
|
||||
try:
|
||||
create_err = create_r.json()
|
||||
except Exception:
|
||||
create_err = {}
|
||||
data = create_err.get("data", {})
|
||||
if (
|
||||
create_r.status_code == 400
|
||||
and "already" in str(data.get("email", {}).get("message", "")).lower()
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="该邮箱已注册,请输入密码登录")
|
||||
msg = create_err.get("message", "创建账号失败")
|
||||
logging.warning(f"salon ensure-user create failed: status={create_r.status_code}, msg={msg}")
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
|
||||
final_r = requests.post(
|
||||
f"{PB_URL}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": DEFAULT_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if final_r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="登录失败")
|
||||
|
||||
auth_data = final_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"user_id": auth_data.get("record", {}).get("id"),
|
||||
"token": auth_data.get("token"),
|
||||
"record": auth_data.get("record"),
|
||||
"is_new": True,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"salon ensure-user error: {e}")
|
||||
raise HTTPException(status_code=500, detail="操作失败")
|
||||
|
||||
|
||||
def _decode_pending_email(user_id: str) -> str | None:
|
||||
if not user_id.startswith("pending_"):
|
||||
return None
|
||||
try:
|
||||
return bytes.fromhex(user_id[8:]).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _query_zpay_paid(order_id: str) -> bool:
|
||||
try:
|
||||
from ..payment import get_payment_provider
|
||||
from ..payment.zpay import ZPayProvider
|
||||
provider = get_payment_provider()
|
||||
if not isinstance(provider, ZPayProvider):
|
||||
return False
|
||||
result = provider.query_order_status(order_id, timeout=10)
|
||||
return bool(result.get("paid"))
|
||||
except Exception as e:
|
||||
logging.warning(f"salon complete-order zpay query failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/complete-order")
|
||||
async def complete_order(item: dict):
|
||||
order_id = (item.get("order_id") or "").strip()
|
||||
if not order_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 order_id")
|
||||
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
token, err = _get_admin_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail=f"服务配置错误:{err}")
|
||||
|
||||
from ..services.payment import parse_order_id
|
||||
|
||||
user_id, pay_type = parse_order_id(order_id)
|
||||
if not user_id or pay_type != "salon":
|
||||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||||
|
||||
max_retries = 4
|
||||
for attempt in range(max_retries):
|
||||
r = requests.get(
|
||||
f"{base}/api/collections/site_vip/records",
|
||||
params={"filter": f'order_id = "{order_id}" && site_id = "{SITE_ID}"', "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
total = (r.json().get("totalItems") or 0) if r.status_code == 200 else 0
|
||||
if total > 0:
|
||||
break
|
||||
if attempt < max_retries - 1 and _query_zpay_paid(order_id):
|
||||
time.sleep(2)
|
||||
continue
|
||||
resp_preview = r.text[:200] if r.ok else f"status={r.status_code}"
|
||||
logging.warning(f"salon complete-order 未找到订单: order_id={order_id}, attempt={attempt + 1}, pb_resp={resp_preview}")
|
||||
raise HTTPException(status_code=400, detail="订单不存在或未支付成功")
|
||||
|
||||
if user_id.startswith("pending_"):
|
||||
email = _decode_pending_email(user_id)
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="订单格式无效")
|
||||
login_r = requests.post(
|
||||
f"{base}/api/collections/users/auth-with-password",
|
||||
json={"identity": email, "password": DEFAULT_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if login_r.status_code != 200:
|
||||
raise HTTPException(status_code=500, detail="登录失败,请稍后重试")
|
||||
auth_data = login_r.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"token": auth_data.get("token"),
|
||||
"record": auth_data.get("record"),
|
||||
"is_new": True,
|
||||
}
|
||||
|
||||
return {"ok": True, "token": None, "record": None, "is_new": False}
|
||||
|
||||
|
||||
@router.get("/vip/check")
|
||||
async def vip_check(user_id: str = ""):
|
||||
"""检查用户是否为 salon VIP"""
|
||||
if not user_id:
|
||||
return {"vip": False}
|
||||
token, _ = _get_admin_token()
|
||||
return {"vip": _check_site_vip(user_id, token)}
|
||||
|
||||
|
||||
@router.get("/join/status")
|
||||
async def join_status(user_id: str = ""):
|
||||
"""检查用户是否已提交 salon 加入申请"""
|
||||
if not user_id:
|
||||
return {"hasRecord": False, "isComplete": False}
|
||||
try:
|
||||
from ..services import get_pb_client
|
||||
pb_client = get_pb_client()
|
||||
solan = pb_client.collection("solanRed")
|
||||
existing = solan.get_list(1, 1, {"filter": f'user_id = "{user_id}"', "sort": "-created"})
|
||||
has_record = len(existing.items) > 0
|
||||
rec = existing.items[0] if existing.items else None
|
||||
is_complete = bool(rec and rec.get("order_id") and rec.get("amount", 0) > 0)
|
||||
return {"hasRecord": has_record, "isComplete": is_complete}
|
||||
except Exception:
|
||||
return {"hasRecord": False, "isComplete": False}
|
||||
|
||||
|
||||
@router.post("/join")
|
||||
async def submit_join(item: dict):
|
||||
"""salon 加入社区报名表单,写入 solanRed 集合"""
|
||||
user_id = item.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="missing user_id")
|
||||
|
||||
try:
|
||||
from ..services import get_pb_client
|
||||
pb_client = get_pb_client()
|
||||
solan = pb_client.collection("solanRed")
|
||||
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", ""),
|
||||
"single": item.get("single", ""),
|
||||
"media": item.get("media", ""),
|
||||
"calculated_age": item.get("calculated_age", 0),
|
||||
"type": "",
|
||||
"order_id": "",
|
||||
"amount": 0,
|
||||
}
|
||||
record = solan.create(pb_data)
|
||||
logging.info(f"salon 申请表单提交成功 - user_id: {user_id}, record_id: {record.id}")
|
||||
return {"ok": True, "user_id": user_id, "record_id": record.id}
|
||||
except Exception as e:
|
||||
logging.error(f"salon join 提交失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="submit failed")
|
||||
274
app/routers/wxh5.py
Normal file
274
app/routers/wxh5.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""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,
|
||||
resolve_provider,
|
||||
resolve_channel,
|
||||
)
|
||||
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, request: Request):
|
||||
"""wxH5 专用:创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||
req_provider = (item.get("provider") or "").strip().lower() or None
|
||||
device = (item.get("device") or "pc").lower()
|
||||
ua = (request.headers.get("user-agent") or "").strip()
|
||||
provider = resolve_provider(device, req_provider, ua)
|
||||
channel = resolve_channel(item.get("channel"))
|
||||
plan = (item.get("plan") or "year").lower()
|
||||
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
|
||||
total_fee = int(item.get("total_fee", total_fee))
|
||||
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)
|
||||
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 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||
provider = get_payment_provider_by_name("zpay")
|
||||
channel = resolve_channel(item.get("channel"))
|
||||
plan = (item.get("plan") or "year").lower()
|
||||
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
|
||||
try:
|
||||
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()
|
||||
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
20
app/sdk/__init__.py
Normal 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",
|
||||
]
|
||||
95
app/sdk/memos.py
Normal file
95
app/sdk/memos.py
Normal 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 = "12345678",
|
||||
) -> 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 = "12345678",
|
||||
) -> 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
92
app/sdk/minio.py
Normal 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)
|
||||
15
app/sdk/payment/__init__.py
Normal file
15
app/sdk/payment/__init__.py
Normal 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",
|
||||
]
|
||||
69
app/sdk/pocketbase.py
Normal file
69
app/sdk/pocketbase.py
Normal 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
16
app/services/__init__.py
Normal 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",
|
||||
]
|
||||
21
app/services/memos.py
Normal file
21
app/services/memos.py
Normal 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 = "12345678"):
|
||||
"""创建 Memos 用户"""
|
||||
return _get_memos_sdk().create_user(user_id=user_id, password=password)
|
||||
51
app/services/minio.py
Normal file
51
app/services/minio.py
Normal 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)
|
||||
217
app/services/payment.py
Normal file
217
app/services/payment.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""支付成功后的统一业务处理(基于 SDK)"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||
from .pb import get_pb_client
|
||||
from .memos import create_memos_user
|
||||
|
||||
# 已处理订单(防重复)
|
||||
processed_orders: set[str] = set()
|
||||
DEFAULT_PASSWORD = "12345678"
|
||||
|
||||
|
||||
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 _decode_pending_email(user_id: str) -> str | None:
|
||||
"""从 pending_hex 格式解析邮箱"""
|
||||
if not user_id.startswith("pending_"):
|
||||
return None
|
||||
hex_part = user_id[8:] # 去掉 "pending_"
|
||||
try:
|
||||
return bytes.fromhex(hex_part).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _create_user_by_email(email: str) -> str | None:
|
||||
"""用管理员创建用户,返回 user_id。若已存在则返回已有用户 id"""
|
||||
token, _ = _get_admin_token()
|
||||
if not token:
|
||||
return None
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
r = requests.post(
|
||||
f"{base}/api/collections/users/records",
|
||||
json={
|
||||
"email": email,
|
||||
"password": DEFAULT_PASSWORD,
|
||||
"passwordConfirm": DEFAULT_PASSWORD,
|
||||
},
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("id")
|
||||
if r.status_code == 400:
|
||||
try:
|
||||
err = r.json()
|
||||
if "already" in str(err.get("data", {}).get("email", {}).get("message", "")).lower():
|
||||
# 用户已存在,查询获取 id
|
||||
get_r = requests.get(
|
||||
f"{base}/api/collections/users/records",
|
||||
params={"filter": f'email="{email}"', "perPage": 1},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if get_r.status_code == 200:
|
||||
items = get_r.json().get("items", [])
|
||||
if items:
|
||||
return items[0].get("id")
|
||||
except Exception:
|
||||
pass
|
||||
logging.warning(f"create user failed: {r.status_code} {r.text[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_admin_token() -> tuple[str | None, str | None]:
|
||||
"""获取 PocketBase 管理员 token"""
|
||||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||
return None, "未配置"
|
||||
base = (PB_URL or "").rstrip("/")
|
||||
for path in ["/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password"]:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base}{path}",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("token"), None
|
||||
if r.status_code == 404:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return None, "认证失败"
|
||||
|
||||
|
||||
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
|
||||
|
||||
# meetup/salon 新用户:支付成功后才创建用户
|
||||
if pay_type in ("meetup", "salon") and user_id.startswith("pending_"):
|
||||
email = _decode_pending_email(user_id)
|
||||
if email:
|
||||
real_id = _create_user_by_email(email)
|
||||
if real_id:
|
||||
user_id = real_id
|
||||
logging.info(f"meetup 支付成功,已创建用户: {email}")
|
||||
else:
|
||||
logging.error(f"meetup 支付成功但创建用户失败: {email}")
|
||||
return
|
||||
|
||||
logging.info("[payjsapi] 支付成功 provider=%s user_id=%s pay_type=%s site_id=%s order_id=%s",
|
||||
provider_name, user_id, pay_type, site_id, order_id)
|
||||
|
||||
if pay_type == "vip" and use_memos:
|
||||
create_memos_user(user_id, password="12345678")
|
||||
|
||||
if pay_type in ("meetup", "salon"):
|
||||
logging.info(f"{pay_type} 类型支付成功,不创建 memos 用户 - user_id: {user_id}")
|
||||
|
||||
valid_types = ["vip", "book", "meetup", "video", "salon"]
|
||||
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")
|
||||
solan_collection = "solan"
|
||||
elif pay_type == "salon":
|
||||
solan = pb_client.collection("solanRed")
|
||||
solan_collection = "solanRed"
|
||||
else:
|
||||
solan = None
|
||||
solan_collection = None
|
||||
|
||||
if solan is not None:
|
||||
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": pay_type,
|
||||
"order_id": order_id,
|
||||
"amount": amount_cents,
|
||||
})
|
||||
logging.info(f"{solan_collection} 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
||||
else:
|
||||
solan.create({
|
||||
"user_id": user_id,
|
||||
"type": pay_type,
|
||||
"order_id": order_id,
|
||||
"amount": amount_cents,
|
||||
})
|
||||
logging.info(f"{solan_collection} 完整记录创建(兜底) - user_id: {user_id}")
|
||||
elif pay_type not in ("meetup", "salon"):
|
||||
existing = pb_client.collection("payments").get_list(1, 1, {
|
||||
"filter": f'order_id = "{order_id}"',
|
||||
})
|
||||
if not existing.items:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"type": pay_type,
|
||||
"order_id": order_id,
|
||||
"amount": amount_cents,
|
||||
"total_fee": amount_cents,
|
||||
"channel": "wxpay",
|
||||
}
|
||||
pb_client.collection("payments").create(payload)
|
||||
|
||||
# 写入 site_vip(站点独立 VIP)。meetup 写 site_id=meetup,salon 写 site_id=salon
|
||||
effective_site_id = "meetup" if pay_type == "meetup" else ("salon" if pay_type == "salon" 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:
|
||||
logging.error("PocketBase 操作失败 order_id=%s error=%s", order_id, e)
|
||||
raise
|
||||
27
app/services/pb.py
Normal file
27
app/services/pb.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""PocketBase 客户端(基于 SDK)"""
|
||||
import logging
|
||||
|
||||
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||
from app.sdk.pocketbase import PocketBaseSDK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_pb_sdk: PocketBaseSDK | None = None
|
||||
|
||||
|
||||
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()
|
||||
logger.info("PocketBase admin 登录成功")
|
||||
return client
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
python-dotenv
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-multipart
|
||||
pocketbase
|
||||
requests
|
||||
minio
|
||||
4
run-with-logs.bat
Normal file
4
run-with-logs.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
REM 强制无缓冲输出,确保日志立即显示在终端
|
||||
set PYTHONUNBUFFERED=1
|
||||
python -u run.py
|
||||
4
run-with-logs.sh
Normal file
4
run-with-logs.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# 强制无缓冲输出,确保日志立即显示在终端
|
||||
export PYTHONUNBUFFERED=1
|
||||
exec python -u run.py
|
||||
31
run.py
Normal file
31
run.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("PYTHONUNBUFFERED", "1")
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("PORT", "8007"))
|
||||
reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes")
|
||||
try:
|
||||
sys.stderr.write(f"\n[payjsapi] startup http://0.0.0.0:{port}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
reload=reload_enabled,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
44
手机微信支付调试说明.md
Normal file
44
手机微信支付调试说明.md
Normal 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`
|
||||
Reference in New Issue
Block a user