Files
junhong_cmp_fiber/openspec/changes/add-commission-clawback-records/tasks.md
break 1aa4eacee2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
用 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
2026-09-14 13:40:34 +08:00

20 KiB
Raw Blame History

1. 事实与常量

  • 1.1 新增 CommissionStatusClawback = 5GetCommissionRecordStatusName 的「回溯」映射,并同步佣金记录状态常量注释与 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
  • 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-314refundSystemAction 定义、:644-645 登记、:311/:643 保留旧动作注册。旧调用点 appendCommissionAuditrecordCommissionFailure 已整体删除(internal/service/refund/audit.go 现无该常量引用),全仓 grep 仅剩常量定义与注册两处。
  • 1.3 核对回溯用例 Audit Event、Domain Ledger、Integration Log 与 Outbox 四类事实的使用决定或 N/A 理由并在仓库外记录证据ENG-AUDIT-001。 证据:四类事实决定记录于仓库外 /tmp/aug26012-evidence/07-audit-facts.mdAudit Event 使用、Domain Ledger 使用、Integration Log N/A本用例不调用外部系统理由已写明、Outbox 复用既有事件。落地实现见 internal/service/refund/clawback.goapplyClawback/settleClawback/appendClawbackAudit(审计与资金同事务、AppendAndGet 要求成功必达)。

2. Schema 与模型

  • 2.1 新增成对迁移建 tb_commission_clawback_recordrefund_idoriginal_commission_idorder_idshop_id、负数 amount、不可提现标识、statusbalance_after、原佣金与退款关键快照、created_at;迁移编号按 migrations/ 目录当前最大编号顺延,不预占编号,不修改任何既有迁移。 证据:新增成对迁移 migrations/000220_add_commission_clawback_record.up.sql/.down.sql;编号按 migrations/ 当前最大 000219 顺延,未修改任何既有迁移(git status 中 migrations 仅新增这两个文件)。建表含 refund_idoriginal_commission_idorder_idshop_idorder_norefund_nocommission_source、负数 amountbalance_afterwithdrawablestatuscreated_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)保持一致:若用 timestamptzUNION ALL 会按会话时区改写其中一支破坏统一排序键与时间筛选口径S-4 同时把两个回溯分支的占位列由 NULL::timestamptz 改为 NULL::timestamp,消除该耦合(实测 【S-4】released_at 跨会话时区稳定Shanghai/NewYork/UTC 三会话同值)。
  • 2.2 迁移内建唯一约束 (refund_id, original_commission_id),以及店铺 + 时间、original_commission_idorder_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:94000113 只迁移状态值未改注释),使 down 成为 up 的严格逆操作ENG-MIG-001。实测down 后 dbhub 只读核对该注释为 状态 1-已入账 2-已失效up 后为 状态 1-已冻结 2-解冻中 3-已发放 4-已失效 5-回溯 99-待人工修正(见 01-migration.txt)。
  • 2.3 新增回溯明细模型与 DTO含中文 description 与状态枚举名ENG-DTO-001。 证据:新增 internal/model/commission_clawback.goCommissionClawbackRecord(表名 tb_commission_clawback_recordDTO 见 internal/model/dto/shop_commission_dto.goShopCommissionClawbackItemShopCommissionRecordItemstatus 描述为 1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 5:回溯, 99:待人工修正,与 pkg/constants 一致)、ShopCommissionRecordDetailReq/Resp,均为中文 description。

