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_FINISH | DGJ 收到消息,但命中“单号状态非法” | 本地退款完成事务未提交 |
延迟 REFUND_APPLY | 采购服务将 13 更新为 11 | 只完成状态前置,不会补做完成副作用 |
| MQ 补偿 | 原路重发完成事件后消费者成功 | 11 -> 5,并补齐付款、流水和入库核销 |
4. 代码入口与责任边界
4.1 多入口请求链路
| 入口/事件 | 代码位置 | 读取与处理 | 最终事实 |
|---|---|---|---|
REFUND_FINISH | application/controllers/tasks/OrderCenterNotify.php:85-86 注册;orderCenterAfterSaleRefundFinish() | 按 outAftersaleNo 找采购退货单,生成付款单/付款明细/账户流水,更新入库核销,最后将采购单置为 5 | 采购退款业务闭环 |
REFUND_APPLY | dgj-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(如有) |
采购服务 AfterSaleConsumer | orderCenterAfterSaleWaitPay、待仓库入库变成待退款 | 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 推荐状态矩阵
| 当前状态 | 事件 | 期望处理 | 是否产生退款副作用 |
|---|---|---|---|
13 | REFUND_FINISH | 允许完成处理,推进 5 | 是,但必须幂等 |
11 | REFUND_FINISH | 现有正常路径,推进 5 | 是,但必须幂等 |
5 | 重复 REFUND_FINISH | ACK,无状态回退 | 否 |
13 | REFUND_APPLY | 推进 11 | 否 |
11 | 重复 REFUND_APPLY | ACK,保持 11 | 否 |
5 | 延迟 REFUND_APPLY | ACK,保持 5,不得回退 | 否 |
| 其他非法状态 | 任一退款事件 | 告警并按重试策略处理,不盲目改状态 | 否 |
5 是终态。任何延迟事件都不能把终态退回中间态;任何重复完成消息都不能再次生成付款明细、账户流水、售后出库单收款或通知。
6. 生产补偿的安全步骤
本次已验证可以在不使用浏览器的情况下,使用线上部署服务的 RabbitMQ 客户端直接按原路由重放。以后执行前必须保留审批或用户明确授权,并逐条隔离。
6.1 补偿前检查
- 用采购退货业务键确认单据存在、未删除,记录当前
billStatus、sid、退款金额和明细金额合计。 - 确认账户流水、订单中心退款成功证据和原始
REFUND_FINISH事件金额一致。 - 确认当前没有同一业务键的消费者重试正在进行;查消息 ID、
redelivered和最近消费日志。 - 先检查是否已经存在退款付款明细。若状态和副作用不一致,停止自动重放,转人工核对,不能只看
billStatus。 - 从线上部署配置读取 RabbitMQ 连接信息和认证信息。文档只记录协议坐标:exchange 为
inspiremq.topic,routing key 为ordercenter_notify.ordercenter_aftersale_refund_finish;账号、密码、配置文件中的敏感字段不得复制到聊天、脚本或知识库。
6.2 逐条重放
- 使用原始消息结构,仅替换为已核对的目标业务键和金额;不要凭记忆拼接完整 payload。
- 投递到原 exchange 和 routing key,记录投递时间、消息 ID 和是否被 broker 标记为重投。
- 等待生产消费者成功证据,再读取数据库确认状态、付款明细、账户流水和入库核销。
- 上一条完成后再投递下一条。这样可以把“消息投递成功”“消费者处理成功”“数据库副作用正确”三个结果分开确认;若第二条失败,不会影响第一条的判断和回滚决策。
- 如果消费者返回状态非法、金额不一致或出现重复副作用,立即停止后续投递,保留证据并转人工处理。
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() 内:
- 以采购退货单业务键查询并加行锁;
- 锁内重新读取
billStatus、订单金额、明细金额和已有退款付款明细; 13、11进入完成处理,5直接 ACK;- 以
stlId + transType + paymentType + 业务退款来源等稳定业务键判断副作用是否已经存在,不能只依赖billStatus; - 校验消息退款金额、订单金额、明细合计和待记账账户金额;
- 在同一事务内完成付款、账户流水、入库核销和状态更新;事务提交后再记录可检索的完成审计信息。
8.2 REFUND_APPLY 只负责前置状态,不得回退终态
在 AfterSaleConsumer::waitPay() 内形成明确矩阵:
13 -> 11;11 -> ACK;5 -> ACK,不得写回11;- 其他状态进入告警/人工核验策略。
当前 waitPay() 只允许 13 或 11,因此延迟到达时若已被完成事件推进到 5,会被视为状态错误。应把“重复/延迟消息”与“真正非法状态”分开。
8.3 失败分类和可观测性
不要把所有异常都当作同一种 NACK:
| 类型 | 处理建议 |
|---|---|
| 重复消息且副作用已存在 | ACK,记录幂等跳过 |
合法乱序状态 13 + FINISH | 完成处理,记录乱序命中 |
延迟前置消息 5 + APPLY | ACK,记录终态保护 |
| 金额/明细不一致 | 不记账,告警并转人工;不要反复重试同一坏消息 |
| 数据库暂时性故障 | 保留事务回滚,按有上限的重试策略重试 |
| 业务键不存在或未知状态 | 告警并隔离,不能盲目创建或改状态 |
建议补充结构化字段:event_type、bill_no_hash/脱敏单号、sid、message_id、old_status、new_status、idempotent_hit、refund_amount、side_effect_counts、result。日志中不要打印完整客户信息或敏感配置。
9. 回归测试矩阵
| 场景 | 初始状态 | 事件顺序 | 预期 | 必查副作用 |
|---|---|---|---|---|
| 正常退款 | 11 | APPLY 后 FINISH | 11 -> 5 | 付款、账户、入库核销各正确 |
| 乱序退款 | 13 | FINISH 先到,APPLY 后到 | FINISH 使 13 -> 5;APPLY ACK 不回退 | 只产生一套副作用 |
| 重复完成 | 11/13 后已为 5 | FINISH、FINISH | 两次均安全,最终 5 | 第二次不新增任何流水 |
| 延迟申请 | 5 | FINISH 后 APPLY | 保持 5 | 不新增、不回退 |
| 并发完成 | 11/13 | 两个消费者同时 FINISH | 一个成功,一个幂等 ACK | 不重复付款/记账 |
| 金额不符 | 11 | FINISH 金额与订单不一致 | 拒绝并告警 | 不产生半套数据 |
| 非法状态 | 已关闭/已取消等 | 任一退款事件 | 按策略隔离 | 不盲目改状态 |
| 普通退货回归 | 正常状态 | 正常 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
下一次排查顺序
- 先确认账户资金事实,再按同一业务键找
REFUND_APPLY、REFUND_FINISH和消费者错误。 - 再查采购退货
billStatus和采购明细金额,判断是否是13/11/5的时序问题。 - 再查
t_scm_po_order_payinfo、t_scm_payment_info_<shard>、账户主/分表、采购入库核销表是否成套。 - 只有“状态与副作用都缺失”时才考虑补偿;先查是否存在部分副作用,避免重复记账。
- 需要补偿时,一次只投递一条原始完成事件;成功证据和数据库读回完成后再处理下一条。
11. 相关规则
- 生产默认只读;本次历史 MQ 重放是用户明确授权的例外。
- 不保存线上 RabbitMQ 密码、账号密钥、完整生产 payload、客户名称或完整历史订单号。
- 当前生产补偿已闭环,但“乱序可处理、重复可 ACK、延迟申请不回退”的代码修复和预发回归仍是后续工作。
- 相关可复用 Skill:
payment-oa-callback-tester;相关排查能力:kibana-log-query-runner、project-db-connector。