1. 这篇文档解决什么问题

当出现“退款金额已经进入待退款账户,但采购退货列表/详情仍显示待退款”时,优先按本文排查。核心搜索词:

REFUND_FINISH、REFUND_APPLY、单号状态非法、退款回调失败、billStatus=11、billStatus=13、billStatus=5、ordercenter_aftersale_refund_finish、ordercenter_aftersale_refund_apply。

本次现象涉及两笔历史采购退货,退款金额分别为 235 元和 110 元。金额已记入“待退款账户”,但采购退货单仍是“待退款”。两笔历史数据已经通过原退款完成消息补偿到 billStatus=5,数据库复核未发现重复记账;这只是历史数据补偿,不代表乱序代码缺陷已经修复。

2. 结论先看

  • 根因不是退款金额没有产生,而是 REFUND_FINISH 先到,消费时本地采购退货单仍为 13=待仓库入库。
  • 旧 DGJ 消费者只接受 11=待退款,因此抛出“单号状态非法”,事务回滚并返回 NACK。
  • 次日到达的 REFUND_APPLY 只把 13 更新为 11;当前采购服务没有注册 REFUND_FINISH,所以没有再次执行退款完成副作用,单据卡在 11。
  • “先确认上一笔消费者成功,再投递下一笔”是补偿时的安全隔离动作:每条消息都要单独确认消费日志、状态变化和金额流水,避免两条消息结果混淆或重复记账。
  • 生产补偿应通过原路 RabbitMQ 事件重放,不应直接 SQL 把状态改成 5。直接改状态无法补齐付款明细、账户流水和入库核销。

3. 已验证的消息时序

sequenceDiagram
    participant ODC as 订单中心
    participant MQ as RabbitMQ ordercenter_notify
    participant DGJ as DGJ旧退款完成消费者
    participant PS as 采购服务退款申请消费者
    participant DB as 采购退货及支付数据
    participant AC as 待退款账户

    ODC->>MQ: REFUND_APPLY(应推进仓库状态)
    ODC->>AC: 退款成功,金额入待退款账户
    ODC->>MQ: REFUND_FINISH
    MQ->>DGJ: 投递退款完成
    DGJ->>DB: 读取 billStatus=13
    DGJ-->>MQ: 状态非法,事务回滚,NACK
    MQ->>PS: 延迟消费 REFUND_APPLY
    PS->>DB: 13 -> 11
    Note over DB,MQ: 没有新的 FINISH,故不会自动执行 11 -> 5

关键时序应按业务本地时间理解;Kibana 的 @timestamp 可能是 UTC,查询结果要先确认时区再排序。

环节已验证事实业务含义
退款账户流水235 元、110 元分别进入账户类型 06,流水方向为收入退款资金侧已完成
REFUND_FINISHDGJ 收到消息,但命中“单号状态非法”本地退款完成事务未提交
延迟 REFUND_APPLY采购服务将 13 更新为 11只完成状态前置,不会补做完成副作用
MQ 补偿原路重发完成事件后消费者成功11 -> 5,并补齐付款、流水和入库核销

4. 代码入口与责任边界

4.1 多入口请求链路

入口/事件代码位置读取与处理最终事实
REFUND_FINISHapplication/controllers/tasks/OrderCenterNotify.php:85-86 注册;orderCenterAfterSaleRefundFinish()按 outAftersaleNo 找采购退货单,生成付款单/付款明细/账户流水,更新入库核销,最后将采购单置为 5采购退款业务闭环
REFUND_APPLYdgj-purchase-service/app/Amqp/Consumer/OrderCenter/AfterSaleConsumer.php:90 注册;waitPay()按 outAftersaleNo 找采购退货单,将 13 置为 11采购单进入待退款
退款账户流水订单中心/支付中心链路,本文只以生产日志和账户表读回为证据账户流水与采购退款单是两个事实源不能用账户有收入推断采购单已经 5

当前责任边界是:旧 DGJ 消费者单一负责 REFUND_FINISH,新采购服务当前只负责 REFUND_APPLY。优化前不要让两个服务同时对同一完成事件记账。

4.2 日志证据矩阵

