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