312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""
|
||
digital/cnomadcna/nomadlms 共享的支付逻辑
|
||
根据 prefix 生成对应 notify_path,业务互不影响
|
||
"""
|
||
import logging
|
||
import random
|
||
import string
|
||
import time
|
||
|
||
from fastapi import APIRouter, Request, HTTPException
|
||
|
||
from ..config import BASE_URL
|
||
from ..payment import (
|
||
get_payment_provider,
|
||
get_payment_provider_by_name,
|
||
resolve_provider,
|
||
resolve_channel,
|
||
)
|
||
from ..payment.zpay import ZPayProvider
|
||
from ..services import handle_payment_success, processed_orders
|
||
|
||
|
||
def _safe_log(level: int, message: str, *args, **kwargs) -> None:
|
||
try:
|
||
logging.log(level, message, *args, **kwargs)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter:
|
||
"""
|
||
创建 digital/cnomadcna/nomadlms 的支付路由
|
||
prefix: 如 "/digital", "/cnomadcna", "/nomadlms" 或 ""(legacy)
|
||
project_name: 用于日志,如 "digital", "cnomadcna", "nomadlms", "legacy"
|
||
site_id: 站点标识,用于 site_vip(digital/meetup/vip)
|
||
"""
|
||
router = APIRouter(prefix=prefix, tags=[project_name])
|
||
|
||
def _notify_path(path: str) -> str:
|
||
base = BASE_URL.rstrip("/")
|
||
if prefix:
|
||
return f"{base}{prefix}{path}"
|
||
return f"{base}{path}"
|
||
|
||
@router.post("/payh5")
|
||
async def payh5(item: dict, request: Request):
|
||
"""创建 H5 支付订单。默认微信支付,PC/手机用 zpay,微信内用 xorpay,支付宝暂不提供"""
|
||
req_provider = (item.get("provider") or "").strip().lower() or None
|
||
device = (item.get("device") or "pc").lower()
|
||
ua = (request.headers.get("user-agent") or "").strip()
|
||
provider = resolve_provider(device, req_provider, ua)
|
||
channel = resolve_channel(item.get("channel"))
|
||
user_id = item.get("user_id", "unknown")
|
||
pay_type = (item.get("type") or "unknown").lower()
|
||
_safe_log(
|
||
logging.INFO,
|
||
"[payjsapi] %s/payh5 user_id=%s type=%s provider=%s channel=%s",
|
||
project_name,
|
||
user_id,
|
||
pay_type,
|
||
provider.name,
|
||
channel,
|
||
)
|
||
try:
|
||
total_fee = int(item.get("total_fee", 80))
|
||
except (TypeError, ValueError):
|
||
total_fee = 80
|
||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||
timestamp = int(time.time())
|
||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||
return_url = item.get("return_url")
|
||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||
sep = "&" if "?" in return_url else "?"
|
||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||
type_map = {
|
||
"book": "购买书籍",
|
||
"meetup": "数字游民社区报名",
|
||
"video": "视频解锁",
|
||
"vip": "VIP会员充值",
|
||
}
|
||
name = type_map.get(pay_type, "商品支付")
|
||
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
||
notify_url = _notify_path(notify_path)
|
||
result = provider.create_order(
|
||
order_id=order_id,
|
||
name=name,
|
||
total_fee_yuan=total_fee_yuan,
|
||
pay_type=channel,
|
||
notify_url=notify_url,
|
||
return_url=return_url,
|
||
client_ip=item.get("client_ip"),
|
||
)
|
||
_safe_log(
|
||
logging.INFO,
|
||
"[payjsapi] %s/payh5 created order_id=%s provider=%s notify_url=%s return_url=%s",
|
||
project_name,
|
||
order_id,
|
||
provider.name,
|
||
notify_url,
|
||
return_url or "",
|
||
)
|
||
if provider.name == "xorpay":
|
||
return {
|
||
"status": "ok",
|
||
"order_id": order_id,
|
||
"xorpay_params": result.get("params", {}),
|
||
"xorpay_url": result.get("pay_url", ""),
|
||
"provider": provider.name,
|
||
}
|
||
params = result.get("params", {})
|
||
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
|
||
return {
|
||
"status": "ok",
|
||
"params": params_str,
|
||
"pay_url": result.get("pay_url", ""),
|
||
"provider": provider.name,
|
||
"submit_method": "POST",
|
||
}
|
||
|
||
@router.post("/payh5/redirect")
|
||
async def payh5_redirect(item: dict, request: Request):
|
||
"""ZPAY mapi 接口,返回 302 或二维码。PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||
from fastapi.responses import RedirectResponse, JSONResponse
|
||
|
||
provider = get_payment_provider_by_name("zpay")
|
||
channel = resolve_channel(item.get("channel"))
|
||
_safe_log(
|
||
logging.INFO,
|
||
"[payjsapi] %s/payh5/redirect user_id=%s type=%s device=%s channel=%s",
|
||
project_name,
|
||
item.get("user_id", ""),
|
||
item.get("type", ""),
|
||
item.get("device", "pc"),
|
||
channel,
|
||
)
|
||
try:
|
||
total_fee = float(item.get("total_fee", 80))
|
||
except (TypeError, ValueError):
|
||
total_fee = 80.0
|
||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||
user_id = item.get("user_id", "unknown")
|
||
pay_type = item.get("type", "unknown").lower()
|
||
return_url = item.get("return_url")
|
||
device = item.get("device", "pc")
|
||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||
timestamp = int(time.time())
|
||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||
sep = "&" if "?" in return_url else "?"
|
||
return_url = f"{return_url}{sep}order_id={order_id}"
|
||
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
||
forwarded = request.headers.get("x-forwarded-for")
|
||
if forwarded:
|
||
client_ip = forwarded.split(",")[0].strip()
|
||
type_map = {
|
||
"book": "购买书籍",
|
||
"meetup": "数字游民社区报名",
|
||
"video": "视频解锁",
|
||
"vip": "VIP会员充值",
|
||
}
|
||
name = type_map.get(pay_type, "商品支付")
|
||
notify_url = _notify_path("/zpay_notify")
|
||
result = provider.create_order_api(
|
||
order_id=order_id,
|
||
name=name,
|
||
total_fee_yuan=total_fee_yuan,
|
||
pay_type=channel,
|
||
notify_url=notify_url,
|
||
return_url=return_url,
|
||
client_ip=client_ip,
|
||
device=device,
|
||
)
|
||
if result.get("status") != "ok":
|
||
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
||
_safe_log(
|
||
logging.INFO,
|
||
"[payjsapi] %s/payh5/redirect created order_id=%s device=%s channel=%s",
|
||
project_name,
|
||
order_id,
|
||
device,
|
||
channel,
|
||
)
|
||
payurl = result.get("payurl")
|
||
payurl2 = result.get("payurl2")
|
||
img = result.get("img")
|
||
if device == "pc" and img:
|
||
return JSONResponse(status_code=200, content={
|
||
"status": "ok", "qrcode_mode": True, "order_id": order_id,
|
||
"img": img, "payurl": payurl, "return_url": return_url, "channel": channel,
|
||
})
|
||
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
|
||
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
|
||
headers = {"X-Pay-Order-Id": order_id}
|
||
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
|
||
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
|
||
if payurl:
|
||
return RedirectResponse(url=payurl, status_code=302, headers=headers)
|
||
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
||
|
||
@router.get("/zpay_order_status")
|
||
async def zpay_order_status(out_trade_no: str):
|
||
"""查询 ZPAY 订单支付状态"""
|
||
provider = get_payment_provider()
|
||
if not isinstance(provider, ZPayProvider):
|
||
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
|
||
result = provider.query_order_status(out_trade_no, timeout=15)
|
||
if result.get("error"):
|
||
_safe_log(logging.WARNING, "%s ZPAY 订单查询失败: %s", project_name, result.get("error"))
|
||
return result
|
||
|
||
@router.get("/order_status")
|
||
async def order_status(order_id: str = "", out_trade_no: str = ""):
|
||
order_id = (order_id or out_trade_no or "").strip()
|
||
if not order_id:
|
||
return {"paid": False, "error": "missing order_id"}
|
||
|
||
try:
|
||
from ..services.pb import get_pb_client
|
||
|
||
pb = get_pb_client()
|
||
records = pb.collection("payments").get_list(
|
||
1, 1, {"filter": f'order_id = "{order_id}"'}
|
||
)
|
||
if records.items:
|
||
return {"paid": True, "source": "pocketbase"}
|
||
except Exception as error:
|
||
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
||
|
||
try:
|
||
xorpay = get_payment_provider_by_name("xorpay")
|
||
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
||
if xorpay_result.get("paid"):
|
||
return {
|
||
"paid": True,
|
||
"source": "xorpay",
|
||
"status": xorpay_result.get("status", ""),
|
||
}
|
||
except Exception as error:
|
||
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
||
|
||
try:
|
||
zpay = get_payment_provider_by_name("zpay")
|
||
if isinstance(zpay, ZPayProvider):
|
||
zpay_result = zpay.query_order_status(order_id, timeout=8)
|
||
if zpay_result.get("paid"):
|
||
return {
|
||
"paid": True,
|
||
"source": "zpay",
|
||
"status": zpay_result.get("status", ""),
|
||
}
|
||
except Exception as error:
|
||
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
||
|
||
return {"paid": False}
|
||
|
||
@router.post("/xorpay_notify")
|
||
async def xorpay_notify(request: Request):
|
||
"""XorPay 异步通知"""
|
||
form_data = await request.form()
|
||
data = {k: v for k, v in form_data.items()}
|
||
_safe_log(logging.INFO, "%s XorPay 异步通知: %s", project_name, data)
|
||
order_id = data.get("order_id", "")
|
||
if order_id in processed_orders:
|
||
return "ok"
|
||
provider = get_payment_provider_by_name("xorpay")
|
||
if not provider.verify_notify(data):
|
||
_safe_log(logging.ERROR, "%s XorPay 验签失败: %s", project_name, data)
|
||
raise HTTPException(status_code=400, detail="sign error")
|
||
parsed = provider.parse_notify(data)
|
||
if parsed.get("trade_status") != "success":
|
||
return "ok"
|
||
handle_payment_success(
|
||
order_id,
|
||
parsed.get("pay_price", data.get("pay_price", "")),
|
||
f"{project_name}-xorpay",
|
||
use_memos=False,
|
||
site_id=site_id,
|
||
)
|
||
return provider.success_response()
|
||
|
||
@router.get("/zpay_notify")
|
||
@router.post("/zpay_notify")
|
||
async def zpay_notify(request: Request):
|
||
"""ZPAY 异步通知"""
|
||
if request.method == "GET":
|
||
data = dict(request.query_params)
|
||
else:
|
||
form_data = await request.form()
|
||
data = {k: v for k, v in form_data.items()}
|
||
_safe_log(logging.INFO, "%s ZPAY 异步通知: %s", project_name, data)
|
||
order_id = data.get("out_trade_no", "")
|
||
if order_id in processed_orders:
|
||
return "success"
|
||
provider = get_payment_provider_by_name("zpay")
|
||
if not provider.verify_notify(data):
|
||
_safe_log(logging.ERROR, "%s ZPAY 验签失败: %s", project_name, data)
|
||
raise HTTPException(status_code=400, detail="sign error")
|
||
parsed = provider.parse_notify(data)
|
||
if parsed.get("trade_status") != "success":
|
||
return "success"
|
||
handle_payment_success(
|
||
order_id,
|
||
parsed.get("pay_price", data.get("money", "")),
|
||
f"{project_name}-zpay",
|
||
use_memos=False,
|
||
site_id=site_id,
|
||
)
|
||
return provider.success_response()
|
||
|
||
return router
|