0fd210ac1f
- 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 幂等加列)+ 兜底迁移脚本 + 冒烟测试
216 lines
9.3 KiB
Python
216 lines
9.3 KiB
Python
# -*- 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
|