Files
server-core/app/pay/routers.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

216 lines
9.3 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 -*-
"""支付子应用接口层:微信回调 + 算力充值(C 端自服务)。
- ``POST /opc/pay/notify`` 微信支付回调(公网、无登录鉴权,靠 V3 验签)。
- ``/opc/compute/recharge/*`` 用户充值(套餐/下单/状态/记录),require_roles("opc_member")。
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from ..api.dependencies import get_db
from ..infrastructure.repositories import Database
from ..rbac import require_roles
from . import profitsharing, service, wxpay
from .repository import ComputeRechargeOrderRepository
logger = logging.getLogger("pay.api")
router = APIRouter(tags=["pay"])
def _repo(db: Database) -> ComputeRechargeOrderRepository:
"""订单仓储(复用请求级 session;支付域自持仓储,便于整域拆分微服务)。"""
return ComputeRechargeOrderRepository(db.session)
# ── 微信支付回调(微信服务器调用,验签解密后到账)─────────────────────────────
@router.post("/opc/pay/notify", summary="微信支付回调(无登录鉴权,V3 验签)")
async def pay_notify(request: Request, db: Database = Depends(get_db)):
body = await request.body()
result = await wxpay.verify_and_decrypt(request.headers, body)
if not result or not isinstance(result, dict):
raise HTTPException(status_code=400, detail="回调验签/解密失败")
# 展平 resource 到顶层(out_trade_no / transaction_id / trade_state ...
resource = result.pop("resource", {})
if isinstance(resource, dict):
result.update(resource)
order_no = result.get("out_trade_no", "")
logger.info("微信支付回调: order=%s trade_state=%s", order_no, result.get("trade_state"))
try:
ok = await service.handle_notify(db, result)
except Exception as exc: # noqa: BLE001
logger.error("回调处理异常 order=%s: %s", order_no, exc, exc_info=True)
raise HTTPException(status_code=500, detail="处理失败,等待微信重试") from exc
if not ok:
raise HTTPException(status_code=500, detail="处理失败,等待微信重试")
return {"code": "SUCCESS", "message": "成功"}
# ── 用户充值(C 端自服务)─────────────────────────────────────────────────────
class RechargeOrderRequest(BaseModel):
"""充值下单请求:package_id(套餐)或 amount_yuan(自由金额)二选一。"""
client_type: str = Field(default="native", description="native(桌面扫码) | jsapi(小程序)")
package_id: str = ""
amount_yuan: float | None = Field(default=None, ge=0)
@router.get("/opc/compute/recharge/packages", summary="充值套餐与支付开关")
async def recharge_packages(
db: Database = Depends(get_db),
_u: dict = Depends(require_roles("opc_member")),
):
return await service.packages(db)
@router.post("/opc/compute/recharge/orders", summary="创建充值订单(native=二维码 / jsapi=小程序支付参数)")
async def recharge_create(
body: RechargeOrderRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
order = await service.create_order(
db, user, client_type=body.client_type,
package_id=body.package_id, amount_yuan=body.amount_yuan,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return order
@router.get("/opc/compute/recharge/orders/{order_no}/pay-params", summary="小程序扫码确认支付(归属校验 + JSAPI 参数)")
async def recharge_pay_params(
order_no: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
"""微信扫桌面端小程序码 → 打开小程序本接口取 wx.requestPayment 参数。"""
try:
return await service.build_pay_params(db, user, order_no)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.post("/opc/compute/recharge/orders/{order_no}/status", summary="查询充值订单状态(含主动对账补单)")
async def recharge_status(
order_no: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
repo = _repo(db)
order = await repo.get_by_order_no(order_no)
if order is None or order["user_id"] != user["id"]:
raise HTTPException(status_code=404, detail="订单不存在")
order = await service.reconcile(db, order)
return {
"order_no": order["order_no"], "status": order["status"],
"paid": order["status"] in ("paid", "credited"),
"credited": order["status"] == "credited",
"amount_fen": order["amount_fen"], "quota_micro": order["quota_micro"],
"bonus_quota_micro": order["bonus_quota_micro"],
"transaction_id": order["transaction_id"], "expires_at": order["expires_at"],
"paid_at": order.get("paid_at", ""), "credited_at": order.get("credited_at", ""),
}
@router.get("/opc/compute/recharge/orders", summary="我的充值记录")
async def recharge_orders(
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
return {"items": await _repo(db).list_by_user(user["id"])}
# ── 微信服务商分账回调(微信服务器调用,无登录鉴权,V3 验签)───────────────────
@router.post("/opc/pay/profitsharing/notify", summary="微信分账结果回调(无登录鉴权,V3 验签)")
async def profitsharing_notify(
request: Request,
db: Database = Depends(get_db),
):
body = await request.body()
result = await profitsharing.verify_split_notify(request.headers, body)
if not result or not isinstance(result, dict):
raise HTTPException(status_code=400, detail="回调验签/解密失败")
resource = result.pop("resource", {})
if isinstance(resource, dict):
result.update(resource)
logger.info("微信分账回调: out_order_no=%s", result.get("out_order_no"))
try:
ok = await service.handle_profitsharing_notify(db, result)
except Exception as exc: # noqa: BLE001
logger.error("分账回调处理异常: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="处理失败,等待微信重试") from exc
if not ok:
raise HTTPException(status_code=500, detail="处理失败,等待微信重试")
return {"code": "SUCCESS", "message": "成功"}
# ── OPC 收款绑定(个人 openid / 商户子商户号 双路径)──────────────────────────
class BindPersonalRequest(BaseModel):
"""个人收款绑定:openid 取自登录用户小程序身份。"""
real_name: str = Field(default="", description="实名(用于微信校验,缺省用昵称)")
class BindMerchantRequest(BaseModel):
"""商户收款绑定:进件申请单号 或 已回填的子商户号。"""
sub_mchid: str = Field(default="", description="特约商户子商户号(进件通过后回填)")
applyment_id: str = Field(default="", description="进件申请单号(进件中)")
@router.post("/opc/pay/bindings/personal", summary="个人收款绑定(分账到微信零钱)")
async def opc_bind_personal(
body: BindPersonalRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.bind_personal(db, user, real_name=body.real_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.post("/opc/pay/bindings/merchant", summary="商户收款绑定(进件 / 子商户号)")
async def opc_bind_merchant(
body: BindMerchantRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.bind_merchant(
db, user, sub_mchid=body.sub_mchid, applyment_id=body.applyment_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.get("/opc/pay/bindings", summary="我的收款绑定列表")
async def opc_bindings(
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
return {"items": await service.list_bindings(db, user)}
@router.post("/opc/pay/bindings/{binding_id}/disable", summary="停用收款绑定")
async def opc_disable_binding(
binding_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.disable_binding(db, user, binding_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc