feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s

用 PRD 2.14 语义整体替换退款佣金「整单全额失效」实现:原佣金保持已发放不变,
回溯事实落在新表 tb_commission_clawback_record 的负数、不可提现明细上。

- 新增成对迁移 000220 建 tb_commission_clawback_record,唯一约束
  (refund_id, original_commission_id) 为权威幂等键,附店铺+时间/原佣金/订单索引。
- 回溯用例(internal/service/refund/clawback.go):准入仅由退款申请状态、审批异常
  标记与退款方式决定;金额按分整数计算,分母取冻结实收(缺失回落审批尝试)、
  分子原路取渠道成功金额,乘法用 math/big 中间量,舍入差自末条起向前补差;
  终态判据要求订单佣金已离开待计算且不存在 status IN (1,2,99) 的记录。
- 三层幂等:唯一约束兜底、佣金行行锁 + 钱包乐观锁、commission_deducted 仅作投影
  并带 WHERE commission_deducted = false 条件置位;闭合三结果为已回溯、无需回溯、
  审批异常转人工。
- 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 →
  扣 balance(允许为负)→ 写负数流水 → 审计;删除旧全额失效写入与其两个审计调用点,
  refund.invalidate_commission 仅保留常量与注册供历史审计读取。
- 读侧:佣金明细列表 status 筛选透传,两表 UNION ALL 合并分页并以 source ASC 作
  末位次序键;新增佣金明细详情接口并同步路由与 OpenAPI 装配。
- 导出:新增 commission_record 场景(白名单、exporter 注册、DTO oneof、DataSource
  与列定义),粒度为佣金记录,原佣金与回溯各一行,金额保持分且可为负。
- 新增退款佣金回溯周期补偿任务(@every 1m / MaxRetry(3) / Timeout(10m) /
  Unique(10m),独立队列),保留启动时补偿扫描,判据与既有实现一致。

Refs: AUG26-012
This commit is contained in:
2026-09-14 13:40:34 +08:00
parent 67893617fe
commit 1aa4eacee2
30 changed files with 1867 additions and 399 deletions

View File

