Files
server-core/app/pay/repository.py
T
Pine 0fd210ac1f feat(pay): 微信服务商分账+资金托管改造——OPC个人/商户双绑定收款、验收95/5分账、回调确认、escrow资金托管字段扩展
- profitsharing.py:服务商分账客户端(添加接收方/发起分账/查分账/退款/回调验签)
- payment_bindings + settlement_logs:OPC 收款绑定(personal openid / merchant 子商户号)与分账流水镜像
- settlement_service.release_escrow:验收后优先发起微信分账(95%接单者/5%平台),未配置时降级台账结算(channel=manual)
- handle_split_notify:分账回调确认 escrow released(幂等)
- 路由:分账回调 + 绑定创建/列表/停用
- 迁移:alembic 0039(新表 + escrows 幂等加列)+ 兜底迁移脚本 + 冒烟测试
2026-09-01 18:39:41 +08:00

332 lines
13 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 -*-
"""支付子应用仓储:充值订单行级操作(不掺 HTTP 逻辑)。"""
from __future__ import annotations
from sqlalchemy import select
from ..infrastructure.repositories import new_id, utcnow_iso
from .models import ComputeRechargeOrder, EventOrder, PaymentBinding, SettlementLog
def _to_dict(o: ComputeRechargeOrder) -> dict:
return {
"id": o.id, "order_no": o.order_no, "user_id": o.user_id, "username": o.username,
"engine_user_id": o.engine_user_id, "package_id": o.package_id,
"amount_fen": o.amount_fen, "quota_micro": o.quota_micro,
"bonus_quota_micro": o.bonus_quota_micro, "client_type": o.client_type,
"appid": o.appid, "status": o.status, "prepay_id": o.prepay_id,
"transaction_id": o.transaction_id, "code_url": o.code_url,
"expires_at": o.expires_at, "paid_at": o.paid_at, "credited_at": o.credited_at,
"notify_payload": o.notify_payload, "created_at": o.created_at, "updated_at": o.updated_at,
}
class ComputeRechargeOrderRepository:
def __init__(self, session):
self.session = session
async def create(self, fields: dict) -> dict:
now = utcnow_iso()
row = ComputeRechargeOrder(
id=new_id("cr"), created_at=now, updated_at=now,
**{k: v for k, v in fields.items() if hasattr(ComputeRechargeOrder, k) and k != "id"},
)
self.session.add(row)
await self.session.commit()
return _to_dict(row)
async def get_by_order_no(self, order_no: str) -> dict | None:
row = await self.session.scalar(
select(ComputeRechargeOrder).where(ComputeRechargeOrder.order_no == order_no)
)
return _to_dict(row) if row else None
async def find_pending_same_amount(self, user_id: str, amount_fen: int, now_iso: str) -> dict | None:
"""同用户同金额未过期 pending 单(下单防重复扫码复用)。"""
row = await self.session.scalar(
select(ComputeRechargeOrder)
.where(
ComputeRechargeOrder.user_id == user_id,
ComputeRechargeOrder.amount_fen == amount_fen,
ComputeRechargeOrder.status == "pending",
ComputeRechargeOrder.expires_at > now_iso,
)
.order_by(ComputeRechargeOrder.created_at.desc())
.limit(1)
)
return _to_dict(row) if row else None
async def list_by_user(self, user_id: str, limit: int = 50) -> list[dict]:
rows = (await self.session.scalars(
select(ComputeRechargeOrder)
.where(ComputeRechargeOrder.user_id == user_id)
.order_by(ComputeRechargeOrder.created_at.desc())
.limit(limit)
)).all()
return [_to_dict(r) for r in rows]
async def _row(self, order_no: str) -> ComputeRechargeOrder | None:
return await self.session.scalar(
select(ComputeRechargeOrder).where(ComputeRechargeOrder.order_no == order_no)
)
async def mark_paid(self, order_no: str, transaction_id: str, payload_json: str) -> bool:
"""pending → paid(幂等排他):仅当仍是 pending 才置 paid;False=已被处理(回调重放/并发)。"""
row = await self._row(order_no)
if row is None or row.status != "pending":
return False
row.status = "paid"
row.transaction_id = transaction_id or row.transaction_id
row.paid_at = utcnow_iso()
row.notify_payload = payload_json
row.updated_at = utcnow_iso()
await self.session.commit()
return True
async def mark_credited(self, order_no: str) -> None:
"""paid → credited:引擎到账完成。"""
row = await self._row(order_no)
if row is None:
return
row.status = "credited"
row.credited_at = utcnow_iso()
row.updated_at = utcnow_iso()
await self.session.commit()
async def set_prepay(self, order_no: str, appid: str, prepay_id: str) -> None:
"""记录 JSAPI 预支付信息(pending 态,小程序扫码拉起支付时回填)。"""
row = await self._row(order_no)
if row is None:
return
row.appid = appid or row.appid
row.prepay_id = prepay_id or row.prepay_id
row.updated_at = utcnow_iso()
await self.session.commit()
async def mark_status(self, order_no: str, status: str, payload_json: str = "") -> None:
"""置 closed / failed 等非终态迁移。"""
row = await self._row(order_no)
if row is None:
return
row.status = status
if payload_json:
row.notify_payload = payload_json
row.updated_at = utcnow_iso()
await self.session.commit()
# ---------------------------------------------------------------------------
# 活动报名订单(课程/沙龙定价支付)
# ---------------------------------------------------------------------------
class EventOrderRepository:
def __init__(self, session):
self.session = session
async def create(self, fields: dict) -> dict:
now = utcnow_iso()
row = EventOrder(
id=new_id("ev"), created_at=now, updated_at=now,
**{k: v for k, v in fields.items() if hasattr(EventOrder, k) and k != "id"},
)
self.session.add(row)
await self.session.commit()
return _to_event_dict(row)
async def get_by_order_no(self, order_no: str) -> dict | None:
row = await self.session.scalar(
select(EventOrder).where(EventOrder.order_no == order_no)
)
return _to_event_dict(row) if row else None
async def find_pending_by_booking(self, booking_id: str, now_iso: str) -> dict | None:
"""同报名未过期 pending 单(继续支付复用,避免重复下单)。"""
row = await self.session.scalar(
select(EventOrder)
.where(
EventOrder.booking_id == booking_id,
EventOrder.status == "pending",
EventOrder.expires_at > now_iso,
)
.order_by(EventOrder.created_at.desc())
.limit(1)
)
return _to_event_dict(row) if row else None
async def _row(self, order_no: str) -> EventOrder | None:
return await self.session.scalar(
select(EventOrder).where(EventOrder.order_no == order_no)
)
async def mark_paid(self, order_no: str, transaction_id: str, payload_json: str) -> bool:
"""pending → paid(幂等排他):仅当仍是 pending 才置 paidFalse=已被处理。"""
row = await self._row(order_no)
if row is None or row.status != "pending":
return False
row.status = "paid"
row.transaction_id = transaction_id or row.transaction_id
row.paid_at = utcnow_iso()
row.notify_payload = payload_json
row.updated_at = utcnow_iso()
await self.session.commit()
return True
async def mark_credited(self, order_no: str) -> None:
"""paid → credited:报名确认完成。"""
row = await self._row(order_no)
if row is None:
return
row.status = "credited"
row.credited_at = utcnow_iso()
row.updated_at = utcnow_iso()
await self.session.commit()
async def set_prepay(self, order_no: str, appid: str, prepay_id: str) -> None:
row = await self._row(order_no)
if row is None:
return
row.appid = appid or row.appid
row.prepay_id = prepay_id or row.prepay_id
row.updated_at = utcnow_iso()
await self.session.commit()
async def mark_status(self, order_no: str, status: str, payload_json: str = "") -> None:
row = await self._row(order_no)
if row is None:
return
row.status = status
if payload_json:
row.notify_payload = payload_json
row.updated_at = utcnow_iso()
await self.session.commit()
def _to_event_dict(o: EventOrder) -> dict:
return {
"id": o.id, "order_no": o.order_no, "booking_id": o.booking_id,
"event_id": o.event_id, "event_title": o.event_title,
"user_id": o.user_id, "username": o.username, "openid": o.openid,
"amount_fen": o.amount_fen, "client_type": o.client_type, "appid": o.appid,
"status": o.status, "prepay_id": o.prepay_id, "transaction_id": o.transaction_id,
"expires_at": o.expires_at, "paid_at": o.paid_at, "credited_at": o.credited_at,
"notify_payload": o.notify_payload, "created_at": o.created_at, "updated_at": o.updated_at,
}
# ---------------------------------------------------------------------------
# OPC 收款绑定(微信服务商分账接收方)
# ---------------------------------------------------------------------------
def _to_binding_dict(b: PaymentBinding) -> dict:
return {
"id": b.id, "user_id": b.user_id, "bind_type": b.bind_type,
"openid": b.openid, "real_name": b.real_name, "sub_mchid": b.sub_mchid,
"applyment_id": b.applyment_id, "status": b.status,
"created_at": b.created_at, "updated_at": b.updated_at,
}
class PaymentBindingRepository:
"""OPC 收款绑定仓储(个人 openid / 商户子商户号双路径)。"""
def __init__(self, session):
self.session = session
async def create(self, fields: dict) -> dict:
now = utcnow_iso()
row = PaymentBinding(
id=new_id("pb"), created_at=now, updated_at=now,
**{k: v for k, v in fields.items() if hasattr(PaymentBinding, k) and k != "id"},
)
self.session.add(row)
await self.session.commit()
return _to_binding_dict(row)
async def get(self, binding_id: str) -> dict | None:
row = await self.session.get(PaymentBinding, binding_id)
return _to_binding_dict(row) if row else None
async def list_by_user(self, user_id: str) -> list[dict]:
rows = (await self.session.scalars(
select(PaymentBinding).where(PaymentBinding.user_id == user_id)
.order_by(PaymentBinding.created_at.desc())
)).all()
return [_to_binding_dict(r) for r in rows]
async def get_active(self, user_id: str, bind_type: str | None = None) -> dict | None:
"""取该用户可接收分账的绑定(优先商户绑定→个人绑定;bind_type 指定时仅取该类型)。"""
order = ("merchant", "personal") if bind_type is None else (bind_type,)
for bt in order:
row = await self.session.scalar(
select(PaymentBinding)
.where(
PaymentBinding.user_id == user_id,
PaymentBinding.status == "active",
PaymentBinding.bind_type == bt,
)
.order_by(PaymentBinding.created_at.desc())
.limit(1)
)
if row is not None:
return _to_binding_dict(row)
return None
async def update(self, binding_id: str, fields: dict) -> dict | None:
row = await self.session.get(PaymentBinding, binding_id)
if row is None:
return None
for k, v in fields.items():
if hasattr(PaymentBinding, k):
setattr(row, k, v)
row.updated_at = utcnow_iso()
await self.session.commit()
return _to_binding_dict(row)
async def set_status(self, binding_id: str, status: str, **extra) -> dict | None:
return await self.update(binding_id, {"status": status, **extra})
# ---------------------------------------------------------------------------
# 分账流水镜像(对账)
# ---------------------------------------------------------------------------
def _to_log_dict(l: SettlementLog) -> dict:
return {
"id": l.id, "escrow_id": l.escrow_id, "task_id": l.task_id,
"share_order_no": l.share_order_no, "channel": l.channel,
"amount": l.amount, "commission": l.commission,
"receivers_json": l.receivers_json, "status": l.status, "detail": l.detail,
"created_at": l.created_at, "updated_at": l.updated_at,
}
class SettlementLogRepository:
"""分账流水镜像仓储(业务侧记录真金去向,供对账/审计)。"""
def __init__(self, session):
self.session = session
async def create(self, fields: dict) -> dict:
now = utcnow_iso()
row = SettlementLog(
id=new_id("sl"), created_at=now, updated_at=now,
**{k: v for k, v in fields.items() if hasattr(SettlementLog, k) and k != "id"},
)
self.session.add(row)
await self.session.commit()
return _to_log_dict(row)
async def get_by_share_order_no(self, share_order_no: str) -> dict | None:
row = await self.session.scalar(
select(SettlementLog).where(SettlementLog.share_order_no == share_order_no)
)
return _to_log_dict(row) if row else None
async def set_status(self, log_id: str, status: str, detail: str = "") -> dict | None:
row = await self.session.get(SettlementLog, log_id)
if row is None:
return None
row.status = status
if detail:
row.detail = detail
row.updated_at = utcnow_iso()
await self.session.commit()
return _to_log_dict(row)