## Context 现状(开工核对确认,均为可达代码事实): - 退款创建 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` 时可签名退款请求 | **结论(已裁决)**:微信支付 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 支付与查单:实例不装载证书,退款能力随之判定为不可用。 - 不提供人工开关;能力仍只由服务商类型与凭证完整性决定。 **渠道结果语义**:渠道文档规定 v2 退款申请接口「返回仅代表业务的受理情况,具体退款是否成功需要通过退款查询接口获取结果」。因此 v2 受理成功映射为**结果未知**(保持原路处理中),由恢复任务查询收敛;`refund_status=SUCCESS` 才算明确成功,`REFUNDCLOSE`/`CHANGE` 为明确失败,`PROCESSING` 保持未知。这一点与微信 v3 不同(v3 申请接口直接返回 `status`)。 **幂等复用**:v2 的 `out_refund_no` 同样取持久化的渠道退款请求号,重试复用、重提更换,与微信 v3、富友、支付宝一致。 ### 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 回传原支付成功时间作为原交易日期,以覆盖 30–360 天区间;超出 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` + 8–18 位字母数字,总长 ≤ 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` 保持 int(ENG-STATE-001),沿用既有 1–4 语义并新增 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:` 兼容,需要区分原路成功与客户收款信息完成时新增独立事件);`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 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,禁止重置整库;渠道适配使用受控桩,不调用真实渠道。