3. 写路径替换

  • 3.1 实现回溯准入:仅 refund.status = 2anomaly_flag = 0 且(非原路方式或 channel_refund_status = 2)时生成;其余状态返回可重试错误,不写明细、不置完成事实;不使用失败分类标记判定准入。 证据:internal/service/refund/clawback.go:clawbackCommission —— refund.AnomalyFlag != 0 先走转人工闭合;refund.Status != model.RefundStatusApproved 返回 CodeServiceUnavailablerefund.Method == original_route && ChannelRefundStatus != 2 返回可重试错误。未引用 RefundFailureReasonIsDefinitivegrep -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(入账置 3recharge_order/service.go:418(置 3shop_commission/service.go:751/:781(人工处置置 4 或 3写入从不写入 1/2(遗留枚举,仅历史读取与名称映射用),故现实等待只可能由 99 引起且处置后必然收敛。。
  • 3.2 实现金额计算:分母取本次退款冻结实收金额(缺失时回落审批尝试记录),<= 0 时记录可恢复失败且不落库、不改余额;分子原路取渠道明确成功金额、其余取审批退款金额;乘法使用 math/big 或等价大整数中间量,全程 int64 分,禁止浮点与溢出。 证据:resolveClawbackDenominator(退款行缺失时回落 LatestAttemptID 对应尝试的 frozen_actual_received_amount)、resolveClawbackNumerator(原路取 ChannelRefundAmount,其余取 ApprovedRefundAmountD <= 0 记录可恢复失败并返回错误,不落库、不改余额、不置位;乘法用 math/big 中间量(mulDivFloor/mulDivFloorInt64),全程 int64 分。实测见 04-write-path.txt:「冻结实收非正 D=0」「分母回落+原路分子」「乘积极端值 6148914691236517204 / [6148914691236517204 6148914691236517205]」通过。
  • 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」通过。
  • 3.4 实现后处理闭合的三种结果与置位保护:已生成回溯、终态无需回溯、审批异常转人工;置位 commission_deductedWHERE commission_deducted = false 保护;订单佣金未终态时不写明细、不置位并返回可重试错误。 证据:clawbackCommission 三种闭合——已生成回溯(applyClawback,置位 WHERE id = ? AND commission_deducted = false 并判 RowsAffected != 1CodeConflict)、终态无需回溯(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 确无佣金保持原行为。
  • 3.5 删除旧全额失效写入(原佣金置已失效、旧审计前后态与调用点),替换为插入回溯明细;保留 collectPendingWithdrawalRejectsapplyPendingWithdrawalRejectsappendWithdrawalRejectAudit,事务内顺序固定为:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 → 扣 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})。
  • 3.6 新增周期性补偿任务:@every 1mMaxRetry(3)Timeout(10m)Unique(10m)、独立队列,配套任务类型常量、QueueForTaskType 映射与 Handler保留既有启动时补偿扫描判据保持 status = 2 且 commission_deducted = false。 证据:pkg/constants/constants.go:95 新增 TaskTypeRefundCommissionRecovery = "refund:commission:recovery":311 纳入 QueueForTaskTypeinternal/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.goRecoverRefundPostProcessing,同时覆盖资产后处理),仅将退款部分抽出为独立函数供启动扫描(cmd/worker/main.go:413commissionDelivery.Recover 保留不变)与周期任务共用。

4. 读侧与导出

  • 4.1 列表支持 status 筛选透传DTO 与 service读侧两表 UNION ALL 合并分页,统一排序键 created_at DESC, id DESC,筛选与数据范围在合并前各自应用。 证据DTO internal/model/dto/shop_commission_dto.go:151 新增 Status *intService internal/service/shop_commission/service.go:283 赋值 Status: req.Status 并补 PageSize > MaxPageSize 归一化Store 新增 ListLedgerByShopID,两支先各自 Count 与各自应用数据范围/筛选,再 UNION ALL 合并后分页。排序与次序键为 created_at DESCid DESCsource ASC:两表自增 ID 空间独立,同一 created_at(created_at DESC, id DESC) 不是全序,必须再以 source ASC 作末位次序键才能在 created_atid 完全相同的跨表记录上保证翻页不重不漏design 第 8 条已同步该理由)。实测见 05-read-path.txt:「合并分页 total=3 逐页 size=1 无重复无缺失」「status=5→1 条回溯 / status=3→1 条原佣金」「同秒排序稳定」通过;次序键反例已实测:强制两表插入 created_atid 完全相同的记录id=987654321后按 size=1 翻页,两行仍不重不漏且次序稳定为 [clawback original]【次序键复验】)。
  • 4.2 原佣金返回 clawback_records 摘要;回溯明细返回原佣金标识、退款单号、负数金额、不可提现标识、回溯后余额与生成时间;越权按既有数据范围返回不存在或空结果。 证据:原佣金行返回 clawback_records 摘要(ListClawbackSummaries)与 clawback_total_amount;回溯行返回 original_commission_idrefund_idrefund_no、负数 amountwithdrawablebalance_afterstatus_name=回溯。越权在 Service 用 middleware.CanManageShop + 详情内 row.ShopID != req.ShopID 双重拦截,错误文本与「不存在」一致。实测见 05-read-path.txt:「越权不可枚举」(越权列表/详情失败、不存在返回「佣金明细不存在」、范围内可见 2 条、详情双向定位)通过。
  • 4.3 新增佣金明细详情接口,并同步可执行路由、cmd/api/docs.gocmd/gendocs/main.go 装配。 证据:internal/handler/admin/shop_commission.go:108-134 新增 GetCommissionRecordinternal/routes/shop.go:162-169 新增 GET /:shop_id/commission-records/:id RouteSpec含中文 Descriptionpkg/openapi/handlers.go:32BuildDocHandlers 已含 ShopCommissioncmd/api/docs.gocmd/gendocs/main.go 同构调用该入口(未新增 Handler 类型,故无需改这两处的显式赋值)。实测:go run cmd/gendocs/main.go 退出码 008-gendocs.txtdocs/admin-openapi.yaml 出现 /api/admin/shops/{shop_id}/commission-records/{id}
  • 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. 验证

  • 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 up220 应用成功)→ version 返回 220 → down 1 回滚 220 → version 返回 219 → up 重新应用到 220 → version 返回 220。全部 fixture 建在回滚事务内,收尾核查 tb_commission_clawback_record 行数为 0、验证用店铺数为 0、验证用退款单数为 0dbhub 只读查询,见 10-fixture-cleanup.txt),未重置整库。
  • 5.2 验证金额:全额与部分回溯、比例与补差、乘法溢出行边界、累计不超过各原佣金剩余、冻结实收非正可恢复失败。 证据:见 04-write-path.txt17 项全部 PASS全额回溯-10000/-25000/-555余额 64445、比例与补差-66/-66/-68 总额-200、补差跨条向前末两条剩余=0 被跳过 → 各 -1000 总额-2000、Σ剩余封顶-2100、乘积极端值单条/多条 int64 max 无溢出、冻结实收非正D=0 与 D<0 均不落库不改余额不置位)、分母回落 + 原路分子(-5000
  • 5.3 验证时序与幂等:佣金未终态等待、终态确无佣金、原路在途不回溯、企业微信通过后撤销不回溯、渠道失败重提后按最终金额回溯一次、重复消费不重复。 证据:同上 04-write-path.txt:佣金未终态等待、终态确无佣金(置位 + refund.clawback_not_required)、原路在途不回溯、撤销不回溯(anomaly_flag=1 转人工且闭合)、渠道失败重提后按最终金额回溯一次(-6000、重复消费不重复3 次 → 1 明细 1 流水)、唯一约束权威(重复插入被数据库拒绝)、并发消费(已有明细时跳过重复扣款并闭合)。
  • 5.4 验证资金与读侧:释放先于扣款且负余额不违约、原佣金状态与金额不变、读侧合并分页与越权、导出负数余额与原样导出。 证据:见 04-write-path.txt 的「释放先于扣款且负余额不违约」frozen 800→0、balance 1000→-4000、提现已拒绝、尝试已释放、原佣金 status/amount/source/released_at 全部不变);05-read-path.txt4 项 PASS的合并分页、status 筛选、越权不可枚举、同秒稳定排序;06-export.txt 的回溯行金额 -90.00 与回溯后余额 -40.00 原样导出、粒度两行、数据范围生效。
  • 5.5 运行 gofmt -wgo build ./cmd/api ./cmd/workergo run cmd/gendocs/main.goopenspec validate add-commission-clawback-records --strictopenspec 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=0openspec validate add-commission-clawback-records --strictChange is validVALIDATE_EXIT=0openspec doctor --json"healthy": true./scripts/context-health.shContext 健康检查通过CTX_EXIT=003-context-health.txt)。自动化测试按项目决策为 N/A临时验证脚手架已全部删除收尾后 find . -name '*_test.go' | wc -l 为 0、find . -name 'zz_*' | wc -l 为 0仓库未新增测试入口。