commit 2b5213cb 改动文件:backend/app/routers/admin_backend.py(+169/−53,只改 admin_execute_refund 一处)
端点:POST /api/admin/orders/{order_id}/refund/execute
| # | 缺陷 | 后果 |
|---|---|---|
| 1 | 完全不接收 request body | A 端弹层填的 {amount_fen, reason} 被静默丢弃,实际退款额永远取 refund_records.approved_amount_fen ⇒ 金额输入框是骗人的 UI |
| 2 | 无 status='approved' 记录时直接 404 | refund_records 生产长期 0 行 ⇒ 平台侧永远退不了钱。10-13 订单 6337 即卡在此处,只能手工补记录绕过 |
| 3 | 微信退款调用在事务内部 | 事务内做网络 IO,长时间持有 orders 行锁;且结算一崩就回滚,记录回到 approved 之外的状态无从兜底 |
这三条是查证得来的,不是设计偏好——照它们改才不会出资金事故。
refund_records 的 CHECK 约束没有中间态 status ∈ (pending, approved, rejected, executed, failed),没有 executing。
refund_notify 只认 approved/executed backend/app/routers/miniapp_pay.py 的退款回调定位查询是
WHERE order_id=$1 AND status IN ('approved','executed')。
⇒ 必须在调微信之前就把记录落为 approved 并 commit。否则结算事务一崩,
微信侧钱已退而 DB 未结,回调找不到记录 ⇒ 单边账且不可追溯。
amount_fen 字段会直接消失 admin-console/src/pages/Orders.tsx:266 留空传的是 amount_fen: undefined,
JSON.stringify 后该字段不存在(不是 null)。A 端语义本来就是「不填 = 全额」。
⇒ 后端必须支持字段缺失 = 全额,不能做成必填。
补充查证:
enforce_order_transition(backend/sql/057_order_state_machine.sql:83)是纯 SELECT + IMMUTABLE,无副作用 ⇒ 可以提前调用做守卫,不会预演即写入。
backend/app/routers/miniapp_orders.py:1967-1999,且 apply_refund 在 pending 阶段就已 frozen→balance 回充。平台直退无从判定
福钥是否已被回充,再回充一次 = 用户白得福钥 ⇒ 平台分支要求 key_deduct_fen = 0。
拆成三段:事务A → 调微信(事务外) → 事务C
FOR UPDATE)→ 404/422 校验 → 决议金额 → 建/取 approved 记录 → 状态机守卫 → commitwxpay.refund,不再持有 orders 行锁executed / 订单状态 / order_events两条来源分支:
amount_fen 与审批额不符时 409,而不是静默按审批额退——否则「UI 填 50、实际退 100」的错觉只是换了个方向。
① key_deduct_fen 必须为 0(含福钥抵扣的单指引走 C 端申请 + 商户审批);
② 必须有 pay_out_trade_no(无流水号即无原路退回对象)。
其他:
total_fen − 已 executed 退款额,超限 422,防同一单被反复退穿。out_refund_no = refund_no = RF-{order}-{rec_id} ⇒ 同记录重复执行,微信侧幂等,不会双退。refund_no 有唯一索引 ⇒ 新记录先落时间戳占位,拿到 id 后回填成 RF-{order}-{id}(与分支 A 同格式)。refundNo / source / toStatus,便于追溯。staging 端到端 45/45 PASS,12 个场景:
| 场景 | 断言要点 |
|---|---|
| S1 指定金额 1000 | 200 / source=platform / cash=1000 / partial_refunded / 记录 executed / reason 留痕 / refund_no 格式 / 两个事件齐全 |
| S2 不传 amount_fen | 按可退上限 5800(= 6800 − 已退 1000)退,不是总额 |
| S3 超出上限 | 422,且未产生退款记录、订单仍 refunding |
| S4 金额 0 | 422(pydantic ge=1) |
| S5 含福钥抵扣 | 422,未产生记录 |
| S6 无 pay_out_trade_no | 422,未产生记录 |
| S7 订单不存在 | 404(既有门禁脚本依赖此行为) |
| S8 非 refunding | 422 |
| S9 有审批 + 金额不符 | 409,订单仍 refunding,未退钱 |
| S10 有审批 + 金额相符 | 200,复用审批记录,转 executed |
| S11 有审批 + 不传金额 | 200,按审批额退 |
| S12 完全不带 body | 200,视为全额 |
测试脚本自带 before 快照 + after 复原校验:订单字段、退款记录集合、事件集合三者均与跑前一致。
跑在 is_test=True 订单上(staging 未配微信商户号,is_test 容忍退款失败 ⇒ 走模拟退款)。
而入参是 3000 ⇒ 旧代码「传 3000 静默退 2000」得到实证,缺陷 1 不是推测。
py_compile 三处注入(缺右括号 / 少逗号 / 破坏 async with)全部被抓出 ⇒ 语法检查有效。scripts/verify_staging_money_gate.py GATE PASS ⇒ 404 分支未被破坏。{amount_fen, reason},现在后端才真正接收)。refunding 状态的订单走一次平台直退,核对微信侧 refund_id 与 refund_records 一致。| 项 | 结论 |
|---|---|
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 端下单不采集联系信息
三个下单页全部硬编码传空串:
miniprogram-v3/pages/product-detail/product-detail.ts:193-194, 244-245miniprogram-v3/pages/hotel-booking/hotel-booking.ts:189-190miniprogram-v3/pages/item-detail/item-detail.ts:123-124, 461-462, 544-545后端 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 端改版需义父手动上传提审。