's'
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,11 +15,12 @@ from ..payment.zpay import ZPayProvider
|
||||
from ..services import handle_payment_success, processed_orders
|
||||
|
||||
|
||||
def create_digital_router(prefix: str, project_name: str) -> APIRouter:
|
||||
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])
|
||||
|
||||
@@ -166,7 +167,13 @@ def create_digital_router(prefix: str, project_name: str) -> APIRouter:
|
||||
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)
|
||||
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")
|
||||
@@ -191,7 +198,13 @@ def create_digital_router(prefix: str, project_name: str) -> APIRouter:
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""cnomadcna 项目支付接口:/cnomadcna/*"""
|
||||
from ._digital_base import create_digital_router
|
||||
|
||||
router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna")
|
||||
router = create_digital_router(prefix="/cnomadcna", project_name="cnomadcna", site_id="meetup")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""digital 项目支付接口:/digital/*"""
|
||||
from ._digital_base import create_digital_router
|
||||
|
||||
router = create_digital_router(prefix="/digital", project_name="digital")
|
||||
router = create_digital_router(prefix="/digital", project_name="digital", site_id="digital")
|
||||
|
||||
@@ -183,7 +183,7 @@ async def nomadvip_xorpay_notify(request: Request):
|
||||
return "ok"
|
||||
pay_price = parsed.get("pay_price", data.get("pay_price", ""))
|
||||
try:
|
||||
handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True)
|
||||
handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True, site_id="vip")
|
||||
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
|
||||
except Exception as e:
|
||||
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
|
||||
@@ -216,7 +216,7 @@ async def nomadvip_zpay_notify(request: Request):
|
||||
return "success"
|
||||
pay_price = parsed.get("pay_price") or data.get("money", "")
|
||||
try:
|
||||
handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True)
|
||||
handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True, site_id="vip")
|
||||
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
|
||||
except Exception as e:
|
||||
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
|
||||
|
||||
270
app/routers/wxh5.py
Normal file
270
app/routers/wxh5.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""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()
|
||||
Binary file not shown.
@@ -25,16 +25,18 @@ def handle_payment_success(
|
||||
provider_name: str,
|
||||
*,
|
||||
use_memos: bool = False,
|
||||
site_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
支付成功后的统一业务处理
|
||||
:param use_memos: 是否创建 Memos 用户,仅 nomadvip 为 True
|
||||
:param site_id: 站点标识(digital/meetup/vip),用于写入 site_vip
|
||||
"""
|
||||
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}")
|
||||
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {order_id}")
|
||||
|
||||
if pay_type == "vip" and use_memos:
|
||||
create_memos_user(user_id)
|
||||
@@ -79,6 +81,32 @@ def handle_payment_success(
|
||||
"order_id": order_id,
|
||||
"amount": amount_cents,
|
||||
})
|
||||
|
||||
# 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup
|
||||
effective_site_id = "meetup" if pay_type == "meetup" else site_id
|
||||
if effective_site_id:
|
||||
try:
|
||||
site_vip = pb_client.collection("site_vip")
|
||||
existing_vip = site_vip.get_list(1, 1, {
|
||||
"filter": f'user_id = "{user_id}" && site_id = "{effective_site_id}"',
|
||||
})
|
||||
if existing_vip.items:
|
||||
site_vip.update(existing_vip.items[0].id, {
|
||||
"order_id": order_id,
|
||||
"expires_at": "",
|
||||
})
|
||||
logging.info(f"site_vip 更新成功 - user_id: {user_id}, site_id: {effective_site_id}")
|
||||
else:
|
||||
site_vip.create({
|
||||
"user_id": user_id,
|
||||
"site_id": effective_site_id,
|
||||
"order_id": order_id,
|
||||
"expires_at": "",
|
||||
})
|
||||
logging.info(f"site_vip 创建成功 - user_id: {user_id}, site_id: {effective_site_id}")
|
||||
except Exception as e:
|
||||
logging.warning(f"site_vip 写入失败(不影响主流程): {e}")
|
||||
|
||||
processed_orders.add(order_id)
|
||||
except Exception as e:
|
||||
print(f"PocketBase 操作失败: {e}")
|
||||
|
||||
Reference in New Issue
Block a user