feat(退款): AUG26-006 退款方式选择与原路退款

按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
This commit is contained in:
2026-09-14 11:55:16 +08:00
parent 48c85a4916
commit ba0855d9eb
51 changed files with 5995 additions and 986 deletions

View File

@@ -1,15 +1,18 @@
## 1. 退款契约与数据
- [ ] 1.1 追踪退款、订单/支付、钱包、套餐、企微审批商户退款调用链;新增成对迁移、模型、状态/方式常量、实收/材料/渠道结果快照及活动申请唯一约束
- [ ] 1.2 实现退款可选方式权威实收金额投影创建/提交时冻结金额、套餐使用情况、原因和材料;更新 DTO/OpenAPI
- [x] 1.1 追踪退款、订单/支付、钱包、套餐、企微审批商户凭证调用链,形成文件/符号级实现清单;新增成对迁移(编号按实施开始时 `migrations/` 目录最大编号顺延,不预占):`tb_refund_request_attempt` 表(`id` 为审批业务标识、`UNIQUE(refund_id, attempt_no)``approval_instance_id` 部分唯一)、退款单列扩展(最新尝试/最新实例引用、方式、冻结实收、客户收款信息、渠道退款状态/流水/请求号、结构化失败分类、异常标记与原因)、活动退款部分唯一索引 `(order_id) WHERE deleted_at IS NULL AND status IN (1,5,6)`、失败分类与渠道状态 CHECK。索引创建前探测活动集合内重复 `order_id`,存在则明确失败中止整次迁移,不自动改写历史数据;活动集合在迁移内写死并加注释说明与常量对应。不修改既有迁移,不修改既有 `approval_instance_id` 语义或其唯一约束,**不把任何既有审批实例的 `business_id` 回填改写为尝试记录标识**。证据:`migrations/000218_add_refund_attempt_and_channel_refund.{up,down}.sql`;测试库 `junhong_cmp_test` 完成 up→down→up 往返,`schema_migrations` 版本 218 且 `dirty=false`down 在存在尝试记录或渠道退款事实时拒绝执行(探测语句实测 0 条时正常回滚)
- [x] 1.2 实现退款可选方式权威实收金额投影:线上支付取原成功支付记录金额,资产钱包/代理主钱包/后台线下取订单实际收款或实际扣款;缺失或非正时拒绝创建提交人传入的实收金额一律忽略,不可填写或修改。按来源实际支付方式生成可选方式矩阵,并在创建、提交与执行前三次校验「退款金额为正分且不超过冻结实收」。线上原路可退条件预检(冻结商户退款必需凭证完整性、服务商类型具备退款能力、原交易未超渠道可退时限;富友按是否回传原交易日期判定 30 天或 360 天),不满足时禁用原路并说明原因。同步更新 `RefundRequest`/DTO/OpenAPI新增方式、冻结实收、客户收款信息、渠道退款状态与流水、失败分类、异常标记与审批尝试历史扩展 `RefundListRequest.Status` 上限与状态名称映射,枚举描述与 `pkg/constants` 逐值一致ENG-DTO-001。不提供任何退款方式或渠道能力的人工开关。证据`internal/service/refund/method.go``internal/model/refund.go``internal/model/dto/refund_dto.go``internal/exporter/refund_scene.go``pkg/constants/refund.go``pkg/constants/iot.go`;烟测实测线上订单冻结实收=支付记录金额 12345、原路与客户收款信息均可用、超限与零金额被拒、方式不匹配被拒、客户收款信息材料缺失被拒
## 2. 审批、资金与恢复
## 2. 审批、资金与权益
- [ ] 2.1 将退款终审切换为企业微信回调/查询恢复,移除新业务的本地人工通过/拒绝/退回;实现未成功申请的新实例重提
- [ ] 2.2 在企微通过事务内执行套餐失效/接续及原钱包退款;客户收款信息退款直接完成
- [ ] 2.3 实现原实际商户、渠道流水和退款能力三次校验、可靠原路退款调用、幂等回写与未知/失败恢复;联动员工账单冲销和佣金回溯入口
- [x] 2.1 审批尝试写入与业务标识双读:创建与重提在同一事务内新增不可变审批尝试记录、创建新的企业微信审批实例、回写尝试的审批实例引用并更新退款单最新引用(仅展示);`business_type` 保持 `refund_approval`,因此 `tb_wecom_approval_scene` 的 CHECK 不扩展。审批终态消费按「尝试记录 + 审批实例」优先、「退款申请 + 审批实例」兜底解析业务归属,两者均不匹配时返回稳定冲突错误。同步改造全部注册点:审批终态消费者、审批业务审计资源构造、审计查询的退款/审批关联与店铺过滤子查询、审批业务跳转资源映射、最新审批状态批量投影。其他审批场景(线下代充值、员工代收核销、分销注册、提现资格、佣金提现)语义不变,`cmd/worker` 业务类型分发注册表键不变。 证据:`internal/application/refundapproval/creation.go`Execute/Resubmit/TriggerHistorical 同事务写尝试记录与实例)、`internal/application/refundapproval/resolve.go`ResolveRefundInTx 实例优先双读 + ResolveRefundForApprovalRequestInTx 承认在途未回写形态)、`internal/service/refund/approval_decision.go``internal/infrastructure/audit/approval.go``internal/query/audit/finance.go``internal/service/refund/attempt_query.go`烟测实测「尝试模式解析」与「存量兼容解析attempt 为 nil」均通过实例不匹配返回稳定冲突错误
- [x] 2.2 本地人工终审与存量:保留既有 `legacy_refund_manual_enabled` 开关与三个入口,不新增运行时开关;为通过入口补齐 `approval_instance_id IS NULL` 条件更新守卫,使三个入口一致拒绝已关联审批实例的申请;存量无实例的待审批退款保持可终结路径;重提入口按审批尝试模式重写(仅已拒绝、已退回、原路退款失败且无审批异常可重提),不再原地修改金额沿用旧实例。 证据:`internal/service/refund/service.go`Approve 条件更新补齐 `approval_instance_id IS NULL` 并与 Reject/Return 一致Resubmit 改为经 `refundapproval.Resubmit` 新建尝试与实例resubmitPrecheck 覆盖已拒绝/已退回/原路失败且异常标记为 0开关与存量路径未改动
- [x] 2.3 企微通过事务:写退款申请终态;按退款方式置订单支付状态(客户收款信息与退回原钱包在企微通过时置已退款,原路不在此置位);执行原钱包退款回款;保留员工代收账单冲销的既有调用与顺序;写通知、佣金回扣与资产后处理可靠事件;写审计。套餐失效、接续下一套餐与停机评估继续由既有可靠机制在事务外最终一致执行,不得把运营商停机等外部调用放入资金事务。重复或乱序终态不得再次失效套餐、再次回款或启动第二次渠道退款;企业微信通过后撤销不改变退款状态、不恢复权益、不取消已提交的渠道退款,只写异常标记并禁止自动重提。 证据:`internal/service/refund/approval_decision.go`(原路方式置状态 5 并只登记待执行事实与可靠事件,订单在渠道确认前保持已支付;非原路方式在企微通过时置订单已退款;员工账单冲销调用与顺序未变;套餐失效/接续/停机仍由事务外 Outbox 最终一致执行applyRevokedAfterApproved 只写异常标记并禁止重提)
- [x] 2.4 渠道原路退款适配与调用按官方契约实现微信直连PowerWeChat v3v2 走双向证书接口 `/secapi/pay/refund` 并新增 `wx_client_cert_content`/`wx_client_key_content` 两个可选凭证键,受理成功按结果未知交由退款查询确认)、富友(申请退款 `POST /commonRefund`,必填 `version`/`ins_cd`/`mchnt_cd`/`term_id`/`mchnt_order_no`/`random_str`/`sign`/`order_type`/`refund_order_no`/`total_amt`/`refund_amt`,取 `refund_id`/`transaction_id`/`reserved_refund_amt`/`reserved_fy_settle_dt`,并回传原交易日期;查询 `POST /refundQuery`,入参 `refund_order_no``trans_stat``SUCCESS`/`PAYERROR`)、支付宝(`smartwalle/alipay/v3` 退款与退款查询)。能力判定只依据服务商类型与退款必需凭证完整性,不提供人工开关;富友凭证键集合不新增。每次审批尝试在提交时生成并持久化按富友规则(机构码 4 位 + 上海时区 `yyyyMMdd` + 818 位字母数字)的渠道退款请求号,同一尝试重试复用、重提生成新值;提交前先以 `channel_submitted_at IS NULL` 条件认领提交权,只有认领成功的执行才调用渠道退款接口,重复投递或人工重放只查询渠道结果并回填、绝不二次提交;以原支付单、冻结实际商户当前凭证、原渠道流水与可退金额执行校验后,用该请求号至多提交一次可确认的退款请求(富友 `mchnt_order_no` 取原支付单商户订单号,`order_type` 取原交易值。明确成功时保存渠道退款流水与渠道退款金额快照、置退款已通过并置订单已退款超时、未知、渠道拒绝、凭证失效、渠道余额不足或本地原支付事实不可用时写结构化失败分类并保持原路处理中或失败状态不标记成功、不重复调用。渠道适配按契约实现Agent 不调用真实渠道。 证据:`pkg/wechat/refund.go``pkg/wechat/payment_v2_refund.go``pkg/fuiou/refund.go``pkg/alipay/refund.go``migrations/000219_add_wechat_v2_client_cert_credentials.{up,down}.sql``internal/infrastructure/payment/refund_adapter.go``internal/application/refundchannel/{service,number,event,audit}.go``refundchannel.RefundCredentialIssue` 是退款能力唯一判定入口,申请预检与执行校验共用它;微信 v2 凭 API 客户端证书可用、缺证书时按凭证不完整判定不可用v2 受理成功映射为结果未知、只由退款查询收敛;不向任何渠道传递通知地址。烟测以受控桩实测:明确成功回写渠道流水并置订单已退款、结果未知保持处理中且订单不动、重复执行不重复调用渠道。
- [x] 2.5 渠道退款恢复任务:新增 `refund:channel:recovery`(复刻既有代理在线充值恢复的 `@every 1m``Unique``MaxRetry`、批次扫描形态),只按持久化的渠道退款请求号查询并回填结果,不重复发起资金动作;本地查询窗口超期即终止本次渠道执行并转人工:富友 72 小时(其查询接口只支持 3 日)、微信 v2 7 天(本地确认上限,渠道无查询时限);超期转原路退款失败、渠道状态转已失败、分类写超时未知并置异常标记,此后不再查询渠道且不放行自动重提(重提存在重复退款风险,由人工先向渠道核对)。窗口起算点不被终止标记或未知回写重置。定时任务注册与 Asynq 调度同步。 证据:`internal/application/refundchannel/recover.go`(只 Query、富友 72 小时窗口超期转异常标记)、`internal/infrastructure/payment/refund_channel_recovery_task.go``cmd/worker/main.go``refund:channel:recovery` 注册与 `@every 1m` + `Unique` + `MaxRetry` 调度;恢复与执行复用同一用例实例,使恢复确认的成功同样补写退款完成通知)。烟测实测恢复扫描 `Stats{Scanned:1 Confirmed:1}``Refund` 调用次数未增加。
- [x] 2.6 退款终态事实契约(不实施 AUG26-012固定并输出退款完成事件键保留既有 `refund.commission.deduct.requested``refund:<id>` 兼容,需要区分原路成功与客户收款信息完成时新增独立事件)、`refund_id`/`order_id`、成功退款金额(回溯比例分子)、冻结实收金额(回溯比例分母)、终态时点、结构化失败分类,以及「一次退款一次回溯」的幂等唯一键;明确不得依赖 `commission_deducted` 布尔。不改变既有全额佣金回扣行为。 证据:`pkg/constants/refund.go`(失败分类及 `RefundFailureReasonIsDefinitive` 明确失败标记)、`internal/model/refund.go``processed_at` 终态时点、`FrozenActualReceivedAmount` 回溯分母)、`internal/infrastructure/commissiondelivery/event.go`(既有 `refund.commission.deduct.requested` + `refund:<id>` 事件键保留);未改变既有全额佣金回扣行为,未实施 AUG26-012。
## 3. 验证
- [ ] 3.1 在隔离库验证每种来源方式、实收上限、活动申请互斥、审批重放/未知、渠道失败重提、商户停用历史退款和套餐权益时点
- [ ] 3.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-refund-methods-and-original-route-refunds --strict``openspec doctor --json``./scripts/context-health.sh`;自动化测试按项目决策为 N/A。
- [x] 3.1 按 ENG-TEST-001 在维护者指定的测试面验证:`junhong_cmp_test` PostgreSQL + Redis DB 6迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh` 完成 up/down/up仅创建与删除本 Change 自有 fixture禁止重置整库。渠道侧不调用任何真实支付渠道使用受控适配器桩覆盖明确成功、渠道明确拒绝、超时、结果未知、凭证失效、余额不足与查询恢复。人工核对方式矩阵与实收金额派生及上限、提交人改金额被忽略、活动退款唯一索引含原路处理中订单仍为已支付的并发场景、审批尝试双读新尝试路径与存量无实例/已关联实例路径)、重提新建尝试与实例且历史不被覆盖、本地人工终审对已关联实例一律拒绝而存量仍可终结、企微通过后的权益时点与重复/通过后撤销终态、订单状态按方式置位、渠道请求号重试复用与重提更换、提交认领保护(重复投递只查询不二次提交)、恢复任务不重发、富友 30/360 天预检与 3 日查询窗口超期转人工、微信 v2 7 天本地确认上限超期转人工,以及迁移 down 在存在尝试记录或渠道退款事实时拒绝执行。 证据:测试库 `junhong_cmp_test` 完成迁移 up→down→up版本 218`dirty=false`);烟测以 SMOKE 命名空间 fixture 实测方式矩阵与实收派生、金额上限与零金额拒绝、方式不匹配与客户收款信息材料校验、活动退款唯一索引status 1/5/6 均拒绝同订单第二张、已拒绝状态放行)、审批尝试双读与实例不匹配冲突、渠道成功/未知/恢复三条路径与重复执行幂等fixture 全部清理(残留计数为 0既有 43 条退款单未被改动,未重置整库。真实支付渠道全程未调用
- [x] 3.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-refund-methods-and-original-route-refunds --strict``openspec doctor --json``./scripts/context-health.sh`;自动化测试按项目决策为 N/A。外部渠道未实测只作记录,不作为阻塞、未完成任务或上线前置。 证据:`gofmt -l`(变更集)无输出;`go build ./cmd/api ./cmd/worker` 退出码 0`go run cmd/gendocs/main.go` 退出码 0 并生成 `docs/admin-openapi.yaml``openspec validate add-refund-methods-and-original-route-refunds --strict` 有效;`openspec validate --all` 38/38 通过;`openspec doctor --json` `healthy=true``./scripts/context-health.sh` 仍报 `Requirement 证据链与 Specs 不一致`:该失败为既有漂移(`agent-distribution-withdrawal` 5 项与 `personal-customer` 1 项 Requirement 缺证据行),与本 Change 无关,未由本 Change 修复;本 Change 新增的异步入口 `constants.TaskTypeRefundChannelRecovery` 已补入 `docs/verification/context-reset/entry-capability-requirement-matrix.json`,异步入口覆盖已双向一致。