10 Commits
dev ... pro

Author SHA1 Message Date
eric
913f1f4064 s 2026-03-23 15:36:04 +08:00
eric
0b69539bc3 's' 2026-03-23 02:20:48 -05:00
eric
89f7bce75e 'env' 2026-03-18 02:01:52 -05:00
eric
1401f0230e 's' 2026-03-18 00:53:37 -05:00
eric
4d1c3448d2 's' 2026-03-16 20:25:57 -05:00
eric
dc0f1dd9ec 's' 2026-03-16 20:08:32 -05:00
eric
e9898ab372 's' 2026-03-16 11:45:46 -05:00
eric
4187cdd64b 'data' 2026-03-16 09:32:46 -05:00
eric
710c30837a 's' 2026-03-16 06:53:17 -05:00
eric
e84c02a329 '小红书解析' 2026-03-16 04:27:53 -05:00
23 changed files with 1504 additions and 1757 deletions

View File

@@ -4,7 +4,7 @@
# 可用 ngrok 等隧道BASE_URL=https://xxx.ngrok.io
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
PORT=8007
BASE_URL=https://api.hackrobot.cn
BASE_URL=https://salonapi.hackrobot.cn
# 支付渠道zpay | xorpay
PAYMENT_PROVIDER=zpay

View File