日志来源搜索锚点成功/失败信号关联键
订单中心/支付中心日志REFUND_APPLY、REFUND_FINISH、outAftersaleNo生产者发布时间、事件类型、退款金额采购退货单号、事件类型、消息 ID(如有)
DGJ OrderCenterNotify退款回调失败、单号状态非法、退款回调结束成功为结束日志并伴随 DB 读回;失败为异常、回滚、NACK采购退货单号、sid、消息 ID/RequestId(如有)
采购服务 AfterSaleConsumerorderCenterAfterSaleWaitPay、待仓库入库变成待退款13 -> 11 或重复状态 ACK采购退货单号、事件类型
MQ 消费日志exchange、routing key、redelivered、消费结果消费成功、重投、NACK/重试消息 ID、采购退货单号、金额
数据库billStatus、stlId、stlNo、rpAmount、hxStateCode状态和所有副作用是否成套出现采购退货单主键/单号

当前旧消费者的成功日志不是强结构化审计事件,不能只凭一条“开始消费”日志认定成功;必须结合数据库读回。

5. 状态机与验收矩阵

5.1 当前状态含义

来源:application/KzData/Enums/PoOrderEnums.php 的 BILL_STATUS_MAP。

状态含义
13待仓库入库
11待退款
5已完成

5.2 推荐状态矩阵

当前状态事件期望处理是否产生退款副作用
13REFUND_FINISH允许完成处理,推进 5是,但必须幂等
11REFUND_FINISH现有正常路径,推进 5是,但必须幂等
5重复 REFUND_FINISHACK,无状态回退否
13REFUND_APPLY推进 11否
11重复 REFUND_APPLYACK,保持 11否
5延迟 REFUND_APPLYACK,保持 5,不得回退否
其他非法状态任一退款事件告警并按重试策略处理,不盲目改状态否

5 是终态。任何延迟事件都不能把终态退回中间态;任何重复完成消息都不能再次生成付款明细、账户流水、售后出库单收款或通知。

6. 生产补偿的安全步骤

本次已验证可以在不使用浏览器的情况下,使用线上部署服务的 RabbitMQ 客户端直接按原路由重放。以后执行前必须保留审批或用户明确授权,并逐条隔离。

6.1 补偿前检查

  1. 用采购退货业务键确认单据存在、未删除,记录当前 billStatus、sid、退款金额和明细金额合计。
  2. 确认账户流水、订单中心退款成功证据和原始 REFUND_FINISH 事件金额一致。
  3. 确认当前没有同一业务键的消费者重试正在进行;查消息 ID、redelivered 和最近消费日志。
  4. 先检查是否已经存在退款付款明细。若状态和副作用不一致,停止自动重放,转人工核对,不能只看 billStatus。
  5. 从线上部署配置读取 RabbitMQ 连接信息和认证信息。文档只记录协议坐标:exchange 为 inspiremq.topic,routing key 为 ordercenter_notify.ordercenter_aftersale_refund_finish;账号、密码、配置文件中的敏感字段不得复制到聊天、脚本或知识库。

6.2 逐条重放

  1. 使用原始消息结构,仅替换为已核对的目标业务键和金额;不要凭记忆拼接完整 payload。
  2. 投递到原 exchange 和 routing key,记录投递时间、消息 ID 和是否被 broker 标记为重投。
  3. 等待生产消费者成功证据,再读取数据库确认状态、付款明细、账户流水和入库核销。
  4. 上一条完成后再投递下一条。这样可以把“消息投递成功”“消费者处理成功”“数据库副作用正确”三个结果分开确认;若第二条失败,不会影响第一条的判断和回滚决策。
  5. 如果消费者返回状态非法、金额不一致或出现重复副作用,立即停止后续投递,保留证据并转人工处理。

6.3 为什么不能直接 SQL 改成 5

退款完成处理不是单字段更新。当前 OrderCenterNotify::orderCenterAfterSaleRefundFinish() 在一个事务中还会:

  • 新增采购付款单和付款明细;
  • 新增/更新账户流水;
  • 将关联采购入库单 rpAmount 更新为 amount、hxStateCode 更新为 2;
  • 写入采购单付款关联信息和站内通知;
  • 最后更新采购退货单为 billStatus=5。

