Files
break 09abee9778 docs(归档): 归档退款方式与原路退款变更并同步主规格
- 将 add-refund-methods-and-original-route-refunds 归档为
  2026-09-14-add-refund-methods-and-original-route-refunds。
- 合并两份 delta 到主规格:
  * order-refund-exchange:改写「订单、退款与换货状态门禁」,新增「退款实收金额与方式矩阵」
    「企业微信唯一终审与审批尝试重提」「原路退款渠道能力与执行」「退款权益与订单状态时点」
    「退款终态事实与失败分类」五项行为要求。
  * merchant-payment-routing:改写「商户与微信授权配置管理」与「新支付商户快照与历史兼容」
    (删除「不得新增渠道退款能力」与「不新增富友退款」,改由退款能力按商户凭证执行;
    微信 v2 客户端证书改为可选凭证键)。
- 同步上下文健康检查证据链与入口矩阵:为新要求登记证据行,并把退款创建、重提、
  企微审批回调、退款详情与 refund:channel:recovery 任务与对应要求双向关联。
2026-09-14 12:00:35 +08:00

25 KiB
Raw Blame History

Context

现状(开工核对确认,均为可达代码事实):

  • 退款创建 100% 经 internal/application/refundapproval/creation.go 在同一事务创建退款单与审批实例,business_id 取退款单 IDtb_approval_instanceuq_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: trueApprove 的条件更新缺少 approval_instance_id IS NULLReject/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:1125revoked_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_contentwx_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_contentapiclient_cert.pem)与 wx_client_key_contentapiclient_key.pem),二者对支付/查单非必填,只用于退款能力判定与退款调用;不破坏既有 v2 商户的支付与回调。
  • 判定规则v2 商户凭证含 APIv2 Key 含上述两项证书内容时原路退款可用;缺证书时按凭证不完整判定为不可用,仅客户收款信息退款,并返回说明「缺少 API 客户端证书」。
  • 迁移 000219 在历史 tb_wechat_config 上补齐同名列(存量行为空,补齐后生效);商户池路径的商户凭证存于 tb_payment_merchant.credentialsJSONB无需改表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_unknownanomaly_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。必填 versionins_cdmchnt_cdterm_idmchnt_order_norandom_strsignorder_typerefund_order_nototal_amtrefund_amt;选填 operator_idreserved_fy_term_idreserved_origi_dtreserved_addn_infreserved_refund_desc。响应 result_code = 000000 时取 refund_idtransaction_idreserved_refund_amtreserved_fy_settle_dt
  • 退款查询:POST /refundQuery,入参 refund_order_notrans_stat 取值 SUCCESSPAYERROR;仅支持 3 日内查询。
  • 全局约束:mchnt_order_norefund_order_no 全局永久唯一;流水号格式为「机构码(4) + 日期(yyyyMMdd) + 随机(8-18 位字母数字)」;重复提交直接拒绝;支持全额退款与多次部分退款。
  • 原交易日期:不传 reserved_origi_dt 仅支持 30 天内原交易,传了可退 360 天内原交易。因此原路执行 MUST 回传原支付成功时间作为原交易日期,以覆盖 30360 天区间;超出 360 天的原交易在创建/提交预检即禁用原路。
  • 回传的原交易标识:mchnt_order_no 取原支付单的商户订单号(现实现为 tb_payment.payment_nointernal/infrastructure/payment/fuiou_scan.go:60order_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_attemptbusiness_id 取尝试记录主键

沿用已确立的提现审批尝试模式(tb_commission_withdrawal_request_attemptmigrations/000215;消费侧 internal/application/distributionwithdrawal/withdrawal_approval.go:32-74)。

新表字段:id(主键,同时是审批 business_id)、refund_idattempt_no、退款方式、退款金额、冻结实收金额、退款原因、客户收款信息、客户凭证键、套餐使用快照、渠道退款请求号、提交人账号、approval_instance_id、创建时间。约束:UNIQUE(refund_id, attempt_no)approval_instance_id 部分唯一、approval_instance_id IS NULL OR > 0

退款单追加(既有列与语义不变):latest_attempt_idlatest_approval_instance_id(仅展示,仿提现)、methodfrozen_actual_received_amountcustomer_account_infochannel_refund_statuschannel_refund_nochannel_refund_request_nochannel_failure_reasonanomaly_flaganomaly_reasonapproval_instance_id 保持「首次接入企业微信审批的实例」语义,其既有唯一索引与本地人工终审的 IS NULL 保护全部不动。

business_type 保持 refund_approval 不变,因此 tb_wecom_approval_scenechk_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} 与状态正交(沿用提现异常标记形态),因此「企微通过后撤销」不改变状态,而是写异常标记并禁止重提。
  • 契约上原路退款在企微通过后、渠道确认前订单仍为已支付,订单支付状态不再能阻止第二张申请,因此活动退款唯一索引是必需的主守卫:
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.requestedhandleRefundAssetProcessing不在事务内执行运营商停机等外部调用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 NULLENG-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_status0 未发起或不适用、1 处理中、2 明确成功、3 明确失败。纯钱包与客户收款信息方式固定为 0

富友查询窗口超期(结果永久未知)不增设新状态:保留状态 5 并置 anomaly_flag = 1anomaly_reason 记录「富友退款查询窗口已过」,转人工处理。

11. 恢复任务形态

新增 refund:channel:recovery 定时任务,复刻既有代理在线充值恢复形态(cmd/worker/main.go:465@every 1mUniqueMaxRetry、批次扫描、不重发资金动作)。任务只按 channel_refund_request_no 查询渠道并回填结果:明确成功→保存流水并把退款单置已通过、按方式置订单已退款;明确失败→写失败分类并置状态 6仍未知→保持状态 5。富友仅在提交后 3 日内查询,超期转人工异常。

12. 与第 7 项AUG26-012的事实契约

本 Change 不实施佣金回溯明细,只固定并提供:退款完成事件键(保留既有 refund.commission.deduct.requested + refund:<id> 兼容,需要区分原路成功与客户收款信息完成时新增独立事件);refund_idorder_id;成功退款金额(回溯比例分子);冻结实收金额(回溯比例分母);终态时点 processed_at;结构化失败分类(区分明确失败可回溯与在途不可回溯);幂等唯一键为「一次退款一次回溯」,不得依赖 commission_deducted 布尔(该标记在既有实现中可能被重复置位)。

现有实现的全额回扣(deductAllCommission 把原佣金置失效并扣佣金钱包)与 PRD 2.14 的「原佣金不变 + 另建负数明细 + 按比例回溯 + 新增回溯终态」存在差异,本 Change 不改变该行为。

13. 渠道退款结果通知:不传递、不接收、只查询

决定(已裁决)

  • 不新增任何渠道退款结果通知路由;
  • 不把退款通知指向既有支付回调 URL既有回调只承载支付业务退款通知会污染其幂等与状态判定
  • 微信 v3 退款请求不传 notify_url(渠道/商户/支付宝特有的通知字段一律不写入退款请求);
  • 富友无退款通知能力,结果只经 /refundQuery 获取;
  • 支付宝退款结果取调用同步响应;
  • 退款结果的唯一确认路径为「渠道同步响应 + 主动查询 + 恢复任务」;
  • 不修改 cmd/api/docs.gocmd/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-001junhong_cmp_test PostgreSQL + Redis DB 6本地工作区以显式 DB_* 执行 scripts/migrate.sh 完成 up/down/up只创建与删除本 Change 自有 fixture禁止重置整库渠道适配使用受控桩不调用真实渠道。