## 1. 事实与常量 - [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、验证用退款单数为 0(dbhub 只读查询,见 `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,仓库未新增测试入口。