@@ -17,7 +17,7 @@
## 环境
本地调试:`BASE_URL` 需为 ZPAY 可访问地址(如 ngrok 暴露 8700 端口)
线上部署:`BASE_URL=https://api.hackrobot.cn`
线上部署:`BASE_URL=https://salonapi.hackrobot.cn`
## 模块结构
@@ -73,11 +73,11 @@ python run.py
## 线上部署
域名:`https://api.hackrobot.cn`
域名:`https://salonapi.hackrobot.cn`
```bash
# .env 中设置
BASE_URL=https://api.hackrobot.cn
BASE_URL=https://api.nomadyt.com
PAYMENT_PROVIDER=zpay
# 其他 ZPAY、PocketBase、Memos 配置见 .env.example
```
@@ -86,9 +86,9 @@ PAYMENT_PROVIDER=zpay
本地正常、Ubuntu 部署后 api.hackrobot.cn 调用支付失败,常见原因:
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700``http://localhost`。正确:`https://api.hackrobot.cn`域名需解析到本机nginx 反向代理对应端口)。
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700``http://localhost`。正确:`https://salonapi.hackrobot.cn`域名需解析到本机nginx 反向代理对应端口)。
2. **服务器无法访问 zpayz.cn**防火墙、DNS 或网络限制。访问 `https://api.hackrobot.cn/health?check_zpay=1` 自检:
2. **服务器无法访问 zpayz.cn**防火墙、DNS 或网络限制。访问 `https://salonapi.hackrobot.cn/health?check_zpay=1` 自检:
- `zpayz_reachable: false` → 开放出站或检查网络
- `zpayz_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启

View File

@@ -43,8 +43,8 @@ MEMOS_TOKEN = os.getenv(
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
)
# 回调地址(用于生成 notify_url
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
# 回调地址(用于生成 notify_url。线上 https://salonapi.hackrobot.cn
BASE_URL = os.getenv("BASE_URL", "https://salonapi.hackrobot.cn")
# MinIO 对象存储
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minioweb.hackrobot.cn")

View File

@@ -1,17 +1,13 @@
"""
支付 API 主入口
持多支付渠道XorPay、ZPAY通过 PAYMENT_PROVIDER 环境变量切换
salon API 主入口
ZPAY/XorPay+ 报名、用户、小红书资料
路由模块化,按项目拆分
- 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
路由:
- /salon/* 支付payh5、payh5/redirect、order_status、zpay_notify、xorpay_notify
- /api/salon/* 报名、check-user、ensure-user、complete-order、join、volunteers、xiaohongshu/profile
本地调试BASE_URL 需为 ZPAY 可访问地址(如 ngrok
线上部署BASE_URL=https://api.hackrobot.cn必须为 ZPAY 可公网访问的地址)
线上部署BASE_URL=https://salonapi.hackrobot.cn必须为 ZPAY 可公网访问的地址)
"""
import logging
import sys
@@ -22,21 +18,11 @@ 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,
)
from .routers import salon_router, salon_meetup_router
app = FastAPI(
title="PayJS API",
description="nomadvip / digital / cnomadcna / nomadlms 支付接口",
title="Salon API",
description="沙龙报名、支付、小红书资料",
version="2.0",
)
@@ -96,9 +82,7 @@ def _safe_stderr_write(message: str) -> None:
def _should_trace_request(path: str) -> bool:
return any(
segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms")
)
return "/salon" in path or "/api/salon" in path
# 启动时校验 BASE_URL线上部署必须为公网可访问地址否则 ZPAY 无法回调)
def _check_base_url():
@@ -108,7 +92,7 @@ def _check_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=%s 疑似内网地址ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://salonapi.hackrobot.cn",
BASE_URL,
)
@@ -121,7 +105,7 @@ async def startup_event():
@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}
out = {"status": "ok", "service": "salonapi", "base_url": BASE_URL}
if str(check_zpay).lower() in ("1", "true", "yes"):
# 1) 检测 zpayz.cn 首页
try:
@@ -153,21 +137,14 @@ async def health(check_zpay: str = ""):
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")
_safe_stderr_write(f"[salonapi] >>> {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")
_safe_stderr_write(f"[salonapi] <<< {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)
@@ -176,13 +153,9 @@ async def root():
"""健康检查"""
return {
"status": "ok",
"service": "payjsapi",
"service": "salonapi",
"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",
"salon": "/salon/payh5, /salon/payh5/redirect, /salon/order_status, /salon/zpay_notify, /salon/xorpay_notify",
"api": "/api/salon/check-user, /api/salon/ensure-user, /api/salon/complete-order, /api/salon/join, /api/salon/join/by-wechat, /api/salon/join/status, /api/salon/join/volunteers, /api/salon/xiaohongshu/profile",
},
}

View File

@@ -246,4 +246,5 @@ class ZPayProvider(PaymentProvider):
status = int(result.get("status", 0))
except (ValueError, TypeError):
status = 0
return {"paid": status == 1, "status": status}
money = result.get("money", "")
return {"paid": status == 1, "status": status, "money": money}

View File

@@ -1,30 +1,12 @@
"""
项目路由模块
- nomadvip: /nomadvip/*
- digital: /digital/*
- cnomadcna: /cnomadcna/*
- nomadlms: /nomadlms/*
- salon: /salon/*(克隆 cnomadcna
- legacy: /payh5, /zpay_notify 等(向后兼容 digital/cnomadcna
salon 项目路由模块
- salon: /salon/* 支付payh5、order_status、zpay_notify、xorpay_notify
- salon_meetup: /api/salon/* 报名、用户、小红书资料
"""
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",
]

View File

@@ -2,6 +2,7 @@
digital/cnomadcna/nomadlms 共享的支付逻辑
根据 prefix 生成对应 notify_path业务互不影响
"""
import asyncio
import logging
import random
import string
@@ -17,7 +18,8 @@ from ..payment import (
resolve_channel,
)
from ..payment.zpay import ZPayProvider
from ..services import handle_payment_success, processed_orders
from ..payment.xorpay import XorPayProvider
from ..services import handle_payment_success, try_add_processed_order, remove_processed_order
def _safe_log(level: int, message: str, *args, **kwargs) -> None:
@@ -27,6 +29,45 @@ def _safe_log(level: int, message: str, *args, **kwargs) -> None:
pass
async def _poll_xorpay_until_paid(
order_id: str,
project_name: str,
site_id: str,
max_attempts: int = 45,
interval_sec: float = 2.0,
) -> None:
"""创建 XorPay 订单后后台轮询,支付成功即更新 DB不依赖 notify 回调"""
provider = get_payment_provider_by_name("xorpay")
if not isinstance(provider, XorPayProvider):
return
for attempt in range(max_attempts):
if attempt > 0:
await asyncio.sleep(interval_sec)
try:
result = await asyncio.to_thread(provider.query_order_status, order_id, 6)
if not result.get("paid"):
continue
pay_price = str(result.get("pay_price", "") or result.get("price", "") or "1")
if not try_add_processed_order(order_id):
return
try:
await asyncio.to_thread(
handle_payment_success,
order_id,
pay_price,
f"{project_name}-xorpay-poll",
use_memos=False,
site_id=site_id,
)
_safe_log(logging.INFO, "%s XorPay 轮询检测到支付 order_id=%s", project_name, order_id)
except Exception as e:
remove_processed_order(order_id)
_safe_log(logging.ERROR, "%s XorPay 轮询更新失败 order_id=%s: %s", project_name, order_id, e)
return
except Exception as e:
_safe_log(logging.WARNING, "%s XorPay 轮询异常 order_id=%s attempt=%s: %s", project_name, order_id, attempt + 1, e)
def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter:
"""
创建 digital/cnomadcna/nomadlms 的支付路由
@@ -76,6 +117,7 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
type_map = {
"book": "购买书籍",
"meetup": "数字游民社区报名",
"salon": "沙龙报名茶歇费",
"video": "视频解锁",
"vip": "VIP会员充值",
}
@@ -100,6 +142,8 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
notify_url,
return_url or "",
)
if provider.name == "xorpay" and site_id and pay_type in ("salon", "meetup"):
asyncio.create_task(_poll_xorpay_until_paid(order_id, project_name, site_id))
if provider.name == "xorpay":
return {
"status": "ok",
@@ -156,6 +200,7 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
type_map = {
"book": "购买书籍",
"meetup": "数字游民社区报名",
"salon": "沙龙报名茶歇费",
"video": "视频解锁",
"vip": "VIP会员充值",
}
@@ -215,68 +260,83 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
if not order_id:
return {"paid": False, "error": "missing order_id"}
try:
from ..services.pb import get_pb_client
def _query_pb() -> dict | None:
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)
return None
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)
def _query_xorpay() -> dict | None:
try:
xorpay = get_payment_provider_by_name("xorpay")
r = xorpay.query_order_status(order_id, timeout=6)
if r.get("paid"):
return {"paid": True, "source": "xorpay", "status": r.get("status", "")}
except Exception as error:
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
return None
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)
def _query_zpay() -> dict | None:
try:
zpay = get_payment_provider_by_name("zpay")
if isinstance(zpay, ZPayProvider):
r = zpay.query_order_status(order_id, timeout=6)
if r.get("paid"):
return {"paid": True, "source": "zpay", "status": r.get("status", "")}
except Exception as error:
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
return None
# 并行查询 PB、ZPAY、XorPay任一返回 paid 即立即返回PC 扫码用 ZPAY微信用 XorPay
pb_task = asyncio.to_thread(_query_pb)
xorpay_task = asyncio.to_thread(_query_xorpay)
zpay_task = asyncio.to_thread(_query_zpay)
results = await asyncio.gather(pb_task, xorpay_task, zpay_task)
for r in results:
if r:
return r
return {"paid": False}
@router.post("/xorpay_notify")
async def xorpay_notify(request: Request):
"""XorPay 异步通知"""
"""XorPay 异步通知:先返回 200 避免超时重试,后台异步更新 solanRed/site_vip"""
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 try_add_processed_order(order_id):
return provider.success_response()
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()
# 先返回 200后台异步更新 DB用户再次打开首页时 by-wechat 可直接查到 solanRed
pay_price = parsed.get("pay_price", data.get("pay_price", ""))
provider_name = f"{project_name}-xorpay"
def _do_update():
try:
handle_payment_success(
order_id,
pay_price,
provider_name,
use_memos=False,
site_id=site_id,
)
except Exception as e:
_safe_log(logging.ERROR, "%s XorPay 后台更新失败 order_id=%s: %s", project_name, order_id, e)
remove_processed_order(order_id)
asyncio.create_task(asyncio.to_thread(_do_update))
return provider.success_response()
@router.get("/zpay_notify")
@@ -290,7 +350,7 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
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:
if not try_add_processed_order(order_id):
return "success"
provider = get_payment_provider_by_name("zpay")
if not provider.verify_notify(data):
@@ -299,13 +359,18 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
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,
)
try:
await asyncio.to_thread(
handle_payment_success,
order_id,
parsed.get("pay_price", data.get("money", "")),
f"{project_name}-zpay",
use_memos=False,
site_id=site_id,
)
except Exception:
remove_processed_order(order_id)
raise
return provider.success_response()
return router

View File

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

View File

@@ -1,65 +0,0 @@
"""共享接口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

View File

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

View File

@@ -1,4 +0,0 @@
"""向后兼容:/payh5、/zpay_notify 等digital/cnomadcna/nomadlms 旧版调用)"""
from ._digital_base import create_digital_router
router = create_digital_router(prefix="", project_name="legacy")

View File

@@ -1,328 +0,0 @@
"""
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}

View File

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

View File

@@ -1,883 +0,0 @@
"""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):
"""创建支付订单。微信内用 xorpayPC/手机用 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()

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
"""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 专用:创建支付订单。微信内用 xorpayPC/手机用 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()

View File

@@ -22,10 +22,12 @@ class PocketBaseSDK:
self._client: Optional[PocketBase] = None
def get_client(self) -> PocketBase:
"""获取已认证的 PocketBase 客户端"""
"""获取已认证的 PocketBase 客户端(首次登录时认证,后续复用)"""
if self._client is None:
import logging
self._client = PocketBase(self.url)
self._client.admins.auth_with_password(self.admin_email, self.admin_password)
logging.getLogger(__name__).info("PocketBase admin 登录成功")
return self._client
def collection(self, name: str):

View File

@@ -5,7 +5,13 @@
"""
from .pb import get_pb_client
from .memos import create_memos_user
from .payment import handle_payment_success, parse_order_id, processed_orders
from .payment import (
handle_payment_success,
parse_order_id,
processed_orders,
try_add_processed_order,
remove_processed_order,
)
__all__ = [
"get_pb_client",
@@ -13,4 +19,6 @@ __all__ = [
"handle_payment_success",
"parse_order_id",
"processed_orders",
"try_add_processed_order",
"remove_processed_order",
]

View File

@@ -1,16 +1,43 @@
"""支付成功后的统一业务处理(基于 SDK"""
import logging
import threading
import time
import requests
from cachetools import TTLCache
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()
# 已处理订单(防重复)24h TTL最大 10000 条,避免内存泄漏
_processed_orders: TTLCache[str, bool] = TTLCache(maxsize=10000, ttl=86400)
_processed_orders_lock = threading.Lock()
# 兼容旧导入
processed_orders = _processed_orders
DEFAULT_PASSWORD = "12345678"
# Admin token 缓存5 分钟),与 meetup/salon 一致
_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
_ADMIN_TOKEN_CACHE_TTL = 300
def try_add_processed_order(order_id: str) -> bool:
"""原子性标记订单已处理。返回 True 表示首次处理False 表示已处理过(跳过)。"""
with _processed_orders_lock:
if order_id in _processed_orders:
return False
_processed_orders[order_id] = True
return True
def remove_processed_order(order_id: str) -> None:
"""处理失败时移除标记,允许支付方重试。"""
with _processed_orders_lock:
_processed_orders.pop(order_id, None)
def parse_order_id(order_id: str) -> tuple[str, str]:
"""从 order_id 解析 user_id 和 pay_type"""
@@ -74,7 +101,11 @@ def _create_user_by_email(email: str) -> str | None:
def _get_admin_token() -> tuple[str | None, str | None]:
"""获取 PocketBase 管理员 token"""
"""获取 PocketBase 管理员 token,带 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, "未配置"
base = (PB_URL or "").rstrip("/")
@@ -86,7 +117,10 @@ def _get_admin_token() -> tuple[str | None, str | None]:
timeout=10,
)
if r.status_code == 200:
return r.json().get("token"), None
token = r.json().get("token")
if token:
_ADMIN_TOKEN_CACHE = (token, now + _ADMIN_TOKEN_CACHE_TTL)
return token, None
if r.status_code == 404:
continue
except Exception:
@@ -111,7 +145,8 @@ def handle_payment_success(
if not user_id:
return
# meetup/salon 新用户:支付成功后才创建用户
# meetup/salon 新用户:支付成功后才创建用户。solanRed 记录创建时用的是 pending_xxx查找需用原值
solan_lookup_user_id = user_id
if pay_type in ("meetup", "salon") and user_id.startswith("pending_"):
email = _decode_pending_email(user_id)
if email:
@@ -151,25 +186,30 @@ def handle_payment_success(
solan_collection = None
if solan is not None:
update_payload = {
"order_id": order_id,
"amount": amount_cents,
}
if pay_type == "salon":
update_payload["vip"] = True
elif pay_type == "meetup":
update_payload["type"] = pay_type
existing = solan.get_list(1, 1, {
"filter": f'user_id = "{user_id}"',
"filter": f'user_id = "{solan_lookup_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,
})
patch = dict(update_payload)
if solan_lookup_user_id != user_id:
patch["user_id"] = user_id
solan.update(record_id, patch)
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,
})
create_payload = {"user_id": user_id, **update_payload}
if pay_type == "salon":
create_payload["type"] = ""
solan.create(create_payload)
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, {
@@ -211,7 +251,7 @@ def handle_payment_success(
except Exception as e:
logging.warning(f"site_vip 写入失败(不影响主流程): {e}")
processed_orders.add(order_id)
# 由回调方在验签后、调用前通过 try_add_processed_order 原子性标记,此处不再 add
except Exception as e:
logging.error("PocketBase 操作失败 order_id=%s error=%s", order_id, e)
raise

View File

@@ -1,10 +1,7 @@
"""PocketBase 客户端(基于 SDK"""
import logging
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
from app.sdk.pocketbase import PocketBaseSDK
logger = logging.getLogger(__name__)
_pb_sdk: PocketBaseSDK | None = None
@@ -20,8 +17,6 @@ def _get_pb_sdk() -> PocketBaseSDK:
def get_pb_client():
"""获取已认证的 PocketBase 客户端"""
"""获取已认证的 PocketBase 客户端(复用已登录实例,不重复打日志)"""
sdk = _get_pb_sdk()
client = sdk.get_client()
logger.info("PocketBase admin 登录成功")
return client
return sdk.get_client()

View File

@@ -1,9 +1,10 @@
fastapi
uvicorn
python-dotenv
sqlalchemy
pydantic
python-multipart
pocketbase
requests
minio
minio
playwright
cachetools

4
run.py
View File

@@ -13,10 +13,10 @@ from dotenv import load_dotenv
load_dotenv()
if __name__ == "__main__":
port = int(os.getenv("PORT", "8007"))
port = int(os.getenv("PORT", "8008"))
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.write(f"\n[salonapi] startup http://0.0.0.0:{port}\n")
sys.stderr.flush()
except (OSError, ValueError):
pass