只执行 UPDATE t_scm_po_order SET billStatus=5 会造成“页面显示完成但付款/核销副作用缺失”,后续对账更难修复。

7. 数据库只读核验模板

以下 SQL 只用于按具体单号替换参数后的生产只读核验。生产执行前设置只读事务;不要把完整客户列表、账号或密码写入脚本和文档。

START TRANSACTION READ ONLY;

-- 1. 采购退货主单、状态、金额
SELECT id, sid, billNo, billStatus, isDelete, transType,
       amount, totalAmount, totalQty, paymentTime
FROM t_scm_po_order
WHERE billNo = :bill_no;

-- 2. 退款明细金额合计(按实际 sid 选择对应分表)
SELECT iid, COUNT(*) AS detail_count,
       SUM(qty) AS qty_sum, SUM(amount) AS amount_sum
FROM t_scm_po_order_info_<sid_shard>
WHERE iid = :po_order_id
GROUP BY iid;

-- 3. 付款单与采购退款付款关联
SELECT iid, COUNT(*) AS payinfo_count,
       SUM(refund) AS refund_amount,
       SUM(amount) AS other_amount
FROM t_scm_po_order_payinfo
WHERE iid = :po_order_id
GROUP BY iid;

SELECT iid, stlId, stlNo, transType, paymentType,
       COUNT(*) AS payment_row_count, SUM(amount) AS amount_sum
FROM t_scm_payment_info_<sid_shard>
WHERE stlId = :po_order_id
GROUP BY iid, stlId, stlNo, transType, paymentType;

-- 4. 入库核销
SELECT srcOrderId, amount, rpAmount, hxStateCode, isDelete
FROM t_scm_pu_invoice_<sid_shard>
WHERE srcOrderId = :po_order_id;

-- 5. 主表/分表账户流水按业务键核对数量和金额
SELECT accId, COUNT(*) AS row_count, SUM(amount) AS amount_sum
FROM t_scm_account
WHERE iid = :payment_id OR billNo = :payment_bill_no
GROUP BY accId;

SELECT accId, COUNT(*) AS row_count, SUM(amount) AS amount_sum
FROM t_scm_account_info_<sid_shard>
WHERE iid = :payment_id OR billNo = :payment_bill_no
GROUP BY accId;

COMMIT;

核验要点:

  • 两笔历史补偿均表现为:采购退货 11 -> 5,退款付款明细各 1 套,付款流水金额为负,待退款账户流水金额为正,入库单 rpAmount=amount 且 hxStateCode=2。
  • t_scm_payment_info_<shard>.amount 为负、账户流水为正,是当前记账方向的正常差异,不能单独判异常。
  • t_bs_account.amount 是基础余额字段,不等于有效账户余额;需要按当前代码的有效账户流水规则汇总主表/分表后判断。
  • “每张表各 1 条”不是通用硬编码规则;复杂退款、分账户退款或历史重试要按业务键、金额和状态组合核对。
  • 结果应在同一只读快照内比较,避免查询期间并发写入导致主表、分表和入库表读到不同时间点。

8. 代码优化方案

8.1 完成事件加锁并以业务键幂等

在 OrderCenterNotify::orderCenterAfterSaleRefundFinish() 内:

  1. 以采购退货单业务键查询并加行锁;
  2. 锁内重新读取 billStatus、订单金额、明细金额和已有退款付款明细;
  3. 13、11 进入完成处理,5 直接 ACK;
  4. 以 stlId + transType + paymentType + 业务退款来源 等稳定业务键判断副作用是否已经存在,不能只依赖 billStatus;
  5. 校验消息退款金额、订单金额、明细合计和待记账账户金额;
  6. 在同一事务内完成付款、账户流水、入库核销和状态更新;事务提交后再记录可检索的完成审计信息。

8.2 REFUND_APPLY 只负责前置状态,不得回退终态

在 AfterSaleConsumer::waitPay() 内形成明确矩阵:

  • 13 -> 11;
  • 11 -> ACK;
  • 5 -> ACK,不得写回 11;
  • 其他状态进入告警/人工核验策略。

当前 waitPay() 只允许 13 或 11,因此延迟到达时若已被完成事件推进到 5,会被视为状态错误。应把“重复/延迟消息”与“真正非法状态”分开。

8.3 失败分类和可观测性

