374767bbca
- 删除死代码 app/db.py / app/repositories.py / app/dependencies.py(legacy 模块, import 即损坏且全仓无引用,活跃实现在 app/infrastructure/) - 删除废弃库文件 serverdata/data/opc.db(2026-08-24 并入总库前的旧分库) - 注释措辞同步 SQLite→统一总库(DATABASE_URL 决定方言)
177 lines
8.2 KiB
Python
177 lines
8.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""活动报名支付业务:课程/沙龙定价 → 报名单支付 → 回调自动确认。
|
||
|
||
复用充值订单已验证的链路(wxpay JSAPI 下单 + 回调验签 + 幂等三重保证),
|
||
差异只在「到账」动作:不是引擎加款,而是把 bookings 行置 paid/confirmed。
|
||
bookings/events 由培训子应用数据层维护(同库,SQLite/MySQL 由 DATABASE_URL 决定),
|
||
此处经 ``asyncio.to_thread`` 调 ``app.training.db`` 读写,不直连 ORM。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import time
|
||
|
||
from ..infrastructure.repositories import Database, utcnow_iso
|
||
from . import config as pay_config
|
||
from . import wxpay
|
||
from .service import ORDER_PREFIX_EVENT, _iso_in
|
||
|
||
logger = logging.getLogger("pay.event_service")
|
||
|
||
|
||
def _training_db():
|
||
from ..training import db as tdb
|
||
return tdb
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 下单
|
||
# ---------------------------------------------------------------------------
|
||
async def create_order_for_booking(db: Database, user: dict, booking: dict, event: dict) -> dict:
|
||
"""为报名单创建/复用支付订单,返回 {orderNo, payParams, amountFen}。
|
||
|
||
归属与状态校验在路由层完成;此处只管微信下单与幂等复用。
|
||
"""
|
||
if not pay_config.pay_enabled():
|
||
raise RuntimeError("支付未配置,暂不可用")
|
||
amount_fen = int(event.get("price_fen") or 0)
|
||
if amount_fen <= 0:
|
||
raise ValueError("本场活动免费,无需支付")
|
||
|
||
openid = user.get("wx_mini_openid", "")
|
||
if not openid:
|
||
raise ValueError("请先用微信登录小程序后再支付")
|
||
|
||
now_iso = utcnow_iso()
|
||
dup = await db.event_orders.find_pending_by_booking(booking["id"], now_iso)
|
||
if dup:
|
||
# 复用未过期 pending 单:重新拉起预支付(prepay 两小时有效,重取更稳)
|
||
result = await _wx_jsapi(dup["order_no"], amount_fen, event.get("title", ""), openid)
|
||
await db.event_orders.set_prepay(dup["order_no"], result.get("appid", ""), result.get("prepay_id", ""))
|
||
await _bind_order_no(booking["id"], dup["order_no"])
|
||
return {"order_no": dup["order_no"], "pay_params": result.get("pay_params"),
|
||
"amount_fen": amount_fen, "expires_at": dup["expires_at"]}
|
||
|
||
order_no = f"{ORDER_PREFIX_EVENT}{int(time.time())}_{secrets.token_hex(4).upper()}"
|
||
result = await _wx_jsapi(order_no, amount_fen, event.get("title", ""), openid)
|
||
order = await db.event_orders.create({
|
||
"order_no": order_no, "booking_id": booking["id"], "event_id": event["id"],
|
||
"event_title": event.get("title", ""), "user_id": user["id"],
|
||
"username": user.get("username", ""), "openid": openid,
|
||
"amount_fen": amount_fen, "client_type": "jsapi",
|
||
"appid": result.get("appid", ""), "prepay_id": result.get("prepay_id", ""),
|
||
"expires_at": _iso_in(pay_config.RECHARGE_EXPIRE_MINUTES * 60),
|
||
})
|
||
await _bind_order_no(booking["id"], order_no)
|
||
return {"order_no": order["order_no"], "pay_params": result.get("pay_params"),
|
||
"amount_fen": amount_fen, "expires_at": order["expires_at"]}
|
||
|
||
|
||
async def _wx_jsapi(order_no: str, amount_fen: int, title: str, openid: str) -> dict:
|
||
try:
|
||
return await wxpay.create_order(
|
||
client_type="jsapi", out_trade_no=order_no, total_fen=amount_fen,
|
||
description=f"活动报名 - {title}"[:120], openid=openid,
|
||
)
|
||
except RuntimeError as exc:
|
||
logger.error("活动报名下单失败 order=%s: %s", order_no, exc)
|
||
raise ValueError(f"微信下单失败:{exc}") from exc
|
||
|
||
|
||
async def _bind_order_no(booking_id: str, order_no: str) -> None:
|
||
"""bookings.order_no 回填(对账/排查用)。"""
|
||
tdb = _training_db()
|
||
await asyncio.to_thread(tdb.update_row, "bookings", booking_id, {"order_no": order_no, "pay_status": "unpaid"})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 回调 / 到账(= 报名确认)
|
||
# ---------------------------------------------------------------------------
|
||
async def process_paid_event_order(db: Database, order_no: str, wechat_data: dict) -> bool:
|
||
"""支付成功 → 确认报名(幂等)。返回 False = 处理失败(触发微信重试)。"""
|
||
order = await db.event_orders.get_by_order_no(order_no)
|
||
if order is None:
|
||
logger.warning("活动订单不存在: %s", order_no)
|
||
return True
|
||
if order["status"] in ("paid", "credited"):
|
||
return True # 已处理(回调重放/并发),幂等成功
|
||
|
||
payload_json = json.dumps(wechat_data, ensure_ascii=False)[:8000]
|
||
got = await db.event_orders.mark_paid(order_no, wechat_data.get("transaction_id", ""), payload_json)
|
||
if not got:
|
||
return True
|
||
|
||
try:
|
||
await asyncio.to_thread(_confirm_booking_sync, order)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.error("报名确认失败 order=%s: %s", order_no, exc, exc_info=True)
|
||
await db.event_orders.mark_status(order_no, "failed")
|
||
await db.audit.add(action="event.booking_confirm_failed", resource="event_order",
|
||
resource_id=order_no, detail=str(exc), user_id=order["user_id"])
|
||
return False
|
||
|
||
await db.event_orders.mark_credited(order_no)
|
||
await db.audit.add(action="event.booking_paid", resource="event_order",
|
||
resource_id=order_no,
|
||
detail=f"booking={order['booking_id']} event={order['event_id']} fen={order['amount_fen']}",
|
||
user_id=order["user_id"])
|
||
logger.info("活动报名支付到账 order=%s booking=%s fen=%s", order_no, order["booking_id"], order["amount_fen"])
|
||
return True
|
||
|
||
|
||
def _confirm_booking_sync(order: dict) -> None:
|
||
"""确认报名(同步,线程池执行):paid + confirmed;auto 审核活动直接 approved。"""
|
||
tdb = _training_db()
|
||
booking = tdb.fetch_by_id("bookings", order["booking_id"])
|
||
if booking is None:
|
||
raise RuntimeError(f"报名单不存在: {order['booking_id']}")
|
||
event = tdb.fetch_by_id("events", booking.get("event_id") or order["event_id"]) or {}
|
||
manual = (event.get("audit_mode") == "manual")
|
||
patch = {"pay_status": "paid", "status": "confirmed"}
|
||
if not manual and booking.get("audit_status") != "approved":
|
||
patch["audit_status"] = "approved"
|
||
tdb.update_row("bookings", booking["id"], patch)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 对账 / 过期
|
||
# ---------------------------------------------------------------------------
|
||
async def reconcile(db: Database, order: dict) -> dict:
|
||
"""pending 活动订单对账:查微信侧状态,已支付则补确认;超时未付置 closed。"""
|
||
if order["status"] != "pending":
|
||
return order
|
||
if order["expires_at"] and order["expires_at"] < utcnow_iso():
|
||
await db.event_orders.mark_status(order["order_no"], "closed")
|
||
return await db.event_orders.get_by_order_no(order["order_no"]) or order
|
||
data = await wxpay.query_order(order["order_no"])
|
||
state = (data or {}).get("trade_state", "")
|
||
if state == "SUCCESS":
|
||
if not await process_paid_event_order(db, order["order_no"], data):
|
||
logger.error("活动订单对账补单失败 order=%s", order["order_no"])
|
||
elif state in ("CLOSED", "PAY_ERROR", "REVOKED"):
|
||
await db.event_orders.mark_status(order["order_no"], "closed")
|
||
return await db.event_orders.get_by_order_no(order["order_no"]) or order
|
||
|
||
|
||
async def booking_pay_status(db: Database, booking: dict) -> dict:
|
||
"""按报名单查支付状态:pending 单先对账,再回读 bookings 行。"""
|
||
order_no = booking.get("order_no") or ""
|
||
if order_no:
|
||
order = await db.event_orders.get_by_order_no(order_no)
|
||
if order and order["status"] == "pending":
|
||
order = await reconcile(db, order)
|
||
|
||
tdb = _training_db()
|
||
row = await asyncio.to_thread(tdb.fetch_by_id, "bookings", booking["id"])
|
||
row = dict(row) if row else booking
|
||
paid = row.get("pay_status") == "paid"
|
||
return {
|
||
"paid": paid,
|
||
"payStatus": row.get("pay_status") or "",
|
||
"bookingStatus": row.get("status") or "",
|
||
"auditStatus": row.get("audit_status") or "",
|
||
}
|