This commit is contained in:
eric
2026-03-10 04:42:05 -05:00
parent d77f90cb8c
commit adeb111282
47 changed files with 1154 additions and 460 deletions

View File

@@ -1,32 +1,37 @@
"""
支付 API 主入口
支持多支付渠道XorPay、ZPAY通过 PAYMENT_PROVIDER 环境变量切换
路由模块化,按项目拆分:
- nomadvip: /nomadvip/* - 固定 ZPAY
- digital: /digital/* - 使用 PAYMENT_PROVIDER
- cnomadcna: /cnomadcna/* - 使用 PAYMENT_PROVIDER
- nomadlms: /nomadlms/* - 使用 PAYMENT_PROVIDER
- legacy: /payh5、/zpay_notify 等(向后兼容)
- common: /submit_meetup_application、/create_user
本地调试BASE_URL 需为 ZPAY 可访问地址(如 ngrok
线上部署https://api.hackrobot.cn
"""
import logging
import random
import string
import time
from typing import Dict, Any
import requests
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from pocketbase import PocketBase
from .config import (
BASE_URL,
PB_URL,
PB_ADMIN_EMAIL,
PB_ADMIN_PASSWORD,
MEMOS_API,
MEMOS_TOKEN,
ZPAY_PID,
ZPAY_KEY,
from .routers import (
nomadvip_router,
digital_router,
cnomadcna_router,
nomadlms_router,
legacy_router,
common_router,
)
from .payment import get_payment_provider
app = FastAPI()
app = FastAPI(
title="PayJS API",
description="nomadvip / digital / cnomadcna / nomadlms 支付接口",
version="2.0",
)
app.add_middleware(
CORSMiddleware,
@@ -46,434 +51,27 @@ logging.basicConfig(
],
)
# 已处理订单(防重复)
processed_orders: set[str] = set()
# 挂载路由
app.include_router(nomadvip_router)
app.include_router(digital_router)
app.include_router(cnomadcna_router)
app.include_router(nomadlms_router)
app.include_router(legacy_router)
app.include_router(common_router)
def get_pb_client():
client = PocketBase(PB_URL)
try:
client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
print("PocketBase admin 登录成功")
except Exception as e:
print("PocketBase admin 登录失败:", e)
raise e
return client
def create_memos_user(user_id: str, password: str = "123456"):
try:
if user_id.startswith("user_"):
cleaned_username = "user" + user_id.split("user_", 1)[1]
elif "_" in user_id:
cleaned_username = user_id.replace("_", "")
else:
cleaned_username = user_id
username = cleaned_username
print(f"正在为用户创建 memos 账号: {username}")
memos_data = {
"username": username,
"password": password,
"role": "USER",
"state": "NORMAL",
}
memos_headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MEMOS_TOKEN}",
}
response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10)
if response.status_code in (200, 201):
result = response.json()
print(f"memos 用户创建成功: {username}")
logging.info(f"memos 用户创建成功: {username}")
return {"success": True, "data": result}
else:
error_text = response.text
print(f"memos 创建失败: {response.status_code} {error_text}")
logging.error(f"memos 创建失败 - username: {username}, status: {response.status_code}, response: {error_text}")
return {"success": False, "error": error_text}
except Exception as e:
print(f"创建 memos 用户异常: {e}")
logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}")
return {"success": False, "error": str(e)}
def _parse_order_id(order_id: str) -> tuple[str, str]:
"""从 order_id 解析 user_id 和 pay_type"""
if "_order_" not in order_id:
return "", "unknown"
prefix = order_id.split("_order_")[0]
if "_" in prefix:
parts = prefix.rsplit("_", 1)
return parts[0], parts[1].lower()
return prefix, "unknown"
def _handle_payment_success(order_id: str, pay_price: str, provider_name: str):
"""支付成功后的统一业务处理"""
user_id, pay_type = _parse_order_id(order_id)
if not user_id:
return
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}")
if pay_type == "vip":
create_memos_user(user_id)
if pay_type == "meetup":
logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}")
valid_types = ["vip", "book", "meetup", "video"]
if pay_type not in valid_types:
pay_type = "vip"
amount_cents = int(float(pay_price or 0) * 100)
try:
pb_client = get_pb_client()
if pay_type == "meetup":
solan = pb_client.collection("solan")
existing = solan.get_list(1, 1, {
"filter": f'user_id = "{user_id}"',
"sort": "-created",
})
if existing.items:
record_id = existing.items[0].id
solan.update(record_id, {
"type": "meetup",
"order_id": order_id,
"amount": amount_cents,
})
logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
else:
solan.create({
"user_id": user_id,
"type": "meetup",
"order_id": order_id,
"amount": amount_cents,
})
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
else:
pb_client.collection("payments").create({
"user_id": user_id,
"type": pay_type,
"order_id": order_id,
"amount": amount_cents,
})
except Exception as e:
print(f"PocketBase 操作失败: {e}")
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
finally:
processed_orders.add(order_id)
# ==================== 支付接口 ====================
@app.post("/payh5")
async def payh5(item: dict):
"""创建 H5 支付订单(根据 PAYMENT_PROVIDER 使用对应渠道)"""
print("payh5 item:", item)
total_fee_yuan = f"{item['total_fee'] / 100:.2f}"
user_id = item.get("user_id", "unknown")
pay_type = item.get("type", "unknown").lower()
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
timestamp = int(time.time())
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
type_map = {
"book": "购买书籍",
"meetup": "数字游民社区报名",
"video": "视频解锁",
"vip": "VIP会员充值",
}
name = type_map.get(pay_type, "商品支付")
provider = get_payment_provider()
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
notify_url = f"{BASE_URL.rstrip('/')}{notify_path}"
# 支付方式:前端可传 channel=alipay|wxpayZPAY 使用XorPay 固定 jsapi
channel = item.get("channel", "alipay")
result = provider.create_order(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
notify_url=notify_url,
return_url=item.get("return_url"),
client_ip=item.get("client_ip"),
)
# 兼容旧版前端:保留 xorpay_params / xorpay_url 字段名
if provider.name == "xorpay":
return {
"status": "ok",
"xorpay_params": result.get("params", {}),
"xorpay_url": result.get("pay_url", ""),
"provider": provider.name,
}
# ZPAY确保所有参数为字符串避免前端传参问题
params = result.get("params", {})
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
@app.get("/")
async def root():
"""健康检查"""
return {
"status": "ok",
"params": params_str,
"pay_url": result.get("pay_url", ""),
"provider": provider.name,
"submit_method": "POST",
"service": "payjsapi",
"routes": {
"nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify",
"digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify",
"cnomadcna": "/cnomadcna/payh5, ...",
"nomadlms": "/nomadlms/payh5, ...",
"legacy": "/payh5, /payh5/redirect, /zpay_notify, /xorpay_notify, /zpay_order_status",
"common": "/submit_meetup_application, /create_user",
},
}
@app.post("/payh5/redirect")
async def payh5_redirect(item: dict, request: Request):
"""
创建支付订单,使用 ZPAY API接口支付mapi.php返回 302 重定向。
根据 device 区分PC 跳转 payurlH5/微信 跳转 payurl 或 payurl2微信H5
文档https://z-pay.cn/doc.html#d3
"""
from fastapi.responses import RedirectResponse
provider = get_payment_provider()
if provider.name != "zpay":
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道")
total_fee = float(item.get("total_fee", 80))
total_fee_yuan = f"{total_fee / 100:.2f}"
user_id = item.get("user_id", "unknown")
pay_type = item.get("type", "unknown").lower()
channel = item.get("channel", "alipay")
return_url = item.get("return_url")
device = item.get("device", "pc") # pc | h5 | wechat微信内传 wechat 走收银台
client_ip = item.get("client_ip") or request.client.host if request.client else "127.0.0.1"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
timestamp = int(time.time())
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
type_map = {
"book": "购买书籍",
"meetup": "数字游民社区报名",
"video": "视频解锁",
"vip": "VIP会员充值",
}
name = type_map.get(pay_type, "商品支付")
notify_url = f"{BASE_URL.rstrip('/')}/zpay_notify"
from .payment.zpay import ZPayProvider
if not isinstance(provider, ZPayProvider):
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
result = provider.create_order_api(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
notify_url=notify_url,
return_url=return_url,
client_ip=client_ip,
device=device,
)
if result.get("status") != "ok":
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
payurl = result.get("payurl")
payurl2 = result.get("payurl2")
img = result.get("img")
# PC 端payurl 为 wap 链接,在 PC 浏览器会显示错误页面。支付宝/微信均需展示二维码
if device == "pc" and img:
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=200,
content={
"status": "ok",
"qrcode_mode": True,
"order_id": order_id,
"img": img,
"payurl": payurl,
"return_url": return_url,
"channel": channel,
},
)
# 微信内payurl2 为 checkmweb会提示「请外微信外打开」。改用收银台表单submit.php由 Z-Pay 识别 UA
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
from fastapi.responses import JSONResponse
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
# 返回 302 时附带 order_id供 digital 写入 cookie 用于轮询检测支付状态(微信 H5 可能不跳转)
headers = {"X-Pay-Order-Id": order_id}
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
if payurl:
return RedirectResponse(url=payurl, status_code=302, headers=headers)
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
@app.get("/zpay_order_status")
async def zpay_order_status(out_trade_no: str):
"""
查询 ZPAY 订单支付状态,用于 PC 二维码页轮询。
文档https://z-pay.cn/doc.html 查询单个订单
"""
provider = get_payment_provider()
if provider.name != "zpay":
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}"
try:
resp = requests.get(url, timeout=10)
result = resp.json() if resp.ok else {}
except Exception as e:
logging.warning(f"ZPAY 订单查询失败: {e}")
return {"paid": False, "error": str(e)}
# ZPAY 错误时可能返回 code='error',需安全解析
try:
code = result.get("code", 0)
code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0
except (ValueError, TypeError):
code = 0
if code != 1:
return {"paid": False, "msg": result.get("msg", "查询失败")}
try:
status = int(result.get("status", 0))
except (ValueError, TypeError):
status = 0
return {"paid": status == 1, "status": status}
@app.post("/xorpay_notify")
async def xorpay_notify(request: Request):
"""XorPay 异步通知POST form"""
form_data = await request.form()
data = {k: v for k, v in form_data.items()}
logging.info(f"XorPay 异步通知: {data}")
order_id = data.get("order_id", "")
if order_id in processed_orders:
return "ok"
provider = get_payment_provider()
if provider.name != "xorpay":
raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay")
if not provider.verify_notify(data):
logging.error(f"XorPay 验签失败: {data}")
raise HTTPException(status_code=400, detail="sign error")
parsed = provider.parse_notify(data)
if parsed.get("trade_status") != "success":
return "ok"
_handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), "xorpay")
return provider.success_response()
@app.get("/zpay_notify")
@app.post("/zpay_notify")
async def zpay_notify(request: Request):
"""ZPAY 异步通知GET 或 POST"""
if request.method == "GET":
data = dict(request.query_params)
else:
form_data = await request.form()
data = {k: v for k, v in form_data.items()}
logging.info(f"ZPAY 异步通知: {data}")
order_id = data.get("out_trade_no", "")
if order_id in processed_orders:
return "success"
provider = get_payment_provider()
if provider.name != "zpay":
raise HTTPException(status_code=400, detail="zpay_notify only for zpay")
if not provider.verify_notify(data):
logging.error(f"ZPAY 验签失败: {data}")
raise HTTPException(status_code=400, detail="sign error")
parsed = provider.parse_notify(data)
if parsed.get("trade_status") != "success":
return "success"
_handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), "zpay")
return provider.success_response()
# ==================== 其他接口 ====================
@app.post("/submit_meetup_application")
async def submit_meetup_application(item: dict):
print("submit_meetup_application item:", item)
user_id = item.get("user_id")
if not user_id:
raise HTTPException(status_code=400, detail="missing user_id")
try:
pb_client = get_pb_client()
solan = pb_client.collection("solan")
pb_data = {
"user_id": user_id,
"nickname": item.get("nickname", ""),
"occupation": item.get("occupation", ""),
"reason": item.get("reason", ""),
"wechatId": item.get("wechatId", ""),
"gender": item.get("gender", ""),
"education": item.get("education", ""),
"gradYear": item.get("gradYear", ""),
"phone": item.get("phone", ""),
"calculated_age": item.get("calculated_age", 0),
"type": "",
"order_id": "",
"amount": 0,
}
record = solan.create(pb_data)
logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}")
return {"status": "ok", "record_id": record.id}
except Exception as e:
print(f"meetup 申请提交失败: {e}")
logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}")
raise HTTPException(status_code=500, detail="submit failed")
class CreateUserRequest(BaseModel):
username: str | None = None
password: str | None = None
@app.post("/create_user")
async def create_user(item: CreateUserRequest):
timenew = str(int(time.time()))
memos_data = {
"username": item.username or f"user{timenew}",
"password": item.password or "123456",
"role": "USER",
"state": "NORMAL",
}
memos_headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MEMOS_TOKEN}",
}
memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers)
memos_result = memos_resp.json()
print("memos_result-", memos_result)
return memos_result