feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
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:
@@ -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/O(ENG-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,不重置整库。验证覆盖全额与部分回溯、比例与补差、溢出行边界、累计不超过剩余、冻结实收非正可恢复失败、佣金未终态等待、终态确无佣金、原路在途不回溯、撤销不回溯、渠道失败重提后按最终金额回溯、重复消费、释放先于扣款与负余额不违约、原佣金不变、读侧合并分页与越权、导出负数余额。
|
||||
|
||||
Reference in New Issue
Block a user