271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""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
|
||
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):
|
||
"""wxH5 专用:创建支付订单,支持 per-request provider 覆盖"""
|
||
req_provider = (item.get("provider") or "").strip().lower()
|
||
provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider()
|
||
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)
|
||
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=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 接口(xorpay 无需 redirect)"""
|
||
req_provider = (item.get("provider") or "").strip().lower()
|
||
provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider()
|
||
if not isinstance(provider, ZPayProvider):
|
||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
|
||
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()
|
||
channel = item.get("channel", "alipay")
|
||
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()
|