Files
gitlab-instance-0a899031_pa…/app/routers/wxh5.py
2026-03-13 05:15:00 -05:00

275 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()