@@ -1,30 +1,161 @@
## Context
退款佣金终态可能异步到达,回溯必须等待原佣金事实且不能修改其历史记录
现有退款佣金回扣是「整单全额失效」:退款 Outbox 事件 `refund.commission.deduct.requested` 触发 `ProcessCommissionDeduction`,由 `deductAllCommission` 对每条已发放佣金调用 `deductSingleCommission`,把原佣金记录状态由 3 改为 4、扣减佣金钱包并写 `commission_deduct` 负数流水,审计动作 `refund.invalidate_commission`。该路径已正确实现两件本 Change 要复用的事实:加锁顺序「申请行 → 尝试行 → 钱包行」,以及「先释放待审提现冻结、再扣减余额」。动机见 `proposal.md` - Why
约束:
- 佣金钱包 CHECK `chk_agent_wallet_available_balance` 的 commission 分支为 `frozen_balance <= GREATEST(balance, 0)`(迁移 000209。扣减前若仍有未释放冻结`GREATEST` 随余额下降而塌陷,先扣款即违约。
- 提现尝试表 `tb_commission_withdrawal_request_attempt` 无 status 列;未结算的唯一事实是 `released_at IS NULL`,释放金额事实是 `attempt.amount`
- 退款状态:`1 待审批 / 2 已通过 / 3 已拒绝 / 4 已退回 / 5 原路处理中 / 6 渠道明确失败`。仅 `2` 是稳定成功终态;`3/4/6` 可重提,重提复用同一退款行且可修改退款金额与冻结实收金额。
- 企业微信通过后撤销**不改变** `status`,只置 `anomaly_flag = 1``failure_reason = 'revoked_after_approved'`,因此「已通过但存在审批异常」与「正常的已通过」在状态上不可区分。
- 原路渠道明确成功路径不产生任何 Outbox 事件,只回写退款行与订单。
## Goals / Non-Goals
**Goals:**
- 用 PRD 2.14 语义整体替换全额失效实现,原佣金记录保持不变。
- 退款回溯准入由退款申请状态与方式唯一决定,不依赖失败分类标记或新增事件。
- 金额可按分精确复现:比例、向下取整、末条起向前补差、累计上限。
- 幂等有数据库权威兜底,`commission_deducted` 明确降级为投影。
- 回溯在提现冻结约束下可安全落库,允许佣金余额为负。
- 回溯事实可在佣金明细列表、详情与导出中按既有数据范围核对。
**Non-Goals:**
- 不修改既有迁移;新增 Schema 变化一律使用新的成对迁移。
- 不重构 `internal/service/refund` 的模块结构,也不改动人工处置链路(`ResolveCommissionRecord` 的 99 → 4 失效)与提现资格、审批语义。
- 不新增退款完成类独立事件,不新增运行时开关。
- 不修改 `GetStats` / `GetDailyStats` 与首页统计口径:回溯不计入净佣金,本期只保证回溯明细在列表、详情与导出中可见。
- 不回填存量:已按旧语义处理过(`commission_deducted = true`)的退款单不重新回溯,其原佣金保持既有的已失效状态,不补建回溯明细。
- 换货回溯、统一时间筛选、员工代收款账单、代理自充与商户池不在范围。
## Decisions
- 回溯表以退款+原佣金唯一,保存负数快照;退款消费者等待佣金终态再可靠重试。
- 在钱包事务内先处理待审提现释放,再插入回溯和扣款流水;唯一约束保证重放安全。
### 1. 回溯准入由退款申请状态与方式决定
## 生成、资金与读侧契约
仅当 `refund.status = 2`(已通过)**且** `anomaly_flag = 0` **且**(非原路方式 **或** `channel_refund_status = 2`)时生成回溯。其余状态返回可重试错误,不写明细、不置完成事实。
### 退款事件消费
- **`anomaly_flag = 0` 是必需条件,不是可选加固**`applyRevokedAfterApproved` 只置异常标记、不改状态,因此「企业微信通过后撤销」的退款单仍是 `status = 2`;原路场景下渠道后续成功仍会把 `status` 由 5 推到 2`applyResult` 的谓词只含 `status = 5`)。仅用 `status = 2` 会把这两种情况误判为可回溯,直接违反「撤销不得回溯」。
- **不含失败分类**`RefundFailureReasonIsDefinitive` 的取值面向「尝试是否终结」,与「退款是否真正完成」正交;成功退款的 `failure_reason` 为空,按分类判定会把成功退款判为不可回溯。该函数保留但 MUST NOT 进入回溯准入。
- **不依赖独立完成事件键**:原路与客户收款信息两种完成的区别已由 `status``channel_refund_status` 表达,`status = 2` 是唯一稳定终态。
- 备选(否决):在渠道明确失败时回溯。被否决,因为 `3/4/6` 可重提并修改金额,按 `(refund_id, original_commission_id)` 幂等会使第二次成功退款无法补回溯,金额必然错误。
- 渠道明确失败后重提并最终成功:`status``5/6` 重新走到 `2`,此时才生成一次回溯,金额取最终成功金额。
- 退款完成可靠事件以 `refund_id``order_id` 进入回溯用例;锁定退款、订单和佣金终态。原订单佣金未终态时不写“无需回溯”,仅保留可重试事件;终态无佣金时写退款已处理且无需回溯的审计;换货事件不进入本用例。
- 查询原订单全部可回溯佣金,按稳定顺序计算。全额退款回溯每条剩余可回溯金额;部分退款以 `refund_amount / frozen_actual_paid_amount` 计算,每条向下取整,最后一条仅补足总额舍入差且不得超过该条剩余可回溯余额。冻结实收金额缺失或非正时记录可恢复失败,不以订单标价替代。
- 新表以 `(refund_id, original_commission_id)` 唯一,保存负数金额、原佣金/订单/退款快照、不可提现标识、生成时间;唯一冲突视为已生成,禁止第二次扣款。
### 2. 消费门禁与周期性补偿
### 钱包与提现原子边界
- 消费端在准入不满足时返回可重试错误,交由既有 Outbox 重试;订单佣金未终态时同样返回可重试错误,不写明细、不置位。
- 既有补偿扫描 `commissionDelivery.Recover` 保留启动时执行,并新增周期性任务:`@every 1m``asynq.MaxRetry(3)``asynq.Timeout(10m)``asynq.Unique(10m)`、独立队列,形态与 `TaskTypeRefundChannelRecovery` 一致;新增任务类型常量与 `QueueForTaskType` 映射及其 Handler。
- 补偿扫描判据保持 `status = 2 AND commission_deducted = false`(不新增列条件),因为「撤销」与「无需回溯」都在第 3 条被收敛为已闭合并置位,扫描不会对它们空转。
- 备选(否决):让扫描排除 `anomaly_flag = 1`。被否决——那样异常的退款单永远没有闭合事实,扫描与投递在每轮都会重复处理同一批记录。
- 在同一钱包事务内,先锁定代理佣金钱包和所有待审核/审批中的提现申请;拒绝这些申请、释放其冻结余额并保存“退款回溯优先”原因,然后插入所有回溯明细和负数佣金钱包流水。允许钱包余额低于零。
- 原佣金记录、历史发放金额和已提现完成事实不更新、不删除;回溯是独立负数事实。事务任一步失败时不释放提现、不写部分回溯或部分流水,可靠事件保留重试。
### 3. 后处理闭合的三种结果
### 查询与导出
`commission_deducted` 的语义统一为「该退款单的佣金回溯后处理是否已闭合(含无需回溯与转人工)」,只在以下三种结果之一成立时置位,且置位使用 `WHERE commission_deducted = false` 条件保护:
- 扩展佣金明细列表/详情:原佣金返回 `clawback_records` 摘要,回溯明细返回 `original_commission_id`、退款单号、负数金额、不可提现、回溯后实际钱包余额和生成时间。关联查询先应用既有佣金数据范围,再按关联 ID 查询;越权不泄露存在性。
- 导出每条原佣金和回溯明细各一行,冻结筛选、操作者、可见范围和生成时间;金额保持分,展示层转换元不得改变负数或余额事实。
| 结果 | 条件 | 资金 | 审计 |
| --- | --- | --- | --- |
| 已生成回溯 | 准入满足且存在可回溯佣金 | 插入明细、扣钱包、写负数流水 | 新动作码 `refund.clawback_commission`,资源含回溯明细 |
| 无需回溯 | 准入满足、原订单佣金已终态且确认无佣金 | 无 | 新动作码 `refund.clawback_not_required` |
| 转人工不回溯 | 准入不满足且 `anomaly_flag = 1` | 无 | 复用既有 `refund.anomaly_flagged` 语义 |
- 准入不满足且**非**异常(待审批、原路处理中、渠道明确失败)时返回可重试错误,不置位、不写审计,等待退款推进。
- 撤销发生在回溯已生成之后时,不自动冲销已落库的回溯事实(资金已实际发生),按异常转人工处理;回收走人工更正,不新增自动反向流水。
- **「原订单佣金已终态」的判据(两个条件同时成立)**
1. 订单佣金流程已离开「待计算」(`order.commission_status != 1`
2. 该订单不存在任何未终态佣金记录(`tb_commission_record.status IN (1 已冻结, 2 解冻中, 99 待人工修正)`)。
- 仅用条件 1 是**错误**判据:`order.commission_status = 3`(待人工处理)只表示「存在 99 记录」,而人工处置(`shop_commission.ResolveCommissionRecord`)把 99 改成已发放/已失效后**不回写订单状态**,订单会长期停留在 3。只看订单状态会把「待人工修正」误判为终态进而在「无已发放佣金」时提前闭合为「无需回溯」并置位此后投影早退 + 补偿扫描只扫 `commission_deducted = false`,该订单后续入账的佣金将**永久不再回溯**,违反本 Change 的 Spec「MUST NOT 提前判定为无需回溯」。
- 收敛条件(保证不会永久等待,依据全仓 `tb_commission_record.status` 写点实测):该表的状态写点只有两类——
- **创建**`internal/service/commission_calculation/service.go:181/:241/:554` 写入已发放 3`:220/:581` 写入待人工修正 99`internal/service/recharge_order/service.go:418` 写入已发放 3
- **更新**`internal/service/commission_calculation/service.go:689-693`(入账置 3`internal/service/shop_commission/service.go:751/:781`(人工处置置 4 或置 3
即当前代码**从不写入 1已冻结或 2解冻中**,这两个值是仅供历史数据读取与名称映射的遗留枚举;把它们纳入未终态集合是防御性判断(历史遗留的 1/2 行确实不是已结算事实),而非新的等待来源。因此现实中的等待只可能由 99 引起,且 99 一旦被人工处置即收敛:处置为已发放(3) → 生成回溯;处置为已失效(4) → 确认无佣金可回溯并闭合。
- 该判据不满足时返回可重试错误(`CodeServiceUnavailable`),不写明细、不置位。
### 4. 金额计算:分母、分子、分摊与舍入
全程 `int64` 分,乘法使用 `math/big` 或等价的 128 位中间量,禁止浮点与溢出。
- 分母 `D` = 本次退款的冻结实收金额(退款行,缺失时回落该次审批尝试记录)。`D <= 0` 时记录可恢复失败不落库、不改余额、不置位MUST NOT 用订单标价或申请金额替代。
- 分子 `N` = 原路成功时取渠道明确成功金额,其余方式取审批退款金额。
- 稳定顺序固定为 `original_commission_id ASC`;该顺序决定补差落点,必须固定,否则同一输入可能得出不同分摊结果。
- 逐条基准 `base_i = min( floor(amount_i × N / D), 剩余_i )``剩余_i = amount_i 该原佣金已生成的回溯累计`
- 应回溯总额 `T = min( floor(Σamount_i × N / D), Σ剩余_i )`
- 补差 `T Σbase_i` 自稳定顺序**末条起向前**分摊,每条不超过该条 `剩余_i`;若最末条不足以吸收全部余差则继续向前一条分摊,直至余差用尽。
- 全额退款(`N = D`)时 `base_i = min(amount_i, 剩余_i)`,自然退化为按剩余可回溯余额回溯。
- 备选(否决):按比例算出总额后平均分配。被否决——会产生与各原佣金金额不成比例的扣减,且无法在分制下复现。
### 5. 新表 `tb_commission_clawback_record`
不复用 `tb_commission_record`:负数语义与本表的可提现判定、结算与统计查询完全隔离,避免污染既有 `status = 3 AND amount > 0` 一类终态判定。
字段:`refund_id``original_commission_id``order_id``shop_id``amount`(负数)、`withdrawable`(固定不可提现)、`status`= 5`balance_after`(回溯后佣金钱包实际余额,可负)、原佣金与退款关键快照(佣金来源、订单号、退款单号)、`created_at`
- 唯一约束 `(refund_id, original_commission_id)` 是唯一权威幂等键。
- 索引:店铺 + 时间(列表与数据范围)、`original_commission_id`(反向关联)、`order_id`(订单维度核对)。
- 备选(否决):复用 `tb_commission_record` 并加 `original_commission_id` / `refund_id` 列。被否决——列表、统计、终态判定与导出全部要重新审计负数的语义,且「已发放」「回溯」会被读侧到处区分,回归面远大于两表合并。
- 代价:读侧必须两表 `UNION ALL` 合并分页(见第 8 条)。
### 6. 幂等三层与投影
1. **第 1 层权威、DB 兜底)**:回溯明细唯一约束 `(refund_id, original_commission_id)`。唯一冲突即视为已生成,不重复扣款。
2. **第 2 层(事务内互斥)**:佣金行 `FOR UPDATE` + 佣金钱包 `version` 乐观锁(沿用既有)。
3. **第 3 层(投影、非幂等键)**`commission_deducted` 仅供 Outbox 消费结果判据与补偿扫描使用MUST NOT 参与逐条幂等判定;置位加 `WHERE commission_deducted = false` 保护(现有实现缺此保护)。
### 7. 加锁顺序、释放顺序与原子边界
同一事务内顺序固定:
```
锁 提现申请行 (shop_id, status = 待审核, ORDER BY id ASC) FOR UPDATE
锁 尝试行 FOR UPDATE, released_at IS NULL
→ 幂等置 released_at释放额 = Σ attempt.amount无尝试记录的存量申请取申请金额
锁 佣金钱包行 FOR UPDATE
→ 解冻 frozen_balance先于扣款约束要求
→ 置申请为已拒绝 + processed_at + 拒绝原因
→ 释放额 > 0 时写正向流水与拒绝审计
插入 回溯明细(唯一约束兜底)
扣减 balance允许为负
写 commission_deduct 负数流水
写回溯审计
```
- 加锁顺序全库统一为**申请行 → 尝试行 → 钱包行**,钱包永远最后加锁,避免与提现终态消费者形成死锁环。
- 保留既有 `collectPendingWithdrawalRejects` / `applyPendingWithdrawalRejects` / `appendWithdrawalRejectAudit`,不重写。
- 任一步失败整体回滚:不释放提现、不写部分明细或部分流水,可靠事件保留重试。事务内 MUST NOT 持有不可回滚的外部 I/OENG-TX-001
### 8. 读侧最小面
- 列表支持 `status` 筛选透传(现有 DTO 与 service 均未透传store 已支持)。
- 两表 `UNION ALL` 合并分页,筛选与数据范围在合并前各自应用。
- 排序与次序键:`created_at DESC``id DESC``source ASC`。两表自增 ID 空间相互独立,同一 `created_at` 下不同表的 `id` 只保证各自表内有序,因此 `(created_at DESC, id DESC)` **不是全序**;必须再以 `source ASC` 作为末位次序键,才能在 `created_at``id` 完全相同的跨表记录上保证翻页不重不漏。
- 原佣金行返回 `clawback_records` 摘要;回溯行返回原始佣金标识、退款单号、负数金额、不可提现标识、回溯后余额、生成时间。
- 新增佣金明细详情接口,并同步可执行路由、`cmd/api/docs.go``cmd/gendocs/main.go` 装配DTO 中文 description 与枚举名与 `pkg/constants` 一致ENG-DTO-001
- 越权一律按既有佣金数据范围返回不存在或空结果,不泄露存在性。
### 9. 导出归属与粒度
- 本 Change 落地佣金明细导出场景:场景白名单、`internal/exporter` 注册、DTO `oneof`、DataSource 与列定义。
- 粒度为佣金记录:原佣金与回溯记录各占一行;入账后金额与回溯后余额取对应钱包变动提交后的实际余额,可为负;金额保持分,展示层转元不得改变负数或余额事实。
- 边界:佣金明细导出的粒度与列定义由本 Change 确定;`add-export-time-filter-standards` 只负责统一 `start_time`/`end_time` 与快照冻结。
### 10. 常量与审计
- 新增 `CommissionStatusClawback = 5``GetCommissionRecordStatusName` 增加「回溯」映射,并同步状态常量注释;`tb_commission_record.status` 无 CHECK 白名单,新增枚举值不需要改列约束。
- 新增回溯审计动作码 `refund.clawback_commission``refund.clawback_not_required`,主资源为退款单,并在 `internal/infrastructure/audit/registry.go` 注册。
-`refund.invalidate_commission` 保留常量与注册(历史审计仍可读),代码中不再有新调用点。
## Risks / Trade-offs
- [两表合并分页复杂,易漏筛选或重复计数] → 合并前各自应用数据范围与筛选,统一排序键并显式以 `source ASC` 打破跨表 `(created_at, id)` 并列;在隔离库对翻页边界与总数做核对。
- [误用 `status = 2` 单条件会导致撤销退款被回溯] → 准入显式包含 `anomaly_flag = 0`,并以「撤销后不回溯」「原路在途不回溯」两个场景固定。
- [周期性补偿放大重复处理] → 幂等第 1 层由唯一约束兜底,第 3 层置位加条件保护;补偿只重投事件,不直接改资金。
- [`math/big` 中间量引入新代码路径] → 仅用于比例乘法,除法与求和保持 `int64`;用溢出边界用例固定。
- [存量已按旧语义处理的退款单与新语义并存] → 明确不回填;`commission_deducted = true` 的存量不进入补偿扫描,原佣金保持已失效是历史事实。
- [新导出场景未覆盖 AUG26-014 的时间筛选] → 列定义与粒度归本 Change时间参数与快照冻结在 AUG26-014 落地;两者叠加不互相改变语义。
## Migration Plan
新增成对迁移;隔离库验证全额/部分、舍入、佣金延迟、重复事件、提现释放、负余额及 up/down/up。
新增成对迁移建表、唯一约束与索引;编号按 `migrations/` 目录当前最大编号顺延,不预占、不修改既有迁移。回滚按 `down` 删除本 Change 新增的表与约束,不影响既有表与数据。
隔离库验证ENG-TEST-001在维护者指定的 `junhong_cmp_test` PostgreSQL 与 Redis DB 6 上,以本地显式 `DB_*` 参数执行 `./scripts/migrate.sh` 完成 up/down/up仅创建与删除本 Change 自己的 fixture不重置整库。验证覆盖全额与部分回溯、比例与补差、溢出行边界、累计不超过剩余、冻结实收非正可恢复失败、佣金未终态等待、终态确无佣金、原路在途不回溯、撤销不回溯、渠道失败重提后按最终金额回溯、重复消费、释放先于扣款与负余额不违约、原佣金不变、读侧合并分页与越权、导出负数余额。

View File

@@ -4,13 +4,17 @@
## Why
套餐退款佣金需以独立负数事实回溯,并与提现冻结和钱包余额一致
现有退款佣金回扣把原佣金记录整单置为已失效,与 PRD 2.14「原佣金不变 + 独立负数回溯明细 + 可按分核对与导出」不一致;该路径在退款真正完成前即可触发,金额与幂等都不可核对
## What Changes
- 新增不可提现负数回溯明细及原佣金/退款关联
- 按冻结实收比例、舍入和幂等规则扣回佣金
- 回溯前释放待审提现,允许佣金钱包负余额
- **BREAKING**(行为替换,非新增):用 PRD 2.14 语义替换现有全额失效实现。原佣金记录保持已发放,不改为已失效、不改金额与发放时间;回溯事实落在新表 `tb_commission_clawback_record` 的负数、不可提现明细上。不保留旧的全额失效路径,不新增运行时开关
- 回溯只在退款申请已通过(`status = 2`)时生成;原路方式还须渠道明确成功(`channel_refund_status = 2`)。待审批、原路处理中、渠道明确失败(可重提)、企业微信通过后撤销均不生成;渠道失败重提后按最终成功金额回溯一次
- 消费端未满足触发条件时返回可重试错误,不写明细、不置完成标记;既有补偿扫描注册为周期性任务,并保留启动时执行
- 金额全程按分整数计算:分母取本次退款的冻结实收金额,分子按方式取渠道成功金额或审批退款金额;每条向下取整,自稳定顺序 (`original_commission_id` 升序) 末条起向前补差,累计不超过各原佣金剩余可回溯余额;乘法使用大整数中间量,禁止溢出与浮点。
- 新增佣金状态 `5 = 回溯` 与名称映射,新增回溯审计动作码并在 `registry.go` 注册;旧 `refund.invalidate_commission` 仅保留常量与注册,使历史审计仍可读,代码中不再有新调用点。
- 在退款佣金回扣用例中落地佣金明细导出场景,粒度为佣金记录:原佣金与回溯记录各一行;扩展佣金明细列表 `status` 筛选与详情接口。
- 生成回溯前,在同一事务内先拒绝并释放待审核提现的冻结余额,再扣减佣金钱包;佣金钱包允许余额为负。
## Capabilities
@@ -18,8 +22,16 @@
- 无。
### Modified Capabilities
- `agent-funds-commission`: 退款佣金回溯。
- `agent-funds-commission`: 退款佣金回溯明细、原佣金保持已发放、佣金钱包负余额、回溯读侧与导出
- `order-refund-exchange`: 回溯准入改为按退款申请状态与方式判定,取消「明确失败才可回溯」语义与独立完成事件键要求。
## Impact
影响退款事件、佣金明细、钱包提现 Schema。
影响退款终态判定与回溯准入、退款后处理补偿任务、佣金记录状态常量、佣金明细读侧与导出场景注册、佣金钱包提现冻结释放、统一审计动作注册、OpenAPI 与数据库 Schema。
## Non-Goals
- 换货回溯不在本期范围PRD §3 列为后续独立需求)。
- 统一 `start_time`/`end_time` 解析、区间校验与筛选、权限快照冻结由 `add-export-time-filter-standards` 负责;本 Change 只负责佣金导出的粒度与列定义,两者边界不得互相改变。
- 本期不修改 `GetStats` / `GetDailyStats` 与首页统计口径,回溯不计入净佣金;如需净佣金口径另立需求。
- 提现资格与审批语义、员工代收款账单、代理自充与商户池不在本期范围。

View File

@@ -1,23 +1,130 @@
## MODIFIED Requirements
### Requirement: 佣金异常状态可见
系统 SHALL 将佣金记录保持为已冻结、解冻中、已发放、已失效、回溯或待人工修正;链路断裂的记录进入待人工修正而不是静默计入可提现余额。回溯记录 MUST 为负数且不可提现MUST NOT 计入可提现余额或提高可提现额度。
#### Scenario: 佣金链路断裂
- **GIVEN** 佣金记录无法关联完成后续发放所需事实
- **WHEN** 系统处理该记录
- **THEN** 记录保持待人工修正状态且不增加可提现余额
#### Scenario: 回溯记录不计入可提现余额
- **GIVEN** 代理店铺存在已发放佣金及其回溯记录
- **WHEN** 查询佣金明细并按可提现余额判定提现资格
- **THEN** 回溯记录以「回溯」状态与负数金额可见,且不增加该店铺的可提现余额
### Requirement: 退款佣金回扣可靠完成
系统 SHALL 在退款申请已通过时持久化佣金回溯请求;回溯请求的投递或处理异常不得静默遗留,且退款单在全部应有回溯明细生成并完成对应钱包流水前不得标记为已回溯。原佣金记录 MUST NOT 因回溯改变状态、金额、佣金来源或发放时间。
#### Scenario: 已退款订单佣金回扣失败后恢复
- **WHEN** 已退款订单的佣金回溯首次处理失败或进程中断
- **THEN** 退款单保持回溯未完成状态并保留可重试事实,后续成功处理后原佣金仍为已发放、回溯明细与佣金钱包负数流水均已生成且退款单标记为已完成
#### Scenario: 订单佣金未终态
- **WHEN** 退款申请已通过但原订单佣金仍未进入终态
- **THEN** 系统不写回溯明细、不标记回溯完成,并保留可重试事实等待终态
#### Scenario: 终态确无佣金
- **WHEN** 原订单佣金已进入终态且确认无佣金
- **THEN** 系统标记该退款单无需回溯并写入「无需回溯」审计,且不产生任何钱包变动
#### Scenario: 审批异常转人工不回溯
- **WHEN** 退款申请存在审批异常标记(企业微信通过后撤销)
- **THEN** 系统不生成回溯明细与钱包变动,标记该退款单的回溯后处理已闭合并写入转人工审计
### Requirement: 退款后处理可补偿
系统 SHALL 对已通过但回溯未完成或资产未完成后处理的退款单提供幂等补偿;重复补偿不得重复生成回溯明细、重复扣减佣金钱包、重复写负数流水或重复处理资产。
#### Scenario: 遗留退款单补偿
- **WHEN** 补偿流程发现已通过且回溯完成事实缺失的退款单
- **THEN** 系统恢复该退款单的唯一后处理请求,并在全部应有回溯明细生成后更新其完成事实
#### Scenario: 周期性补偿
- **WHEN** 补偿扫描按既有周期任务形态执行且存在回溯后处理未闭合的退款单
- **THEN** 系统按固定周期重复补偿直至完成事实落库或该退款单转人工,且不重复产生任何资金事实
## ADDED Requirements
### Requirement: 套餐退款佣金回溯
系统 SHALL 在套餐退款后保留原佣金不变,并创建关联原佣金记录和退款单的负数、不可提现回溯明细,冻结原订单号及原佣金关键字段。同一退款业务必须幂等;若佣金计算未终态则等待终态后生成,确认无佣金才标记无需回溯。换货不在本期范围。
部分退款按本次退款金额与订单冻结实收金额比例,对每条原佣金按分向下取整;最后一条补足舍入差,累计回溯不得超过原佣金。生成前系统 MUST 拒绝并释放待审核提现,再生成回溯明细和钱包扣款流水;佣金钱包允许负余额
系统 SHALL 在退款申请已通过后生成关联原佣金记录与退款单的负数、不可提现回溯明细,并保留原佣金记录不变。回溯明细 MUST 冻结原订单号、原佣金标识、负数金额、不可提现标识、回溯后佣金钱包实际余额与生成时间。换货不在本期范围
回溯准入 MUST 由退款申请状态与方式得出:仅退款申请已通过时可生成,原路退款还须渠道明确成功。待审批、原路处理中、渠道明确失败与企业微信通过后撤销 MUST NOT 生成回溯明细;渠道明确失败可修改材料后重提,重提后按最终成功金额生成一次。同一退款业务 MUST 幂等幂等依据为「一次退款对应一次原佣金」的唯一事实MUST NOT 依赖退款单上的佣金回扣标记。
原订单佣金未进入终态时系统 MUST 等待终态后再生成MUST NOT 提前判定为无需回溯;确认无佣金时才标记无需回溯。
部分退款按本次成功退款金额与本次退款冻结实收金额的比例对每条原佣金等比例回溯。金额 MUST 以分整数精确计算MUST NOT 溢出或引入浮点误差;每条按分向下取整,舍入差自稳定顺序(原佣金标识升序)末条起向前补足,每条不超过该条剩余可回溯余额;累计回溯 MUST NOT 超过各原佣金的可回溯余额。冻结实收金额缺失或非正时系统 MUST 记录可恢复失败且不落库、不改变余额MUST NOT 以订单标价或申请金额替代。
生成回溯前系统 MUST 先拒绝并释放待审核提现的冻结余额再生成回溯明细与钱包扣款流水佣金钱包余额允许为负MUST NOT 因余额不足而跳过或拒绝回溯。
#### Scenario: 部分退款舍入
- **WHEN** 一笔部分退款关联多条原佣金且比例计算产生分级舍入差
- **THEN** 系统按各条向下取整并仅在最后一条补差,回溯总额等于应回溯额且不超过各原佣金可回溯余额
- **THEN** 系统按各条向下取整并自末条起向前补差,回溯总额等于应回溯额且不超过各原佣金可回溯余额
#### Scenario: 全额回溯
- **WHEN** 退款金额与冻结实收金额相等
- **THEN** 系统按各原佣金的剩余可回溯金额回溯,回溯总额等于各原佣金金额之和
#### Scenario: 冻结实收金额非正
- **WHEN** 本次退款冻结实收金额缺失或非正
- **THEN** 系统记录可恢复失败,不写回溯明细、不改变佣金钱包余额,且不以订单标价替代计算
#### Scenario: 原路渠道失败重提后按最终金额回溯
- **GIVEN** 一笔原路退款曾在渠道明确失败并可重提
- **WHEN** 该退款重提后最终渠道明确成功
- **THEN** 系统仅在该退款申请已通过时按其最终成功金额生成一次回溯明细,失败阶段不产生任何回溯事实
#### Scenario: 重复退款消费
- **WHEN** 同一退款完成事件被重复消费
- **THEN** 系统不重复生成回溯明细、钱包扣款或提现释放事实
#### Scenario: 回溯后佣金钱包负余额
- **GIVEN** 店铺佣金钱包余额不足以覆盖本次回溯金额
- **WHEN** 系统生成回溯明细
- **THEN** 钱包余额允许为负并记录回溯后实际余额,且不因余额不足而跳过或拒绝回溯
#### Scenario: 原佣金保持不变
- **WHEN** 一笔已发放佣金被回溯
- **THEN** 该原佣金记录的状态、金额、佣金来源与发放时间均不变,可提现余额按回溯金额减少
### Requirement: 回溯明细关联查询与导出
系统 SHALL 在佣金明细中分别展示原发放佣金和回溯扣款记录,并允许从任一记录查询其关联的退款单、原佣金或全部回溯明细。回溯记录必须显示负数金额、不可提现标识、来源退款单号、原佣金记录号、生成时间和回溯后佣金钱包实际余额。佣金明细及导出 MUST 使用既有佣金数据范围:代理仅可读取自身及其既有可见范围内的事实,平台/超级管理员遵循既有范围;无权记录不得通过关联 ID、汇总或导出泄露。
导出应冻结筛选条件、操作者和可见范围;原佣金与回溯记录均作为独立行导出,回溯后余额为对应钱包变动提交后的实际余额,可为负数
系统 SHALL 在佣金明细中分别展示原发放佣金和回溯扣款记录,并允许从任一记录查询其关联的退款单、原佣金或全部回溯明细。回溯记录必须显示负数金额、不可提现标识、来源退款单号、原佣金记录号、生成时间和回溯后佣金钱包实际余额。原佣金与回溯记录 MUST 合并为同一列表的同一分页与同一排序口径MUST NOT 因来源表不同而丢失或重复任一条事实
佣金明细及导出 MUST 使用既有佣金数据范围:代理仅可读取自身及其既有可见范围内的事实,平台与超级管理员遵循既有范围;无权记录不得通过关联 ID、汇总或导出泄露存在性。
佣金明细导出 MUST 使用佣金记录粒度,原佣金与回溯记录各占一行,并冻结创建时筛选条件、操作者与可见范围。回溯后余额为对应钱包变动提交后的实际余额,可为负数;金额保持分,展示层转元 MUST NOT 改变负数或余额事实。
#### Scenario: 代理查询越权回溯记录
- **WHEN** 代理使用回溯记录 ID、原佣金 ID 或退款单号查询其数据范围外的回溯关系
- **THEN** 系统按既有数据范围返回不存在或空结果,不泄露关联事实
#### Scenario: 重复退款消费
- **WHEN** 同一退款完成事件被重复消费
- **THEN** 系统不重复生成回溯明细、钱包扣款或提现释放事实
#### Scenario: 原佣金与回溯合并分页
- **GIVEN** 同一店铺同时存在原佣金记录与回溯记录
- **WHEN** 查询佣金明细列表并翻页
- **THEN** 两类记录按同一排序口径出现在同一结果集内,任一条不缺失也不重复
#### Scenario: 回溯记录导出
- **WHEN** 导出含回溯记录的佣金明细
- **THEN** 原佣金与回溯记录各占一行,回溯行金额为负数、可提现标识为不可提现、含关联佣金明细与退款单标识,且回溯后余额为负数时原样导出

View File

@@ -0,0 +1,27 @@
## MODIFIED Requirements
### Requirement: 退款终态事实与失败分类
退款申请 SHALL 保存结构化失败分类、渠道退款状态、渠道退款流水与渠道退款请求号,并在列表、详情和导出中返回冻结实收金额、方式、申请状态、渠道退款状态、失败安全摘要、审批尝试历史和渠道流水,按既有订单数据范围过滤。失败分类 MUST 为稳定枚举,至少覆盖:渠道明确拒绝、渠道凭证失效、渠道余额不足、超时或结果未知、企业微信驳回或关闭、企业微信通过后撤销,以及本地原支付事实不可用。渠道凭证失效与本地原支付事实不可用 MUST 为两个并列分类、语义不得合并:前者指该商户退款必需凭证缺失或失效,后者指本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过。每个分类 MUST 显式标记其是否属于「明确失败」:明确失败表示该次退款尝试已终结且不可自动恢复,非明确失败表示仍在途、可自动恢复或需人工处理。该标记 MUST 仅用于判定尝试终结性与人工处置MUST NOT 作为佣金回溯的准入条件。审计 SHALL 记录申请、重提、审批终态、权益处理、渠道调用与恢复,且不得记录凭证内容、完整收款文本或商户密钥。
为后续佣金回溯能力提供稳定事实,退款终态 SHALL 可按退款单与订单定位,并提供:成功退款金额、冻结实收金额与终态时点。佣金回溯准入 MUST 由退款申请状态、审批异常标记与退款方式得出MUST NOT 依据失败分类标记、退款原因文本或新增的独立完成事件键:仅退款申请已通过且不存在审批异常标记时可进入回溯判定,原路退款还须渠道明确成功;待审批、原路处理中、渠道明确失败(可修改材料后重提)与企业微信通过后撤销均不得回溯。系统 MUST 保留既有退款佣金回扣事件键的兼容语义;佣金回溯的幂等键为一次退款一次回溯,不依赖退款单上的佣金回扣标记。
#### Scenario: 渠道失败分类可查询
- **WHEN** 原路退款因渠道余额不足失败
- **THEN** 退款详情返回该失败分类与安全摘要,且不返回任何凭证内容或完整收款文本
#### Scenario: 审计不含敏感内容
- **WHEN** 渠道退款调用或恢复完成后写入审计
- **THEN** 审计只记录业务标识、金额、状态与脱敏摘要,不记录商户密钥或凭证原文
#### Scenario: 回溯准入仅取决于退款申请状态
- **WHEN** 退款申请处于待审批、原路处理中、渠道明确失败或企业微信通过后撤销
- **THEN** 系统不生成任何佣金回溯事实;仅当退款申请已通过(原路退款还须渠道明确成功)时才生成
#### Scenario: 失败分类不决定回溯准入
- **WHEN** 一次退款尝试带有明确失败分类,但该退款申请尚未处于已通过状态
- **THEN** 系统不生成佣金回溯事实,该分类只用于判定尝试终结性与人工处置

View File

@@ -1,8 +1,35 @@
## 1. 回溯实现
- [ ] 1.1 追踪退款完成、佣金计算终态、提现冻结和佣金钱包链路。
- [ ] 1.2 新增回溯明细、退款/原佣金唯一约束、状态/索引的成对迁移和 DTO。
- [ ] 1.3 实现比例分摊、最后一条舍入补差、终态等待、待审提现释放、负余额扣款及幂等消费者。
## 1. 事实与常量
## 2. 验证
- [ ] 2.1 隔离库验证全额/部分、重复消费、佣金延迟、负余额和 up/down/up
- [ ] 2.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-commission-clawback-records --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。
- [x] 1.1 新增 `CommissionStatusClawback = 5``GetCommissionRecordStatusName` 的「回溯」映射,并同步佣金记录状态常量注释与 `internal/model/commission.go` 的说明。 证据:`pkg/constants/iot.go:214` 新增 `CommissionStatusClawback = 5``:345` 注释同步为 `…4=已失效, 5=回溯, 99=待人工修正``:356-357` 新增 `case CommissionStatusClawback: return "回溯"``internal/model/commission.go:23` 列注释同步含 `5-回溯``:12` 补说明「退款佣金回溯不改动本表」;迁移 000220 同步 `COMMENT ON COLUMN tb_commission_record.status`
- [x] 1.2 新增回溯审计动作码 `refund.clawback_commission`(生成回溯明细)与 `refund.clawback_not_required`(终态确无佣金),在 `internal/infrastructure/audit/registry.go` 注册;保留 `refund.invalidate_commission` 常量与注册以维持历史审计可读,并删除其全部业务调用点。 证据:`pkg/constants/audit.go:363-366` 新增 `AuditActionRefundClawbackCommission = "refund.clawback_commission"``AuditActionRefundClawbackNotRequired = "refund.clawback_not_required"``:360-362` 旧常量补注释「保留常量与注册仅供历史审计读取,代码中已无调用点」;`internal/infrastructure/audit/registry.go:313-314``refundSystemAction` 定义、`:644-645` 登记、`:311`/`:643` 保留旧动作注册。旧调用点 `appendCommissionAudit``recordCommissionFailure` 已整体删除(`internal/service/refund/audit.go` 现无该常量引用),全仓 grep 仅剩常量定义与注册两处
- [x] 1.3 核对回溯用例 Audit Event、Domain Ledger、Integration Log 与 Outbox 四类事实的使用决定或 N/A 理由并在仓库外记录证据ENG-AUDIT-001。 证据:四类事实决定记录于仓库外 `/tmp/aug26012-evidence/07-audit-facts.md`Audit Event 使用、Domain Ledger 使用、Integration Log N/A本用例不调用外部系统理由已写明、Outbox 复用既有事件。落地实现见 `internal/service/refund/clawback.go``applyClawback`/`settleClawback`/`appendClawbackAudit`(审计与资金同事务、`AppendAndGet` 要求成功必达)。
## 2. Schema 与模型
- [x] 2.1 新增成对迁移建 `tb_commission_clawback_record``refund_id``original_commission_id``order_id``shop_id`、负数 `amount`、不可提现标识、`status``balance_after`、原佣金与退款关键快照、`created_at`;迁移编号按 `migrations/` 目录当前最大编号顺延,不预占编号,不修改任何既有迁移。 证据:新增成对迁移 `migrations/000220_add_commission_clawback_record.up.sql`/`.down.sql`;编号按 `migrations/` 当前最大 000219 顺延,未修改任何既有迁移(`git status` 中 migrations 仅新增这两个文件)。建表含 `refund_id``original_commission_id``order_id``shop_id``order_no``refund_no``commission_source`、负数 `amount``balance_after``withdrawable``status``created_at`,及 `chk_commission_clawback_amount CHECK (amount < 0)``chk_commission_clawback_withdrawable CHECK (withdrawable = FALSE)``chk_commission_clawback_status CHECK (status = 5)``created_at` 采用 `TIMESTAMP`(不带时区),与另一支 `tb_commission_record.created_at`(实测 `timestamp without time zone`)保持一致:若用 `timestamptz``UNION ALL` 会按会话时区改写其中一支破坏统一排序键与时间筛选口径S-4 同时把两个回溯分支的占位列由 `NULL::timestamptz` 改为 `NULL::timestamp`,消除该耦合(实测 `【S-4】released_at 跨会话时区稳定Shanghai/NewYork/UTC 三会话同值`)。
- [x] 2.2 迁移内建唯一约束 `(refund_id, original_commission_id)`,以及店铺 + 时间、`original_commission_id``order_id` 索引;`down` 仅删除本 Change 新增对象。 证据:迁移内建 `CONSTRAINT uk_commission_clawback_refund_original UNIQUE (refund_id, original_commission_id)` 与三个索引 `idx_commission_clawback_shop_created (shop_id, created_at DESC, id DESC)``idx_commission_clawback_original (original_commission_id)``idx_commission_clawback_order (order_id)``down` 在存在回溯明细时 `RAISE EXCEPTION` 阻断后 `DROP TABLE`,并还原 `tb_commission_record.status` 列注释,不触碰既有表与数据。 D-2 修复:`down` 还原的 `tb_commission_record.status` 列注释改为 000220 up 之前的**真实值** `状态 1-已入账 2-已失效`(最后一次写该注释的是 `migrations/archive/000029_add_one_time_commission.up.sql:94`000113 只迁移状态值未改注释),使 down 成为 up 的严格逆操作ENG-MIG-001。实测down 后 dbhub 只读核对该注释为 `状态 1-已入账 2-已失效`up 后为 `状态 1-已冻结 2-解冻中 3-已发放 4-已失效 5-回溯 99-待人工修正`(见 `01-migration.txt`)。
- [x] 2.3 新增回溯明细模型与 DTO含中文 description 与状态枚举名ENG-DTO-001。 证据:新增 `internal/model/commission_clawback.go``CommissionClawbackRecord`(表名 `tb_commission_clawback_record`DTO 见 `internal/model/dto/shop_commission_dto.go``ShopCommissionClawbackItem``ShopCommissionRecordItem`status 描述为 `1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 5:回溯, 99:待人工修正`,与 `pkg/constants` 一致)、`ShopCommissionRecordDetailReq/Resp`,均为中文 description。
## 3. 写路径替换
- [x] 3.1 实现回溯准入:仅 `refund.status = 2``anomaly_flag = 0` 且(非原路方式或 `channel_refund_status = 2`)时生成;其余状态返回可重试错误,不写明细、不置完成事实;不使用失败分类标记判定准入。 证据:`internal/service/refund/clawback.go:clawbackCommission` —— `refund.AnomalyFlag != 0` 先走转人工闭合;`refund.Status != model.RefundStatusApproved` 返回 `CodeServiceUnavailable``refund.Method == original_route && ChannelRefundStatus != 2` 返回可重试错误。未引用 `RefundFailureReasonIsDefinitive``grep -rn "RefundFailureReasonIsDefinitive" internal/service/refund/` 为空)。实测见 `04-write-path.txt`:「原路在途不回溯」「渠道失败不回溯」「待审批等待」通过。 准入与终态判据见 `internal/service/refund/clawback.go:67-97`;终态判据除「订单佣金非待计算」外,还要求该订单不存在 `status IN (1,2,99)` 的未终态佣金记录D-1 修复,理由与收敛条件见 design 第 3 条)。收敛性依据实测的全仓状态写点:`tb_commission_record.status` 仅被 `commission_calculation/service.go:181/:241/:554`(置 3`:220/:581`(置 99`:689-693`(入账置 3`recharge_order/service.go:418`(置 3`shop_commission/service.go:751/:781`(人工处置置 4 或 3写入**从不写入 1/2**(遗留枚举,仅历史读取与名称映射用),故现实等待只可能由 99 引起且处置后必然收敛。。
- [x] 3.2 实现金额计算:分母取本次退款冻结实收金额(缺失时回落审批尝试记录),`<= 0` 时记录可恢复失败且不落库、不改余额;分子原路取渠道明确成功金额、其余取审批退款金额;乘法使用 `math/big` 或等价大整数中间量,全程 `int64` 分,禁止浮点与溢出。 证据:`resolveClawbackDenominator`(退款行缺失时回落 `LatestAttemptID` 对应尝试的 `frozen_actual_received_amount`)、`resolveClawbackNumerator`(原路取 `ChannelRefundAmount`,其余取 `ApprovedRefundAmount``D <= 0` 记录可恢复失败并返回错误,不落库、不改余额、不置位;乘法用 `math/big` 中间量(`mulDivFloor`/`mulDivFloorInt64`),全程 int64 分。实测见 `04-write-path.txt`:「冻结实收非正 D=0」「分母回落+原路分子」「乘积极端值 6148914691236517204 / [6148914691236517204 6148914691236517205]」通过。
- [x] 3.3 实现分摊:稳定顺序 `original_commission_id ASC``剩余_i`、逐条 `base_i`、应回溯总额上限,逐条向下取整,舍入差自末条起向前分摊且每条不超过该条剩余。 证据:`allocateClawbackAmounts` —— 输入按 `original_commission_id ASC` 固定;`剩余_i = amount_i 已生成回溯累计``loadClawedAmounts` 从明细聚合);`base_i = min(floor(amount_i×N/D), 剩余_i)``T = min(floor(Σamount_i×N/D), Σ剩余_i)`;补差自末条向前逐条受 `剩余_i base_i` 限制。实测见 `04-write-path.txt`:「比例与补差 -66/-66/-68 总额-200」「补差跨条向前末两条剩余=0 被跳过 → 各 -1000 总额-2000」「Σ剩余封顶 总额-2100」通过。
- [x] 3.4 实现后处理闭合的三种结果与置位保护:已生成回溯、终态无需回溯、审批异常转人工;置位 `commission_deducted``WHERE commission_deducted = false` 保护;订单佣金未终态时不写明细、不置位并返回可重试错误。 证据:`clawbackCommission` 三种闭合——已生成回溯(`applyClawback`,置位 `WHERE id = ? AND commission_deducted = false` 并判 `RowsAffected != 1``CodeConflict`)、终态无需回溯(`settleClawback` + `refund.clawback_not_required`)、审批异常转人工(`settleClawback` 复用 `refund.anomaly_flag`);订单 `CommissionStatusPending` 时返回可重试错误、不写明细、不置位。旧实现两处无保护的 `Update("commission_deducted", true)` 已随 `deductAllCommission` 整体删除。实测见 `04-write-path.txt`:「未终态等待」「终态确无佣金」「撤销不回溯」「置位保护」通过。 另实测 D-1 修复后的闭合边界(`04-write-path.txt`「order=3 + 仅 99 记录」返回可重试错误err=订单存在未终态佣金记录,等待终态后再回溯)且无明细/未置位/无审计99 处置为已发放 5000 后生成 -5000 明细并闭合99 处置为已失效后写「无需回溯」审计并闭合不永久等待order=2 确无佣金保持原行为。
- [x] 3.5 删除旧全额失效写入(原佣金置已失效、旧审计前后态与调用点),替换为插入回溯明细;保留 `collectPendingWithdrawalRejects``applyPendingWithdrawalRejects``appendWithdrawalRejectAudit`,事务内顺序固定为:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 → 扣 `balance`(允许为负)→ 写负数流水 → 审计。 证据:`deductAllCommission`/`deductSingleCommission`/`appendCommissionAudit`/`recordCommissionFailure` 已整体删除(原佣金置已失效写入、旧审计前后态与全部调用点消失)。`applyClawback` 事务内顺序为:锁原佣金行 → 按店铺升序 `collectPendingWithdrawalRejects`(申请行 → 尝试行)→ 锁佣金钱包行 → `applyPendingWithdrawalRejects`(解冻 + 置驳回 + 正向流水 + 审计)→ 插回溯明细 → 扣 `balance`(允许为负)→ 写 `commission_deduct` 负数流水 → 审计 → 条件置位。`collectPendingWithdrawalRejects`/`applyPendingWithdrawalRejects`/`appendWithdrawalRejectAudit` 未重写。实测见 `04-write-path.txt`:「释放先于扣款 frozen 800→0、balance 1000→-4000、提现已拒绝、尝试已释放、原佣金不变」通过。 S-1 修复:本次未生成任何明细时(仅投影陈旧可达成)不写 `refund.clawback_commission` 审计,避免 `clawback_record_count=0 且 clawback_required=true` 的误导性事实;置位逻辑不变,正常路径(确有明细)审计与 metadata 计数不变(实测 `【S-1】``【S-1 对照】`:正常路径 metadata={"order_id": 540, "clawback_required": true, "clawback_record_count": 2, "clawback_total_amount": -5000})。
- [x] 3.6 新增周期性补偿任务:`@every 1m``MaxRetry(3)``Timeout(10m)``Unique(10m)`、独立队列,配套任务类型常量、`QueueForTaskType` 映射与 Handler保留既有启动时补偿扫描判据保持 `status = 2 且 commission_deducted = false`。 证据:`pkg/constants/constants.go:95` 新增 `TaskTypeRefundCommissionRecovery = "refund:commission:recovery"``:311` 纳入 `QueueForTaskType``internal/infrastructure/commissiondelivery/recovery_task.go` 新增 `RefundRecoveryTaskHandler`(只重投稳定事件,不直接改资金);`cmd/worker/main.go:502-511` 注册 Handler、`:164` 调用Scheduler 以 `@every 1m` + `MaxRetry(3)` + `Timeout(10m)` + `Unique(10m)` + `Queue(QueueForTaskType(...))` 注册(与 `TaskTypeRefundChannelRecovery` 同形态)。补偿扫描判据**与 HEAD 一致、未新增任何列条件**:仍是 `status = 2 AND (commission_deducted = false OR asset_reset = false)``internal/infrastructure/commissiondelivery/event.go``RecoverRefundPostProcessing`,同时覆盖资产后处理),仅将退款部分抽出为独立函数供启动扫描(`cmd/worker/main.go:413``commissionDelivery.Recover` 保留不变)与周期任务共用。
## 4. 读侧与导出
- [x] 4.1 列表支持 `status` 筛选透传DTO 与 service读侧两表 `UNION ALL` 合并分页,统一排序键 `created_at DESC, id DESC`,筛选与数据范围在合并前各自应用。 证据DTO `internal/model/dto/shop_commission_dto.go:151` 新增 `Status *int`Service `internal/service/shop_commission/service.go:283` 赋值 `Status: req.Status` 并补 `PageSize > MaxPageSize` 归一化Store 新增 `ListLedgerByShopID`,两支先各自 `Count` 与各自应用数据范围/筛选,再 `UNION ALL` 合并后分页。**排序与次序键为 `created_at DESC``id DESC``source ASC`**:两表自增 ID 空间独立,同一 `created_at``(created_at DESC, id DESC)` 不是全序,必须再以 `source ASC` 作末位次序键才能在 `created_at``id` 完全相同的跨表记录上保证翻页不重不漏design 第 8 条已同步该理由)。实测见 `05-read-path.txt`:「合并分页 total=3 逐页 size=1 无重复无缺失」「status=5→1 条回溯 / status=3→1 条原佣金」「同秒排序稳定」通过;**次序键反例已实测**:强制两表插入 `created_at``id` 完全相同的记录id=987654321后按 size=1 翻页,两行仍不重不漏且次序稳定为 `[clawback original]``【次序键复验】`)。
- [x] 4.2 原佣金返回 `clawback_records` 摘要;回溯明细返回原佣金标识、退款单号、负数金额、不可提现标识、回溯后余额与生成时间;越权按既有数据范围返回不存在或空结果。 证据:原佣金行返回 `clawback_records` 摘要(`ListClawbackSummaries`)与 `clawback_total_amount`;回溯行返回 `original_commission_id``refund_id``refund_no`、负数 `amount``withdrawable``balance_after``status_name=回溯`。越权在 Service 用 `middleware.CanManageShop` + 详情内 `row.ShopID != req.ShopID` 双重拦截,错误文本与「不存在」一致。实测见 `05-read-path.txt`:「越权不可枚举」(越权列表/详情失败、不存在返回「佣金明细不存在」、范围内可见 2 条、详情双向定位)通过。
- [x] 4.3 新增佣金明细详情接口,并同步可执行路由、`cmd/api/docs.go``cmd/gendocs/main.go` 装配。 证据:`internal/handler/admin/shop_commission.go:108-134` 新增 `GetCommissionRecord``internal/routes/shop.go:162-169` 新增 `GET /:shop_id/commission-records/:id` RouteSpec含中文 Description`pkg/openapi/handlers.go:32``BuildDocHandlers` 已含 `ShopCommission``cmd/api/docs.go``cmd/gendocs/main.go` 同构调用该入口(未新增 Handler 类型,故无需改这两处的显式赋值)。实测:`go run cmd/gendocs/main.go` 退出码 0`08-gendocs.txt``docs/admin-openapi.yaml` 出现 `/api/admin/shops/{shop_id}/commission-records/{id}`
- [x] 4.4 落地佣金明细导出场景:场景白名单、`internal/exporter` 注册、DTO `oneof`、DataSource 与列定义;粒度为佣金记录,原佣金与回溯记录各一行,金额保持分且可为负,冻结创建时筛选条件、操作者与可见范围。 证据:`pkg/constants/constants.go:370` 新增 `ExportTaskSceneCommissionRecord = "commission_record"``internal/exporter/registry.go:39` 注册 `NewCommissionRecordDataSource``:76` 纳入 `IsSupportedScene` 白名单;`internal/model/dto/export_task_dto.go` 三处 `oneof` 与描述补齐;新增 `internal/exporter/commission_record_scene.go`(列:记录来源/记录ID/代理店铺名称/关联订单号/资产标识/佣金来源/金额(元)/是否可提现/状态/回溯后佣金余额(元)/原佣金记录ID/来源退款单号/佣金入账时间/生成时间)。实测见 `06-export.txt`:「原佣金与回溯各一行,回溯金额 -90.00、余额 -40.00 原样导出,数据范围生效」通过。
## 5. 验证
- [x] 5.1 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL 与 Redis DB 6 上,以本地显式 `DB_*` 参数执行 `./scripts/migrate.sh` 完成 up/down/up仅创建与删除本 Change 自己的 fixture禁止重置整个测试库。 证据:见 `/tmp/aug26012-evidence/01-migration.txt`(含 D-2 修正后的 down 注释还原核对):显式 `DB_*``junhong_cmp_test`)执行 `./scripts/migrate.sh up`220 应用成功)→ `version` 返回 220 → `down 1` 回滚 220 → `version` 返回 219 → `up` 重新应用到 220 → `version` 返回 220。全部 fixture 建在回滚事务内,收尾核查 `tb_commission_clawback_record` 行数为 0、验证用店铺数为 0、验证用退款单数为 0dbhub 只读查询,见 `10-fixture-cleanup.txt`),未重置整库。
- [x] 5.2 验证金额:全额与部分回溯、比例与补差、乘法溢出行边界、累计不超过各原佣金剩余、冻结实收非正可恢复失败。 证据:见 `04-write-path.txt`17 项全部 PASS全额回溯-10000/-25000/-555余额 64445、比例与补差-66/-66/-68 总额-200、补差跨条向前末两条剩余=0 被跳过 → 各 -1000 总额-2000、Σ剩余封顶-2100、乘积极端值单条/多条 int64 max 无溢出、冻结实收非正D=0 与 D<0 均不落库不改余额不置位)、分母回落 + 原路分子(-5000
- [x] 5.3 验证时序与幂等:佣金未终态等待、终态确无佣金、原路在途不回溯、企业微信通过后撤销不回溯、渠道失败重提后按最终金额回溯一次、重复消费不重复。 证据:同上 `04-write-path.txt`:佣金未终态等待、终态确无佣金(置位 + `refund.clawback_not_required`)、原路在途不回溯、撤销不回溯(`anomaly_flag=1` 转人工且闭合)、渠道失败重提后按最终金额回溯一次(-6000、重复消费不重复3 次 → 1 明细 1 流水)、唯一约束权威(重复插入被数据库拒绝)、并发消费(已有明细时跳过重复扣款并闭合)。
- [x] 5.4 验证资金与读侧:释放先于扣款且负余额不违约、原佣金状态与金额不变、读侧合并分页与越权、导出负数余额与原样导出。 证据:见 `04-write-path.txt` 的「释放先于扣款且负余额不违约」frozen 800→0、balance 1000→-4000、提现已拒绝、尝试已释放、原佣金 status/amount/source/released_at 全部不变);`05-read-path.txt`4 项 PASS的合并分页、status 筛选、越权不可枚举、同秒稳定排序;`06-export.txt` 的回溯行金额 `-90.00` 与回溯后余额 `-40.00` 原样导出、粒度两行、数据范围生效。
- [x] 5.5 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-commission-clawback-records --strict``openspec doctor --json``./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 证据:见 `02-build.txt`(退出码逐一显式记录,不经管道过滤):`gofmt -l` 对全部 22 个变更/新增 Go 文件**无输出**`go build ./cmd/api ./cmd/worker` **BUILD_EXIT=0**(构建输出仅含 go 模块缓存写权限噪音,无编译错误);`go vet ./internal/... ./pkg/...` **VET_EXIT=0** 且无输出;`go run cmd/gendocs/main.go` **GENDOCS_EXIT=0**`openspec validate add-commission-clawback-records --strict``Change is valid`VALIDATE_EXIT=0`openspec doctor --json``"healthy": true``./scripts/context-health.sh``Context 健康检查通过`CTX_EXIT=0`03-context-health.txt`)。自动化测试按项目决策为 N/A临时验证脚手架已全部删除收尾后 `find . -name '*_test.go' | wc -l` 为 0、`find . -name 'zz_*' | wc -l` 为 0仓库未新增测试入口。