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,38 +1,241 @@
## Context
退款服务已有申请、企业微信审批和钱包审计路径,但现有状态/人工入口不足以表达渠道退款未知结果。支付商户池 Change 完成后,新支付单可读取冻结实际商户;历史单继续走旧支付配置。
现状(开工核对确认,均为可达代码事实):
- 退款创建 100% 经 `internal/application/refundapproval/creation.go` 在同一事务创建退款单与审批实例,`business_id` 取退款单 ID`tb_approval_instance``uq_approval_instance_business UNIQUE (business_type, business_id)``migrations/000170_create_approval_core.up.sql:12`)因此结构性地禁止同一退款单拥有第二个审批实例。
- 重提入口 `POST /api/admin/refunds/:id/resubmit` 只接受已退回状态,直接改金额回待审批且不新建实例(`internal/service/refund/service.go:720`)。
- 本地人工终审默认开放(`legacy_refund_manual_enabled: true``Approve` 的条件更新缺少 `approval_instance_id IS NULL``Reject`/`Return` 已具备(`service.go:315``:641``:685`)。
- 实收金额由请求体自填且不校验(`internal/model/dto/refund_dto.go:6`),退款金额上限只比 `order.actual_paid_amount`
- 活动退款互斥只有 `pg_advisory_xact_lock(order_id)` 加计数(`creation.go:181-188`),无数据库约束。
- 企微终态唯一入口 `internal/service/refund/approval_decision.go`:事务内写退款单、订单、钱包回款、员工账单冲销、通知/佣金/资产 Outbox 与审计;套餐失效、接续与停机在事务外由 Outbox→Worker 执行(`service.go:1125``revoked_after_approved` 当前直接 `return nil`
- 渠道侧完全没有退款代码:微信 v3 仅下单/查单/关单/回调,自研微信 v2 仅 `unifiedorder`/`orderquery`,支付宝仅 `TradeWapPay`,富友仅 `preCreate`/`wxPreCreate`/`commonQuery`。退款能力判定已存在且只依据服务商类型与凭证完整性(`internal/service/refund/payment_merchant.go:64`,无人工开关)。
- 商户加载不过滤状态,停用商户仍可加载(`internal/application/merchantpayment/routing.go:81`)。
关键数据边界(测试库 `junhong_cmp_test` 只读核对43 条退款单中 12 条无审批实例、其中 4 条待审批2 条申请金额为 0无订单存在两张活动退款单。
## Goals / Non-Goals
**Goals:**
- 让原路退款在企微通过后由原实际收款商户的当前凭证可靠执行,结果未知时可恢复且绝不重复扣款。
- 让每次提交持有独立的、不可变的审批材料与审批实例,且不破坏存量已关联实例的审批消费。
- 让退款状态机、活动申请互斥与冻结实收金额在数据库层和条件更新层同时闭合。
**Non-Goals:**
- AUG26-012 佣金回溯明细(本 Change 只提供事实契约)。
- 代理充值预存款业务单的退款。
- 真实支付渠道实测(见「不做外部渠道实测」)。
- 对既有状态显示名、既有 `approval_instance_id` 语义或其唯一约束做破坏性改名或重构。
- 新增任何退款相关的运行时开关(含渠道能力开关、退款方式开关、恢复开关)。
## Decisions
- 退款单新增方式、冻结实收金额、套餐使用/材料快照、渠道退款状态/流水/安全失败原因和审批实例历史;金额均为分。
- 创建、审批消费和渠道执行使用同一退款单锁及条件状态更新。一笔订单的活动退款唯一约束防止并发重复;渠道请求使用稳定请求标识,结果未知进入恢复任务而非重发。
- 企业微信通过事务内完成套餐失效与钱包退款准备;原路渠道调用使用可靠事件,成功回写退款终态。客户收款信息退款以企微通过终态完成。
- 移除/拒绝本地人工终审语义,保留历史兼容读取;未成功重提始终冻结新实例快照。
### 1. 渠道退款能力判定只由服务商类型与退款必需凭证完整性决定
## 行为契约
在既有 `validateFrozenMerchantRefundCredentials` 的判定上把全部三类服务商都视为具备原路退款能力,并按下列口径修正:
### 退款申请与重提
| 服务商类型 | 原路退款能力 | 依据 |
| --- | --- | --- |
| `wechat`(微信直连 v3 | 支持 | 凭证含 `wx_mch_id`/`wx_api_v3_key`/`wx_cert_content`/`wx_key_content`/`wx_serial_no`/`wx_notify_url` 时可用 v3 退款接口 |
| `wechat_v2` | 支持(需 APIv2 Key + API 客户端证书) | v2 退款走双向证书接口(`/secapi/pay/refund`,渠道文档明确「请求需要双向证书」),故必需凭证含 `wx_client_cert_content``wx_client_key_content` |
| `fuiou` | 支持 | 既有凭证键集合已含 `/commonRefund``/refundQuery` 所需全部参数 |
| `alipay` | 支持 | 凭证含 `ali_app_id`/`ali_private_key`/`ali_public_key` 时可签名退款请求 |
- `POST /refunds`:调用者必须在订单数据范围内。服务锁定订单和既有活动退款,读取订单或原成功支付记录的权威实收金额;缺失、非正或已存在审批中/原路处理中/原路失败申请时拒绝。请求包含退款原因、退款方式、退款金额、客户收款信息及附件(仅客户收款信息方式);金额为正分且不超过冻结实收金额。
- 服务按实际支付方式生成可选方式:微信/支付宝线上支付为原路或客户收款信息,资产钱包/代理主钱包仅原钱包,后台线下/员工代收仅客户收款信息;不匹配的方式返回“该订单不支持此退款方式”。客户收款信息与至少一个凭证附件必须同时存在,且不读取员工收款方式字典。
- `PUT /refunds/:id` 或既有重提入口只允许驳回、关闭或渠道明确失败的未成功申请;重新锁定订单和申请,保存新的原因、方式、金额、收款信息、附件和套餐使用快照,创建新的企业微信审批实例。提交失败或审批未知不是可重提状态;已成功、审批中、原路处理中返回状态冲突。
**结论(已裁决)**:微信支付 v2 **本身支持退款**,其退款接口 `/secapi/pay/refund` 按渠道文档「请求需要双向证书」。因此本 Change 为 `wechat_v2` 扩展两个凭证键并实现该接口:
### 企业微信终审与权益处理
- 新增凭证键 `wx_client_cert_content``apiclient_cert.pem`)与 `wx_client_key_content``apiclient_key.pem`),二者对支付/查单**非必填**,只用于退款能力判定与退款调用;不破坏既有 v2 商户的支付与回调。
- 判定规则v2 商户凭证含 APIv2 Key **且**含上述两项证书内容时原路退款可用;缺证书时按凭证不完整判定为不可用,仅客户收款信息退款,并返回说明「缺少 API 客户端证书」。
- 迁移 `000219` 在历史 `tb_wechat_config` 上补齐同名列(存量行为空,补齐后生效);商户池路径的商户凭证存于 `tb_payment_merchant.credentials`JSONB无需改表`merchantpayment.MerchantConfig` 自动映射。
- 证书装载失败(内容非法/不匹配)不阻断 v2 支付与查单:实例不装载证书,退款能力随之判定为不可用。
- 不提供人工开关;能力仍只由服务商类型与凭证完整性决定。
- 回调和既有审批恢复任务以审批实例 ID 进入同一幂等用例;移除新业务的本地人工通过、拒绝和退回终审路径,历史接口仅保留兼容读取或明确拒绝
- 首次最终通过时锁定退款和订单,校验批准金额不超过冻结实收金额;在事务中标记审批通过、使关联套餐失效、接续下一套餐、评估停机并建立退款执行事实。重复/乱序回调不得再次失效套餐或启动第二次渠道退款。
- 客户收款信息方式在企业微信通过时标记退款成功;原钱包方式沿用原钱包退款事务;原路方式只写待执行可靠事件,不能在审批事务中假定渠道已成功。
**渠道结果语义**:渠道文档规定 v2 退款申请接口「返回仅代表业务的受理情况,具体退款是否成功需要通过退款查询接口获取结果」。因此 v2 受理成功映射为**结果未知**(保持原路处理中),由恢复任务查询收敛;`refund_status=SUCCESS` 才算明确成功,`REFUNDCLOSE`/`CHANGE` 为明确失败,`PROCESSING` 保持未知。这一点与微信 v3 不同v3 申请接口直接返回 `status`
### 原路执行与恢复
**幂等复用**v2 的 `out_refund_no` 同样取持久化的渠道退款请求号,重试复用、重提更换,与微信 v3、富友、支付宝一致。
- 原路执行消费者在调用前锁定退款,验证原支付单、实际收款商户、渠道流水、可退金额和当前商户退款凭证。新支付按冻结 `merchant_id` 加载商户当前凭证,历史支付按 `payment_config_id`;商户停用不阻断历史校验。
- 以退款 ID/稳定渠道请求号至多提交一次可确认请求。渠道明确成功时保存渠道退款流水并转退款成功;超时、未知、凭证失效、余额不足、拒绝均写安全原因并保持处理中或失败恢复状态,不得标记成功或盲目再次调用。
- 原路失败后若改为客户收款信息退款,必须修改申请材料并走新企业微信审批;审批通过后撤销只记录审批异常,不恢复套餐权益、不取消已提交渠道退款且不自动重提。
### 2.1 本地查询窗口:富友 72 小时、微信 v2 7 天
### 读取与审计
`channel_submitted_at` 之外的第二个时间边界是本地查询窗口:
- 退款列表、详情和导出返回冻结实收金额、方式、申请/渠道状态、失败安全摘要、审批实例和渠道流水,并按既有订单数据范围过滤。审计记录申请、重提、审批终态、权益处理、渠道调用和恢复,但不得记录凭证内容、完整收款文本或商户密钥。
| 服务商 | 本地窗口 | 性质 |
| --- | --- | --- |
| 富友 | 72 小时 | **渠道硬约束**:其退款查询接口只支持 3 日内的退款交易,超期后无法再查询 |
| 微信 v2 | 7 天168 小时) | **本地阈值**:渠道侧无查询时限,此处按本地放弃阈值避免一笔未知结果被无限轮询 |
| 微信 v3 / 支付宝 | 无 | 持续查询直到渠道给出终态 |
**超期后果(终止本次渠道执行并转人工)**:退款申请转 `status=6`(原路退款失败)、渠道退款状态转 `3`(已失败)、`failure_reason=timeout_unknown``anomaly_flag=1` 并写一次异常审计;此后退出轮询,不再查询渠道。
三点必须保持的设计约束:
1. **「未确认」的性质由 `failure_reason` 承载,不由 `status` 承载**。`RefundFailureReasonIsDefinitive(timeout_unknown)` 为 false因此该退款不会被后续佣金回溯判为「明确失败可回溯」`status=6` 只表达该尝试的渠道路径已终止。
2. **不放行自动重提**(保留 `anomaly_flag=1`):本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次提交资金动作,构成重复退款风险。由人工先向渠道核对再决定处置。
3. **账号级重复保护仍然生效**`status=6` 属于活动集合 `{1,5,6}`,因此同一订单仍被 `uk_refund_request_active_order` 阻断新建申请。
窗口起算点为「进入原路退款处理中」的时点。两条保证该点不会被隐式重置:结果未知的回写与窗口超期终止均使用 `UpdateColumns`,不隐式推进 `updated_at`
### 2.2 资金动作至多提交一次:提交认领
`refund.channel.refund.requested` 由 Outbox 至少一次投递(重投、人工重放都可能),因此 `Execute` 必须区分「首次提交」与「重复投递」:
- 提交前先以 `UPDATE ... SET channel_submitted_at = now() WHERE id = ? AND status = 原路处理中 AND channel_submitted_at IS NULL` 认领提交权;
- **认领成功**才调用渠道退款接口;**认领失败**表示该尝试已提交过,本次只调用退款查询并回填,绝不再次提交资金动作;
- 认领与结果回写同以 `status = 原路处理中` 为谓词,因此并发执行也至多有一次认领成功。
这样「至多提交一次可确认的请求」不依赖渠道自身的幂等去重,而是本地持久化事实保证;渠道请求号只是第二道防线。
### 2. 富友退款按官方契约实现
- 申请退款:`POST /commonRefund`。必填 `version``ins_cd``mchnt_cd``term_id``mchnt_order_no``random_str``sign``order_type``refund_order_no``total_amt``refund_amt`;选填 `operator_id``reserved_fy_term_id``reserved_origi_dt``reserved_addn_inf``reserved_refund_desc`。响应 `result_code = 000000` 时取 `refund_id``transaction_id``reserved_refund_amt``reserved_fy_settle_dt`
- 退款查询:`POST /refundQuery`,入参 `refund_order_no``trans_stat` 取值 `SUCCESS``PAYERROR`;仅支持 3 日内查询。
- 全局约束:`mchnt_order_no``refund_order_no` 全局永久唯一;流水号格式为「机构码(4) + 日期(yyyyMMdd) + 随机(8-18 位字母数字)」;重复提交直接拒绝;支持全额退款与多次部分退款。
- 原交易日期:不传 `reserved_origi_dt` 仅支持 30 天内原交易,传了可退 360 天内原交易。因此原路执行 MUST 回传原支付成功时间作为原交易日期,以覆盖 30360 天区间;超出 360 天的原交易在创建/提交预检即禁用原路。
- 回传的原交易标识:`mchnt_order_no` 取原支付单的商户订单号(现实现为 `tb_payment.payment_no``internal/infrastructure/payment/fuiou_scan.go:60``order_type` 取原交易使用的值(现实现唯一可达值为 `WECHAT` 主扫,`pkg/fuiou/scan.go:20`)。本 Change 按现值回传,并把「未来引入其他富友 `order_type` 时必须冻结原交易 `order_type`」记为已知约束,不回填历史。
### 3. 渠道请求号:一次尝试一个稳定值,按富友规则生成
每次审批尝试在提交时生成并持久化 `channel_refund_request_no`,同一尝试的重试复用该值,重提生成新值。生成规则统一采用富友规则(机构码 4 位 + 上海时区 `yyyyMMdd` + 818 位字母数字,总长 ≤ 30该值同时满足微信 v3 与支付宝的字母数字与长度约束,也满足微信 v2 的 32 位上限,因此三类渠道共用一个生成器,无需按渠道分支。
生成时点选在提交而非执行,是因为提交已必须加载冻结实际商户做能力判定,`fy_ins_cd` 在该时点可得;若在执行时才生成,重试路径将无法保证复用同一值。
### 4. 不做外部渠道实测
渠道适配按官方 SDK/接口契约实现(微信 v3 PowerWeChat、微信 v2 自研 XML、富友自研 XML/RSA、支付宝 `smartwalle/alipay/v3`。Agent MUST NOT 调用任何真实支付渠道完成验证;本地验证使用受控适配器桩,覆盖明确成功、渠道明确拒绝、超时、结果未知、凭证失效、余额不足与查询恢复。真实渠道可退款性由维护者后续手工验证。文档只记录「按契约实现,未做外部渠道实测」,不列为阻塞、未完成任务或上线前置。
### 5. 审批尝试模型:新增 `tb_refund_request_attempt``business_id` 取尝试记录主键
沿用已确立的提现审批尝试模式(`tb_commission_withdrawal_request_attempt``migrations/000215`;消费侧 `internal/application/distributionwithdrawal/withdrawal_approval.go:32-74`)。
新表字段:`id`(主键,同时是审批 `business_id`)、`refund_id``attempt_no`、退款方式、退款金额、冻结实收金额、退款原因、客户收款信息、客户凭证键、套餐使用快照、渠道退款请求号、提交人账号、`approval_instance_id`、创建时间。约束:`UNIQUE(refund_id, attempt_no)``approval_instance_id` 部分唯一、`approval_instance_id IS NULL OR > 0`
退款单追加(既有列与语义不变):`latest_attempt_id``latest_approval_instance_id`(仅展示,仿提现)、`method``frozen_actual_received_amount``customer_account_info``channel_refund_status``channel_refund_no``channel_refund_request_no``channel_failure_reason``anomaly_flag``anomaly_reason``approval_instance_id` 保持「首次接入企业微信审批的实例」语义,其既有唯一索引与本地人工终审的 `IS NULL` 保护全部不动。
`business_type` 保持 `refund_approval` 不变,因此 `tb_wecom_approval_scene``chk_wecom_approval_scene_business` 无需扩展,企微场景字段映射也不变。
**业务标识解析(实例优先、双读兼容)**
1. `tb_refund_request_attempt WHERE id = business_id AND approval_instance_id = instance_id` → 新路径;
2. `tb_refund_request WHERE id = business_id AND approval_instance_id = instance_id` → 存量兼容;
3. 两者均不匹配 → 稳定冲突错误,不得回落。
`approval_instance_id` 作为唯一判别式是必需的:尝试记录与退款单来自两个独立序列,必然存在同值,仅凭 `business_id` 无法区分。
**备选方案与取舍**:曾考虑直接为退款单开放多实例(放宽 `uq_approval_instance_business`),被否决——该约束是全部六个审批场景共享的不变量,放宽会波及已上线的核销、提现、分销与资格审批;尝试记录模式改动面局限在退款自身。
### 6. 状态集、活动集合与活动退款唯一索引
`RefundRequest.Status` 保持 intENG-STATE-001沿用既有 14 语义并新增 5、6
| 值 | 常量名 | 名称 | 语义 | 可否重提 | 是否阻断同订单新申请 |
| --- | --- | --- | --- | --- | --- |
| 1 | `RefundStatusPending` | 待审批 | 企微审批在途 | 否 | 是 |
| 2 | `RefundStatusApproved` | 已通过 | 审批通过且退款已完成(原路须渠道明确成功) | 否 | 是 |
| 3 | `RefundStatusRejected` | 已拒绝 | 企微驳回、撤销或删除 | 是 | 否 |
| 4 | `RefundStatusReturned` | 已退回 | 本地退回,仅存量无实例申请可达 | 是 | 否 |
| 5 | `RefundStatusChannelProcessing` | 原路退款处理中 | 企微已通过,渠道结果未确认 | 否 | 是 |
| 6 | `RefundStatusChannelFailed` | 原路退款失败 | 渠道明确失败或超时保留的可恢复失败 | 是 | 是 |
- **活动集合**(同订单互斥与唯一索引谓词):`{1, 5, 6}`,与 PRD 2.3.6 的「审批中、原路退款处理中、原路退款失败」一一对应。
- **可重提集合**`{3, 4, 6}``anomaly_flag = 0`
- 既有状态 2 的显示名保持「已通过」,不做破坏性改名;其新增语义为「退款已完成」。
- `anomaly_flag ∈ {0,1}` 与状态正交(沿用提现异常标记形态),因此「企微通过后撤销」不改变状态,而是写异常标记并禁止重提。
- 契约上原路退款在企微通过后、渠道确认前订单仍为已支付,订单支付状态不再能阻止第二张申请,因此活动退款唯一索引是必需的主守卫:
```sql
CREATE UNIQUE INDEX uk_refund_request_active_order
ON tb_refund_request (order_id)
WHERE deleted_at IS NULL AND status IN (1, 5, 6);
```
迁移 MUST 在创建索引前探测活动集合内的重复 `order_id`,存在时明确失败并中止整次迁移,不自动改写历史数据。活动集合在迁移内写死并加注释说明与常量对应(迁移不引用 Go 常量)。
### 7. 权益时点:企微通过写可靠失效事实,最终一致执行
企微通过事务内只写可回滚的本地事实:退款申请终态、按方式确定的订单支付状态、原钱包退款回款、员工账单冲销、通知/佣金/资产三类 Outbox 与审计。套餐失效、接续下一套餐与停机评估保留既有 Outbox→Worker 路径(`refund.asset.process.requested``handleRefundAssetProcessing`不在事务内执行运营商停机等外部调用ENG-TX-001
**与原设计措辞的偏离**:原 `design.md` 与 spec 写作「企业微信通过事务内完成套餐失效/接续/停机」。照此实现会把运营商停机调用放进资金事务,违反 ENG-TX-001且套餐失效本身已是可靠的最终一致流程无需迁入事务。本 Change 改为契约层的「企微通过即写可靠失效事实」,对外可观察语义不变(企微通过后权益必然失效,渠道后续失败不回滚)。
### 8. 订单支付状态时点按退款方式确定
| 退款方式 | 置订单已退款的时点 | 说明 |
| --- | --- | --- |
| 客户收款信息 | 企微通过 | 退款单同时标记已通过,不等待线下付款 |
| 退回原钱包(资产钱包/代理主钱包) | 企微通过 | 回款在同一事务完成 |
| 原路退款 | 渠道明确成功 | 此前订单保持已支付,由活动退款唯一索引阻止第二张活动申请 |
退款单一律按 PRD 2.3.2:仅渠道明确成功才标记已通过并保存渠道退款流水。既有实现的订单态更新位于企微通过事务(`approval_decision.go:90`),在原路方式下必须改为条件更新并在渠道成功回填路径执行;这也使既有 `changed` 门与重复投递幂等语义需要一并复核。
### 9. 本地人工终审与存量
保留既有 `Approval.LegacyRefundManualEnabled` 开关与入口,不新增任何运行时开关:
- `Approve` 的条件更新补齐 `approval_instance_id IS NULL`ENG-CONC-001 expected-status使其与 `Reject`/`Return` 一致;
- 已关联审批实例的申请在三个入口一律拒绝;
- 存量无实例的待审批退款(测试库 4 条、生产 9 条)保持可终结路径,不强制接入企业微信;
- `Resubmit` 按审批尝试模式重写,不再原地改金额。
零金额语义保留:既有 `refund-approval` 能力要求接受零金额退款通过,且存量零金额待审批申请可经既有补发入口接入企业微信。新申请金额必须为正分,企微路径的批准金额等于申请金额,因此零金额只可能来自存量申请,其方式限客户收款信息,不产生钱包或渠道资金动作。
### 10. 结构化失败分类
`channel_failure_reason` 稳定枚举(`refund_failure_reason` 列,空表示无失败):
| 值 | 名称 | 触发 | 判定为明确失败 |
| --- | --- | --- | --- |
| `channel_rejected` | 渠道明确拒绝 | 渠道返回拒绝或业务错误码 | 是 |
| `credential_invalid` | 凭证失效或缺失 | 商户退款必需凭证不完整或已失效 | 是 |
| `insufficient_balance` | 渠道余额不足 | 渠道返回余额不足 | 是 |
| `timeout_unknown` | 超时或结果未知 | 调用超时、网络错误或渠道仍处理中 | 否,保持可恢复 |
| `approval_rejected` | 企微驳回或关闭 | 企业微信驳回、撤销或删除 | 是 |
| `revoked_after_approved` | 企微通过后撤销 | 企业微信通过后撤销 | 否,转人工异常 |
| `payment_fact_invalid` | 原支付事实不可用 | 原支付单、实际商户、原渠道流水或可退金额校验不通过 | 是 |
`payment_fact_invalid` 是超出既定六项分类的补充项,已裁决保留并与 `credential_invalid` 并列、语义不合并:`credential_invalid` 指该商户退款必需凭证缺失或失效(渠道侧会拒绝),`payment_fact_invalid` 指本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过(本地即不可执行)。两者失败位置与处置不同,合并会丢失该区分;第 7 项据本分类判定可回溯性:标记为「是」的分类表示该退款已明确失败、可进入回溯判定,「否」表示仍在途或需人工处理。
渠道退款状态 `channel_refund_status``0` 未发起或不适用、`1` 处理中、`2` 明确成功、`3` 明确失败。纯钱包与客户收款信息方式固定为 `0`
富友查询窗口超期(结果永久未知)不增设新状态:保留状态 5 并置 `anomaly_flag = 1``anomaly_reason` 记录「富友退款查询窗口已过」,转人工处理。
### 11. 恢复任务形态
新增 `refund:channel:recovery` 定时任务,复刻既有代理在线充值恢复形态(`cmd/worker/main.go:465``@every 1m``Unique``MaxRetry`、批次扫描、不重发资金动作)。任务只按 `channel_refund_request_no` 查询渠道并回填结果:明确成功→保存流水并把退款单置已通过、按方式置订单已退款;明确失败→写失败分类并置状态 6仍未知→保持状态 5。富友仅在提交后 3 日内查询,超期转人工异常。
### 12. 与第 7 项AUG26-012的事实契约
本 Change 不实施佣金回溯明细,只固定并提供:退款完成事件键(保留既有 `refund.commission.deduct.requested` + `refund:<id>` 兼容,需要区分原路成功与客户收款信息完成时新增独立事件);`refund_id``order_id`;成功退款金额(回溯比例分子);冻结实收金额(回溯比例分母);终态时点 `processed_at`;结构化失败分类(区分明确失败可回溯与在途不可回溯);幂等唯一键为「一次退款一次回溯」,不得依赖 `commission_deducted` 布尔(该标记在既有实现中可能被重复置位)。
现有实现的全额回扣(`deductAllCommission` 把原佣金置失效并扣佣金钱包)与 PRD 2.14 的「原佣金不变 + 另建负数明细 + 按比例回溯 + 新增回溯终态」存在差异,本 Change 不改变该行为。
### 13. 渠道退款结果通知:不传递、不接收、只查询
**决定(已裁决)**
- 不新增任何渠道退款结果通知路由;
- 不把退款通知指向既有支付回调 URL既有回调只承载支付业务退款通知会污染其幂等与状态判定
- 微信 v3 退款请求**不传 `notify_url`**(渠道/商户/支付宝特有的通知字段一律不写入退款请求);
- 富友无退款通知能力,结果只经 `/refundQuery` 获取;
- 支付宝退款结果取调用同步响应;
- 退款结果的唯一确认路径为「渠道同步响应 + 主动查询 + 恢复任务」;
- 不修改 `cmd/api/docs.go``cmd/gendocs/main.go`
**理由**新增通知入口需要注册路由并同步两处文档生成装配ENG-ROUTE-001且通知会与既有支付回调的验签、幂等和状态推进逻辑纠缠。退款终态本就要求「仅渠道明确成功才标记成功」而同步响应与主动查询已能给出该结论恢复任务本就定义为「只查询与回填」因此通知路径是冗余的。代价是终态确认最坏延迟到一个恢复周期可接受。
**已知约束(渠道侧未实测的前提)**:不传 `notify_url` 在渠道侧的行为未经真实调用验证。执行时按契约调用;若维护者后续实测发现渠道强制要求该字段,须新增受控通知入口并同步文档生成入口——属独立任务扩张,需另行确认。
## Risks / Trade-offs
- **不传 `notify_url` 的渠道行为未经实测** → 若某渠道在实际环境中强制要求通知字段,退款调用可能被拒绝或结果只能靠查询收敛;缓解:按契约不传该字段,终态由同步响应与恢复查询确认,未实测仅作记录、不作为阻塞。
- **[活动集合含状态 5/6] 与原路退款处理期间订单保持已支付组合后,唯一索引成为唯一守卫** → 索引谓词与 Go 常量漂移会导致并发重复申请;缓解:迁移内写死集合并加注释,测试断言集合与常量一致。
- **富友 30/360 天与 3 日查询窗口是渠道硬约束** → 超期原交易无法原路、超期未知结果永久未知;缓解:创建/提交预检禁用超期原路,超期未知转人工异常,绝不重发。
- **微信 v2 商户退款能力默认不可用** → 存量 v2 商户原路退款被禁用可能被误判为功能缺失缓解在错误文案中说明凭证不完整spec 已明确能力只由凭证完整性决定。
- **尝试模式切换后存量审批终态可能失配** → 业务标识解析只凭 `business_id` 会因两个序列同值而误判;缓解:解析必须同时匹配 `approval_instance_id`,双读顺序与冲突失败已写入 spec。
- **订单支付状态在原路方式下延后置位** → 期间订单显示已支付但企微已通过、套餐已失效;缓解:退款单状态 5 与渠道退款状态在列表/详情可见spec 已把该期间的可观察行为写死。
- **不做外部渠道实测** → 契实现正确性无渠道证据;缓解:按契约实现并记录,实际可退款性由维护者手工验证,不阻塞实施、验证或归档。
## Migration Plan
新增成对迁移和活动退款约束,先部署兼容读写与恢复消费者,再关闭人工审批入口;隔离库验证方式矩阵、重复回调、渠道未知、失败重提、权益时点和 up/down/up。
1. 新增成对迁移(编号按实施开始时 `migrations/` 目录最大编号顺延,不预占):`tb_refund_request_attempt` 表、退款单列扩展、活动退款部分唯一索引、失败分类与渠道状态 CHECK。既有迁移与既有 `approval_instance_id` 约束不改;不把任何既有实例的 `business_id` 回填改写为尝试记录标识。
2. 迁移前探测活动集合内重复 `order_id`,存在则明确失败中止。
3. 先部署兼容读写的 API/Worker尝试模式写入 + 业务标识双读 + 恢复任务注册),再实现渠道退款调用与回填;本地人工终审入口保持开启,行为仅收紧为「已关联实例一律拒绝」。
4. down 迁移须在存在尝试记录或渠道退款事实时拒绝执行,避免丢失不可重建的资金事实。
5. 验证面按 ENG-TEST-001`junhong_cmp_test` PostgreSQL + Redis DB 6本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh` 完成 up/down/up只创建与删除本 Change 自有 fixture禁止重置整库渠道适配使用受控桩不调用真实渠道。

View File

@@ -4,14 +4,16 @@
## Why
现有退款以本地审批状态处理,不能按来源实际支付事实决定退款方式、冻结实收金额,或在企业微信通过后用原实际收款商户可靠执行原路退款。
现有退款以本地审批状态处理,不能按来源实际支付事实决定退款方式、冻结实收金额,或在企业微信通过后用原实际收款商户可靠执行原路退款;退款单也无法按「每次提交持有独立审批实例」重提,且系统没有任何渠道原路退款调用
## What Changes
- 退款申请冻结来源订单、权威实收金额、唯一套餐使用情况、退款金额/原因、方式和当次审批材料;实收金额不可由提交人修改。
- 退款申请冻结来源订单、权威实收金额、唯一套餐使用情况、退款金额/原因、方式和当次审批材料;实收金额由系统从原成功支付记录或订单实际收款派生,提交人不可填写或修改。
- 建立线上支付、资产钱包、代理主钱包和后台线下订单的退款方式矩阵,并在创建、提交和执行前重复校验。
- 企业微信是唯一审批终审:通过即按既有规则失效套餐;客户收款信息退款即完成,原路退款须待渠道明确成功才完成
- 原路退款固定使用原支付单实际商户和渠道流水;超时/未知/失败保留可恢复状态,不重复退款;未成功申请可按规则修改并新建审批实例重提
- 企业微信是唯一审批终审;每次提交或重提新增一条不可变审批尝试记录与一个新的企业微信审批实例,历史材料不被覆盖;本地人工终审只保留既有存量终结路径,不新增任何运行时开关
- 企业微信通过即写可靠失效事实,使关联套餐失效、接续下一套餐并评估停机,由既有可靠机制最终一致执行;客户收款信息退款与退回原钱包在企微通过时完成,原路退款须待渠道明确成功才完成,且仅此时置订单已退款
- 按官方契约新增微信直连、富友和支付宝的原路退款适配,固定使用原支付单实际商户与其当前凭证;渠道能力只由服务商类型与退款必需凭证完整性决定,不提供人工开关;超时/未知/失败保留可恢复状态,不重复退款。
- 一笔订单最多一张最终成功退款、同时至多一张活动退款申请,由活动退款唯一约束与条件状态更新共同保证。
## Capabilities
@@ -21,8 +23,10 @@
### Modified Capabilities
- `order-refund-exchange`: 退款申请、状态机、方式矩阵套餐联动。
- `order-refund-exchange`: 退款申请、状态机、方式矩阵、原路渠道退款执行与套餐联动。
## Impact
影响退款模型/接口、企业微信审批、支付商户与渠道退款、资产/代理钱包、员工账单和佣金回溯;需新增成对迁移并淘汰本地人工终审入口
影响退款模型/接口、企业微信审批、支付商户与渠道退款(微信直连、富友、支付宝)、资产/代理钱包、员工账单和佣金回溯;需新增成对迁移。
渠道适配按官方 SDK/接口契约实现,本 Change 不调用任何真实支付渠道验证:本地验证使用受控适配器桩,「未做外部渠道实测」只作记录,不作为阻塞、未完成任务或上线前置,真实渠道可退款性由维护者后续手工验证。

View File

@@ -0,0 +1,92 @@
## MODIFIED Requirements
### Requirement: 商户与微信授权配置管理
系统 SHALL 将实际收款商户与微信授权配置分离。一个商户 MUST 仅对应 `wechat``alipay` 一种支付方式,并保存名称、支付方式、服务商类型、商户号或应用标识、敏感凭证、状态和备注;微信直连与富友均为微信支付商户。创建或更新商户时 `credentials` MUST 为扁平 JSON 对象,必填键由支付方式与服务商类型组合确定:`wechat/wechat``wx_mch_id``wx_api_v3_key``wx_cert_content``wx_key_content``wx_serial_no``wx_notify_url``wechat/wechat_v2``wx_mch_id``wx_api_v2_key``wx_notify_url`,并可选 `wx_client_cert_content``wx_client_key_content`API 客户端证书与私钥,为 v2 原路退款的双向证书所需);`wechat/fuiou``fy_mchnt_cd``fy_ins_cd``fy_term_id``fy_private_key``fy_public_key``fy_api_url``fy_notify_url``alipay/alipay``ali_app_id``ali_private_key``ali_public_key``ali_notify_url``ali_return_url`。凭证值 MUST 为字符串,仅可选的 `ali_production`(布尔)与 `ali_pay_expire_minutes`(整数)例外;`merchant_identity` MUST 分别等于 `wx_mch_id``fy_mchnt_cd``ali_app_id`。平台最多存在一个启用的微信授权配置,该配置保存 C 端公众号 H5/JSSDK、小程序登录所需参数C 端微信登录、OpenID 和微信支付 AppID MUST 只读取该配置。
超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。仅上述角色的专用管理列表和详情响应可返回完整凭证;日志、审计快照、错误、导出、支付快照和其他业务响应 MUST NOT 保存或返回敏感凭证。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和原路退款仍使用该商户当前凭证;原路退款的实际渠道调用由 `order-refund-exchange` 能力按该商户当前凭证执行,本能力 MUST NOT 保存渠道退款流水或推进退款状态。可退性只依据服务商类型与该服务商类型退款必需凭证的完整性判定,本能力 MUST NOT 提供人工退款能力开关。`wechat/wechat_v2``wx_client_cert_content``wx_client_key_content` 为可选键:不填不影响该商户的支付、查单与回调,只使原路退款按其凭证完整性判定为不可用。
#### Scenario: 受引用商户停用
- **WHEN** 管理员停用已被支付单命中的商户
- **THEN** 新支付单不再选择该商户,已命中支付单的回调、查询和原路退款仍按该商户处理
#### Scenario: 非管理角色读取配置
- **WHEN** 不具备超级管理员或平台用户身份的账号请求商户或微信授权配置
- **THEN** 系统拒绝访问且不返回任何凭证或身份字段
#### Scenario: 凭证轮换后处理历史支付
- **GIVEN** 已被支付单引用的商户或当前微信授权配置完成凭证更新
- **WHEN** 更新提交后创建支付、处理回调、查单或原路退款
- **THEN** 系统只使用更新后的当前有效凭证,不得继续使用更新前的缓存凭证
#### Scenario: 凭证键不完整或身份不一致被拒
- **WHEN** 管理员创建或更新商户时提交缺少该支付方式与服务商类型必填键的 `credentials`,或 `merchant_identity` 与凭证中的商户号或应用标识不一致
- **THEN** 系统拒绝写入并返回参数错误,不创建或修改商户记录
#### Scenario: 商户凭证不足以执行退款
- **GIVEN** 已被支付单命中的商户缺少其服务商类型执行原路退款所需的凭证
- **WHEN** 系统判定该支付单原路退款的可退性
- **THEN** 判定结果为不可用且只允许客户收款信息退款,系统不因该结果新增商户凭证键或人工退款开关
### Requirement: 新支付商户快照与历史兼容
C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照、轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于原路退款路径的可退性判定,不提供人工开关;实际渠道退款调用与退款终态由 `order-refund-exchange` 能力实现,本能力 MUST NOT 发起渠道退款请求。
上线迁移在存在唯一当前生效综合支付配置时复制完整凭证:具备完整凭证的微信/支付宝方式创建商户和各自单成员启用池,微信授权字段完整时创建全局微信授权配置;不完整的支付方式不建池,授权字段不完整时不建授权配置并使相关 C 端微信功能明确失败。没有当前生效综合支付配置时迁移仅创建 Schema 和管理入口,不创建商户、商户池或微信授权配置数据;新订单按暂无可用商户失败,微信授权相关功能明确返回未配置。存在多条当前生效综合支付配置时迁移 MUST 失败。迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker三类后续新支付 MUST 立即只走商户池并冻结路由无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空仅表示历史订单,继续按 `payment_config_id` 服务历史回调、查询和原路退款,直到独立 Change 按数据留存期删除旧读取路径。本 Change MUST NOT 自动删除旧配置或旧读取路径。
#### Scenario: 新支付冻结实际商户
- **WHEN** 客户以微信或支付宝创建覆盖范围内的新线上支付单
- **THEN** 系统选择并冻结一个实际商户和商户池路由快照,并使用该商户的服务商凭证发起支付
#### Scenario: 迁移后的新支付只走商户池
- **GIVEN** 迁移完成且已部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker
- **WHEN** 客户创建覆盖范围内的后续新线上支付
- **THEN** 系统立即经启用商户池选择并冻结实际商户;无可用池、成员或池已停用时明确失败,不得走旧综合支付配置创建
#### Scenario: 历史支付保留旧读取路径
- **GIVEN** 支付单 merchant ID 为空
- **WHEN** 该支付经过回调、查单或原路退款路径
- **THEN** 系统仅按其 `payment_config_id` 处理,不因当前商户池推断或改写其商户,直到独立 Change 按数据留存期删除旧读取路径
#### Scenario: 旧支付单回调
- **WHEN** 商户池切换后收到未带新商户快照的历史支付单回调
- **THEN** 系统按既有综合支付配置兼容处理该历史单,不将其改挂到任何新商户
#### Scenario: 微信授权配置迁移缺失
- **GIVEN** 当前生效综合支付配置的微信授权字段不完整
- **WHEN** 执行商户池配置迁移后访问 C 端微信登录、OpenID、JSSDK 或微信支付 AppID 功能
- **THEN** 系统明确返回微信授权未配置,不得回退旧综合支付配置
#### Scenario: 多个当前生效综合支付配置
- **GIVEN** 存在多条当前生效综合支付配置
- **WHEN** 执行商户池配置迁移
- **THEN** 迁移失败且不选择任一配置作为复制来源
#### Scenario: 富友双读兼容基线
- **GIVEN** 富友 `CommonQuery` 未经第三方实测
- **WHEN** 系统以商户当前凭证完成富友支付的本地双读配置来源和商户加载接线,且不改变现有 `CommonQuery` 请求格式、签名算法、验签、状态映射或恢复语义
- **THEN** 系统保留该兼容基线;富友原路退款由 `order-refund-exchange` 能力按 `/commonRefund``/refundQuery` 契约单独实现,未第三方实测可以记录,但不得作为实施、验证或归档阻塞
#### Scenario: 维护者指定测试环境的配置迁移验证
- **GIVEN** 维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 与当前 Change fixture
- **WHEN** 本地工作区以明确 `DB_*` 完成 migration up/down/up 和数据行为验证后提交推送 Iteration/8-11
- **THEN** Gitea 只构建/部署 `cmp-test` 测试镜像并检查迁移版本;测试日志保存在 `/opt/junhong_cmp/logs`,不得自动执行迁移或重置整库
#### Scenario: 历史支付路径保留
- **GIVEN** 存在 merchant ID 为空的历史支付
- **WHEN** 商户池功能已上线且独立 Change 尚未按数据留存期删除旧读取路径
- **THEN** 系统仍按该支付的 `payment_config_id` 处理回调、查询和退款,不自动删除旧配置或将其改挂商户

View File

@@ -1,32 +1,190 @@
## MODIFIED Requirements
### Requirement: 订单、退款与换货状态门禁
系统 SHALL 仅允许待支付订单取消;退款申请按待审批、已通过、已拒绝、已退回、原路退款处理中、原路退款失败流转,仅已拒绝、已退回和原路退款失败申请可修改并重新提交;换货按待填写信息、待发货、已发货、已完成或已取消流转,并拒绝与当前状态或流程类型不匹配的操作。处于待审批、原路退款处理中或原路退款失败的退款申请 MUST 阻止同一订单创建新的退款申请,且 MUST NOT 被再次推进为新的审批实例或第二次渠道退款。
#### Scenario: 重复推进终态
- **GIVEN** 退款或换货已进入不允许当前操作的状态
- **WHEN** 再次审批、发货、完成、取消或重新提交
- **THEN** 系统返回状态冲突且不重复改变资产、余额或业务状态
#### Scenario: 原路退款失败后重提
- **WHEN** 退款申请处于原路退款失败状态且调用方在其数据范围内修改材料后重新提交
- **THEN** 系统新增一条审批尝试记录与一个新的企业微信审批实例并使申请回到待审批,历史尝试与已提交的渠道退款不被覆盖
#### Scenario: 企业微信通过后撤销的申请被再次推进
- **GIVEN** 退款申请已记录企业微信通过后撤销的审批异常
- **WHEN** 调用者再次提交或重提该申请
- **THEN** 系统返回状态冲突,不恢复套餐权益、不取消已提交的渠道退款且不自动重提
## ADDED Requirements
### Requirement: 退款实收金额与方式矩阵
系统 SHALL 从来源订单或原成功支付记录带出并冻结权威实收金额,提交人不得填写或修改;无法确定时拒绝申请。退款金额和企业微信授权金额不得超过该冻结额,且一笔订单最多一张最终成功退款申请,成功后不得再申请。
线上微信/支付宝套餐订单仅可选原路退款或客户收款信息退款;资产钱包支付仅自动退回原资产钱包代理预存款/主钱包支付仅自动退回原代理钱包;后台线下/员工代收套餐订单仅可选客户收款信息退款。代理充值预存款业务单不在本期退款范围。客户收款信息退款必须包含客户收款信息自由文本和客户凭证附件,且不得复用公司收款方式字典
系统 SHALL 从来源订单或原成功支付记录派生并冻结权威实收金额,提交人不得填写或修改;无法确定权威实收金额或该金额非正时拒绝创建申请。冻结来源为:线上支付取该订单原成功支付记录金额;资产钱包代理主钱包和后台线下套餐订单取订单实际收款或实际扣款金额。退款金额 MUST 为正分且不得超过冻结实收金额,系统 MUST 在申请创建、审批提交和企业微信通过后的执行前重复校验该上限。一笔订单最多一张最终成功退款申请,成功后该订单不得再申请退款。代理充值预存款业务单不在本期退款范围
可选方式按来源订单的实际支付方式确定,不匹配的方式返回「该订单不支持此退款方式」:线上微信、支付宝或富友支付仅可选原路退款或客户收款信息退款;资产钱包支付仅退回原资产钱包;代理预存款或主钱包支付仅退回原代理钱包;后台线下和员工代收套餐订单仅可选客户收款信息退款。系统 MUST NOT 为本能力新增人工退款方式开关。客户收款信息退款必须同时提供客户收款信息自由文本和至少一个客户凭证附件,且不得复用公司收款方式字典。
线上订单原路可退条件按渠道契约在创建、提交和执行前重复预检:冻结实际商户的退款必需凭证不完整、服务商类型不具备退款能力、或原交易超出渠道可退时限时禁用原路并说明原因,只允许客户收款信息退款。富友原交易的原始日期必须回传渠道;未回传时仅支持 30 天内原交易,回传后可退 360 天内原交易,超出该范围的申请不得选择原路。
#### Scenario: 无权威实收金额
- **WHEN** 来源订单无法取得权威实收金额
- **WHEN** 来源订单无法取得权威实收金额,或取得的金额非正
- **THEN** 系统拒绝创建退款申请,不允许提交人以自填金额替代
#### Scenario: 提交人试图修改冻结实收金额
- **WHEN** 创建或重提请求携带与派生值不同的实收金额
- **THEN** 系统仍使用派生值作为冻结实收金额,不接受请求值
#### Scenario: 钱包订单申请退款
- **WHEN** 已支付套餐订单的实际支付方式为资产钱包或代理主钱包
- **THEN** 系统只提供退回对应原钱包方式,不展示原路或客户收款信息退款
### Requirement: 企业微信审批和未成功重提
退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地不得人工通过、拒绝或退回。
#### Scenario: 原路凭证不完整
同一订单同时至多存在一张审批中、原路处理中或原路失败申请。企业微信驳回、申请关闭或渠道明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次必须新建审批实例及快照。提交失败或审批结果未知保持在途,使用既有查询/恢复闭环,不得另建或重提。企业微信通过后撤销不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。
- **GIVEN** 订单为线上支付且其冻结实际商户缺少该服务商类型退款必需凭证
- **WHEN** 调用者申请退款
- **THEN** 系统禁用原路退款并返回原因,只允许客户收款信息退款
#### Scenario: 富友原交易超出可退时限
- **WHEN** 富友原交易的原始支付时间早于可退时限
- **THEN** 系统禁用原路退款并说明原因,只允许客户收款信息退款
### Requirement: 企业微信唯一终审与审批尝试重提
退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地 MUST NOT 人工通过、拒绝或退回已关联审批实例的申请。每次创建或重提 MUST 新增一条不可变的审批尝试记录,并以该尝试记录作为通用审批业务标识;同一申请每次提交各自持有独立审批实例,历史尝试材料与审批结果不被覆盖。退款单 SHALL 仅保存最新尝试与最新审批实例引用用于展示。
同一订单同时至多存在一张待审批、原路退款处理中或原路退款失败的退款申请。企业微信驳回、企业微信关闭、或原路退款明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次重提必须新建审批实例及快照。企业微信提交失败或审批结果未知时申请保持在途,使用既有查询与恢复闭环,不得另建或重提。企业微信通过后撤销时不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。
审批终态 MUST 以审批实例标识判别业务归属:先按审批尝试记录标识与审批实例匹配,未命中时按退款申请标识与审批实例匹配以兼容尚未切换到尝试模式的存量申请,两者均不匹配时返回稳定冲突错误,不得回落到任一候选业务单。
#### Scenario: 审批通过前结果未知
- **WHEN** 企业微信提交成功性或最终结果暂时未知
- **THEN** 申请保持在途且订单不得创建第二张活动申请,系统通过既有恢复机制确认结果
### Requirement: 原路退款执行与权益时点
对线上订单,系统在创建、提交及企业微信通过后的执行前均 SHALL 校验原支付单、实际收款商户、渠道流水、可退金额及商户退款能力/凭证;商户停用不得阻断历史单校验。条件不满足时禁用原路并说明原因,只允许客户收款信息退款。
#### Scenario: 未成功申请重提新建实例
企业微信最终通过即按既有规则使关联套餐失效、接续下一套餐并评估停机;客户收款信息退款同时标记退款成功,不等待线下付款。原路退款必须以冻结金额调用原实际收款商户;仅渠道明确成功后标记成功并保存渠道退款流水。超时、未知、凭证失效、余额不足或渠道拒绝时保留处理中/失败与安全原因,不标记成功或重复调用;需改客户收款信息退款时必须修改后重新审批。
- **GIVEN** 退款申请处于已拒绝、已退回或原路退款失败状态且无审批异常
- **WHEN** 调用者修改材料后重新提交
- **THEN** 系统在同一事务新增审批尝试记录并创建新的企业微信审批实例,原尝试记录与历史审批结果保持不变
#### Scenario: 存量申请审批终态仍被消费
- **GIVEN** 退款申请由本能力上线前创建,其审批实例的业务标识指向退款申请本身
- **WHEN** 该审批实例到达通过或关闭终态
- **THEN** 系统按审批实例标识匹配到该退款申请并幂等推进,不按尝试模式误判为不存在
#### Scenario: 已关联实例的本地人工终审
- **GIVEN** 退款申请已关联审批实例
- **WHEN** 后台账号调用本地通过、拒绝或退回入口
- **THEN** 系统拒绝该操作且不改变退款申请与订单状态
#### Scenario: 存量无实例待审批申请仍可终结
- **GIVEN** 退款申请未关联任何审批实例且处于待审批状态
- **WHEN** 后台账号在既有开关开放时调用本地通过、拒绝或退回入口
- **THEN** 系统按既有语义终结该申请,不要求该申请先接入企业微信审批
### Requirement: 原路退款渠道能力与执行
对线上订单,系统 SHALL 在创建、提交及企业微信通过后的执行前校验原支付单、实际收款商户、原渠道交易流水、可退金额和该商户退款必需凭证;商户停用不得阻断历史单校验。新支付按冻结实际商户标识加载该商户当前有效凭证,历史支付按原支付配置标识读取,不得按当前启用商户池推断历史商户。退款能力判定 MUST 只依据服务商类型与该服务商类型退款必需凭证的完整性,不提供人工退款能力开关:微信直连、富友和支付宝商户具备原路退款能力;微信 v2 商户的退款接口为双向证书接口,故其退款必需凭证含 API 客户端证书,缺少该证书时按其凭证完整性判定为不可用并只允许客户收款信息退款,补录证书后即可用。系统 MUST NOT 提供渠道降级开关。不可用时返回的错误说明 MUST 指明缺少哪一类凭证,避免被误判为功能未实现。
微信 v2 退款申请接口的返回仅代表渠道受理情况MUST NOT 据此判定退款成功:受理成功时退款申请 MUST 保持原路退款处理中并等待退款查询确认,退款查询返回明确成功时才可标记已通过并保存渠道退款流水。
原路退款 MUST 使用冻结金额并调用原实际收款商户,实际收款商户不得改选。每次审批尝试 MUST 持久化一个稳定的记录级渠道退款请求号,提交时按冻结服务商类型的契约规则生成,同一尝试重试复用该值、重提生成新值;系统 MUST 以该请求号作为渠道幂等标识,至多提交一次可确认的退款请求。
仅渠道明确成功留存渠道退款流水后退款单才标记已通过;超时、结果未知、渠道明确拒绝、凭证失效、渠道余额不足或本地原支付事实不可用时不标记成功,写入结构化失败分类并保持原路退款处理中或失败状态。重申资金动作至多提交一次:系统 MUST 在提交前持久化认领该次渠道提交,只有认领成功的执行才可调用渠道退款接口;同一次尝试的重复投递或人工重放 MUST 只查询渠道结果并回填MUST NOT 再次提交资金动作。
恢复任务 MUST 只查询渠道并回填结果,不得重复发起资金动作。本地查询窗口超期且结果仍未知时,系统 MUST 终止本次渠道执行并转人工核对:退款申请 SHALL 转为原路退款失败、渠道退款状态 SHALL 转为已失败、失败分类 SHALL 写为超时或结果未知,且此后 MUST NOT 再查询该渠道。超期 MUST NOT 标记为渠道明确成功。窗口超期后系统 MUST NOT 自动重提该申请(本次渠道请求可能已被受理,重提会以新请求号再次提交资金动作),也 MUST NOT 解除同一订单的活动退款互斥。超期边界为:富友因其契约只支持 3 日内查询而在 72 小时后超期;微信 v2 的受理响应不含退款状态且渠道无查询时限,其本地确认上限为 7 天。需改为客户收款信息退款时必须修改申请材料并重新走企业微信审批。
#### Scenario: 原路渠道调用未知
- **WHEN** 企业微信已通过的原路退款调用超时且无法确认渠道结果
- **THEN** 退款保持原路处理中或失败恢复状态,套餐权益不恢复,系统不得再次盲目提交退款
- **THEN** 退款保持原路退款处理中,套餐权益不恢复,系统不得再次提交退款,仅由查询恢复回填结果
#### Scenario: 渠道明确成功
- **WHEN** 渠道返回明确退款成功
- **THEN** 系统保存渠道退款流水号与渠道退款金额快照并把退款申请标记为已通过,同时按方式置订单已退款
#### Scenario: 富友退款查询窗口已过且结果未知
- **GIVEN** 富友原路退款已提交但结果未确认,且已超出其退款查询窗口
- **WHEN** 恢复任务再次处理该退款
- **THEN** 系统不再发起退款调用,保留办理中并记录审批异常供人工核对
#### Scenario: 商户已停用的历史原路退款
- **GIVEN** 原支付单命中的实际商户已被停用
- **WHEN** 该订单执行原路退款
- **THEN** 系统仍按该商户当前凭证调用渠道,商户停用本身不阻断调用
#### Scenario: 微信 v2 补录证书后原路可用
- **GIVEN** 订单原支付单命中的商户服务商类型为微信 v2且其凭证补录了 API 客户端证书与私钥
- **WHEN** 调用者申请原路退款
- **THEN** 系统判定该商户原路退款可用,并以商户退款单号作为渠道路径的幂等标识发起退款
#### Scenario: 微信 v2 退款申请仅代表渠道受理
- **GIVEN** 微信 v2 商户凭证完整且企业微信已通过原路退款
- **WHEN** 渠道退款申请接口返回受理成功但未返回退款状态
- **THEN** 系统保持退款申请为原路退款处理中且订单保持已支付,只由退款查询确认最终结果
#### Scenario: 微信 v2 商户缺少客户端证书
- **GIVEN** 订单原支付单命中的商户服务商类型为微信 v2且其凭证只有 APIv2 密钥
- **WHEN** 调用者申请退款
- **THEN** 系统禁用原路退款并说明缺少 API 客户端证书,只允许客户收款信息退款,且不阻断该商户的支付与查单
#### Scenario: 渠道退款结果不经通知接收
- **WHEN** 系统发起原路退款请求
- **THEN** 系统不传递任何渠道退款结果通知地址、不新增退款通知路由,退款终态只由渠道同步响应、主动查询与恢复任务确认
### Requirement: 退款权益与订单状态时点
企业微信最终通过时,系统 SHALL 在同一事务内写退款申请终态、按方式确定的订单支付状态、原钱包退款回款、员工代收账单冲销和可靠套餐失效事实;企业微信通过后的撤销不回滚上述任何已成立事实。套餐失效、接续下一套餐和停机评估 MUST 由既有可靠机制最终一致执行,不得在资金事务内执行运营商停机等不可回滚的外部调用。
订单支付状态时点按退款方式确定:客户收款信息退款与退回原钱包在企微通过时置为已退款;原路退款仅在渠道明确成功时置为已退款,此前订单保持已支付。原路退款在企微通过后、渠道结果确认前的期间内,同一订单 MUST 由活动退款唯一约束阻止产生第二张活动退款申请。重复或乱序的企业微信终态不得再次失效套餐、再次回款或启动第二次渠道退款。
#### Scenario: 企微通过后的权益时点
- **WHEN** 退款申请到达企业微信最终通过
- **THEN** 系统在同一事务写退款终态与可靠失效事实,套餐失效、接续与停机由既有可靠机制随后完成
#### Scenario: 原路退款在渠道确认前置订单状态
- **GIVEN** 退款方式为原路退款且企业微信已通过
- **WHEN** 渠道结果尚未确认
- **THEN** 订单保持已支付、套餐权益已按企微通过失效,且该订单不得创建新的退款申请
#### Scenario: 渠道最终失败不回滚权益
- **WHEN** 原路退款渠道明确失败
- **THEN** 退款申请标记原路退款失败且不标记已通过,已失效套餐权益不恢复,订单保持已支付
### Requirement: 退款终态事实与失败分类
退款申请 SHALL 保存结构化失败分类、渠道退款状态、渠道退款流水与渠道退款请求号,并在列表、详情和导出中返回冻结实收金额、方式、申请状态、渠道退款状态、失败安全摘要、审批尝试历史和渠道流水,按既有订单数据范围过滤。失败分类 MUST 为稳定枚举,至少覆盖:渠道明确拒绝、渠道凭证失效、渠道余额不足、超时或结果未知、企业微信驳回或关闭、企业微信通过后撤销,以及本地原支付事实不可用。渠道凭证失效与本地原支付事实不可用 MUST 为两个并列分类、语义不得合并:前者指该商户退款必需凭证缺失或失效,后者指本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过。每个分类 MUST 显式标记其是否属于「明确失败」:明确失败表示退款已终结且可进入后续回溯判定,非明确失败表示仍在途或需人工处理。审计 SHALL 记录申请、重提、审批终态、权益处理、渠道调用与恢复,且不得记录凭证内容、完整收款文本或商户密钥。
为后续佣金回溯能力提供稳定事实,退款终态 SHALL 可按退款单与订单定位,并提供:成功退款金额、冻结实收金额、终态时点、结构化失败分类及其明确失败标记,以及区分原路成功与客户收款信息完成的完成事件键。后续回溯判定 MUST 依据上述分类标记而非猜测文本:标记为明确失败的退款才可进入回溯判定,标记为非明确失败的退款(超时或结果未知、企业微信通过后撤销)保持在途或转人工,不得回溯。系统 MUST 保留既有退款佣金回扣事件键的兼容语义;佣金回溯的幂等键为一次退款一次回溯,不依赖退款单上的佣金回扣标记。
#### Scenario: 渠道失败分类可查询
- **WHEN** 原路退款因渠道余额不足失败
- **THEN** 退款详情返回该失败分类与安全摘要,且不返回任何凭证内容或完整收款文本
#### Scenario: 审计不含敏感内容
- **WHEN** 渠道退款调用或恢复完成后写入审计
- **THEN** 审计只记录业务标识、金额、状态与脱敏摘要,不记录商户密钥或凭证原文

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`,异步入口覆盖已双向一致。