退款执行接口改造(2026-10-13 · 波波)

commit 2b5213cb 改动文件:backend/app/routers/admin_backend.py(+169/−53,只改 admin_execute_refund 一处)

一、修了什么

端点:POST /api/admin/orders/{order_id}/refund/execute

#缺陷后果
1完全不接收 request bodyA 端弹层填的 {amount_fen, reason} 被静默丢弃,实际退款额永远取 refund_records.approved_amount_fen ⇒ 金额输入框是骗人的 UI
2无 status='approved' 记录时直接 404refund_records 生产长期 0 行 ⇒ 平台侧永远退不了钱。10-13 订单 6337 即卡在此处,只能手工补记录绕过
3微信退款调用在事务内部事务内做网络 IO,长时间持有 orders 行锁;且结算一崩就回滚,记录回到 approved 之外的状态无从兜底

二、三条约束决定了实现形态

这三条是查证得来的,不是设计偏好——照它们改才不会出资金事故。

  1. refund_records 的 CHECK 约束没有中间态

status ∈ (pending, approved, rejected, executed, failed),没有 executing。

  1. refund_notify 只认 approved/executed

backend/app/routers/miniapp_pay.py 的退款回调定位查询是

WHERE order_id=$1 AND status IN ('approved','executed')。

⇒ 必须在调微信之前就把记录落为 approved 并 commit。否则结算事务一崩,

微信侧钱已退而 DB 未结,回调找不到记录 ⇒ 单边账且不可追溯。

  1. A 端留空时 amount_fen 字段会直接消失

admin-console/src/pages/Orders.tsx:266 留空传的是 amount_fen: undefined,

JSON.stringify 后该字段不存在(不是 null)。A 端语义本来就是「不填 = 全额」。

⇒ 后端必须支持字段缺失 = 全额,不能做成必填。

补充查证:

纯 SELECT + IMMUTABLE,无副作用 ⇒ 可以提前调用做守卫,不会预演即写入。

apply_refund 在 pending 阶段就已 frozen→balance 回充。平台直退无从判定

福钥是否已被回充,再回充一次 = 用户白得福钥 ⇒ 平台分支要求 key_deduct_fen = 0。

三、实现

拆成三段:事务A → 调微信(事务外) → 事务C

两条来源分支:

而不是静默按审批额退——否则「UI 填 50、实际退 100」的错觉只是换了个方向。

① key_deduct_fen 必须为 0(含福钥抵扣的单指引走 C 端申请 + 商户审批);

② 必须有 pay_out_trade_no(无流水号即无原路退回对象)。

其他:

四、验证

staging 端到端 45/45 PASS,12 个场景:

场景断言要点
S1 指定金额 1000200 / source=platform / cash=1000 / partial_refunded / 记录 executed / reason 留痕 / refund_no 格式 / 两个事件齐全
S2 不传 amount_fen按可退上限 5800(= 6800 − 已退 1000)退,不是总额
S3 超出上限422,且未产生退款记录、订单仍 refunding
S4 金额 0422(pydantic ge=1)
S5 含福钥抵扣422,未产生记录
S6 无 pay_out_trade_no422,未产生记录
S7 订单不存在404(既有门禁脚本依赖此行为)
S8 非 refunding422
S9 有审批 + 金额不符409,订单仍 refunding,未退钱
S10 有审批 + 金额相符200,复用审批记录,转 executed
S11 有审批 + 不传金额200,按审批额退
S12 完全不带 body200,视为全额

测试脚本自带 before 快照 + after 复原校验:订单字段、退款记录集合、事件集合三者均与跑前一致。

跑在 is_test=True 订单上(staging 未配微信商户号,is_test 容忍退款失败 ⇒ 走模拟退款)。

反证(不反证就不算验证通过)

而入参是 3000 ⇒ 旧代码「传 3000 静默退 2000」得到实证,缺陷 1 不是推测。

五、状态与后续

六、顺带核实「已修 / 无需改」的旧待办

项结论
SLA 回退 bug(crm.py)已修且已部署:4 处 s.escalate_minutes / s.first_reply_minutes 全带 COALESCE(...,120/10),本地与生产行号完全一致(141/142/149/150/159/160)
detail-config.ts 降级 bug已修:loadDetailConfig 的 catch 已有 console.warn,且返回 __configLoaded / __configKey 区分「没配」与「读失败」
C 端订单列表跳详情已支持:pages/orders/orders.wxml 有 bindtap="openDetail" + data-id,orders.ts:200-207 跳真实详情页并带 fail 提示

七、新发现(待义父拍板,未动手)

🔴 C 端下单不采集联系信息

三个下单页全部硬编码传空串:

后端 orders 表有 contact_name / contact_phone 两列,且 INSERT 会写

(backend/app/routers/miniapp_orders.py:673-677)——是前端不传。

实测:生产 11 条订单(6331–6341)姓名电话全为空串;6337 为 contact_name='' / contact_phone=''。

附带缺口:retrievalChannel 支持 mail(邮寄),但 orders 表根本没有地址列

⇒ 选了邮寄无处填地址。

需拍板:① 哪些业态强制留电话;② 是否给 orders 加收货地址列(动数据模型);

③ C 端改版需义父手动上传提审。