Files
server-core/app/pay/event_service.py
T
Pine 374767bbca chore: 清理废弃 SQLite 代码与过时注释
- 删除死代码 app/db.py / app/repositories.py / app/dependencies.py(legacy 模块,
  import 即损坏且全仓无引用,活跃实现在 app/infrastructure/)
- 删除废弃库文件 serverdata/data/opc.db(2026-08-24 并入总库前的旧分库)
- 注释措辞同步 SQLite→统一总库(DATABASE_URL 决定方言)
2026-08-31 21:46:22 +08:00

177 lines
8.2 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.
# -*- 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 + confirmedauto 审核活动直接 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 "",
}