不要把所有异常都当作同一种 NACK:

类型处理建议
重复消息且副作用已存在ACK,记录幂等跳过
合法乱序状态 13 + FINISH完成处理,记录乱序命中
延迟前置消息 5 + APPLYACK,记录终态保护
金额/明细不一致不记账,告警并转人工;不要反复重试同一坏消息
数据库暂时性故障保留事务回滚,按有上限的重试策略重试
业务键不存在或未知状态告警并隔离,不能盲目创建或改状态

建议补充结构化字段:event_type、bill_no_hash/脱敏单号、sid、message_id、old_status、new_status、idempotent_hit、refund_amount、side_effect_counts、result。日志中不要打印完整客户信息或敏感配置。

9. 回归测试矩阵

场景初始状态事件顺序预期必查副作用
正常退款11APPLY 后 FINISH11 -> 5付款、账户、入库核销各正确
乱序退款13FINISH 先到,APPLY 后到FINISH 使 13 -> 5;APPLY ACK 不回退只产生一套副作用
重复完成11/13 后已为 5FINISH、FINISH两次均安全,最终 5第二次不新增任何流水
延迟申请5FINISH 后 APPLY保持 5不新增、不回退
并发完成11/13两个消费者同时 FINISH一个成功,一个幂等 ACK不重复付款/记账
金额不符11FINISH 金额与订单不一致拒绝并告警不产生半套数据
非法状态已关闭/已取消等任一退款事件按策略隔离不盲目改状态
普通退货回归正常状态正常 APPLY/FINISH旧链路不变普通订单、坏品退货、仓库入库

预发验收至少要同时看消费者日志和数据库读回,不能只看消息客户端的 publish confirm;publish confirm 只能证明 broker 接收,不能证明业务事务提交。

10. 本次证据与后续查找入口

已验证来源

  • DGJ 退款完成消费者:/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/controllers/tasks/OrderCenterNotify.php
  • 采购服务退款申请消费者:/Users/zhoujiangbin/code/docker-dev-env/www/dgj-purchase-service/app/Amqp/Consumer/OrderCenter/AfterSaleConsumer.php
  • 状态枚举:/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/KzData/Enums/PoOrderEnums.php
  • 事件枚举:/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/application/KzData/Enums/MqEventEnums.php
  • 采购服务事件枚举:/Users/zhoujiangbin/code/docker-dev-env/www/dgj-purchase-service/vendor/kz/kz-rpc-base/src/Amqp/Enums/EventTypeEnums.php
  • RabbitMQ exchange 枚举:/Users/zhoujiangbin/code/docker-dev-env/www/dgj-purchase-service/vendor/kz/kz-rpc-base/src/Amqp/Enums/ExchangeEnums.php
  • 当前任务证据:/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/doc/ai/changes/active/BUG-20260813-FLASH-ORDER-CLOSE-秒杀折扣订单支持关闭/evidence.md
  • 当前任务验证:/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0/doc/ai/changes/active/BUG-20260813-FLASH-ORDER-CLOSE-秒杀折扣订单支持关闭/verification.md

下一次排查顺序

  1. 先确认账户资金事实,再按同一业务键找 REFUND_APPLY、REFUND_FINISH 和消费者错误。
  2. 再查采购退货 billStatus 和采购明细金额,判断是否是 13/11/5 的时序问题。
  3. 再查 t_scm_po_order_payinfo、t_scm_payment_info_<shard>、账户主/分表、采购入库核销表是否成套。
  4. 只有“状态与副作用都缺失”时才考虑补偿;先查是否存在部分副作用,避免重复记账。
  5. 需要补偿时,一次只投递一条原始完成事件;成功证据和数据库读回完成后再处理下一条。

11. 相关规则

  • 生产默认只读;本次历史 MQ 重放是用户明确授权的例外。
  • 不保存线上 RabbitMQ 密码、账号密钥、完整生产 payload、客户名称或完整历史订单号。
  • 当前生产补偿已闭环,但“乱序可处理、重复可 ACK、延迟申请不回退”的代码修复和预发回归仍是后续工作。
  • 相关可复用 Skill:payment-oa-callback-tester;相关排查能力:kibana-log-query-runner、project-db-connector。