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

@@ -0,0 +1,63 @@
-- 回滚退款审批尝试记录与渠道原路退款事实。
-- 存在审批尝试记录或渠道退款事实时禁止破坏性回滚:
-- 尝试记录承载每次提交的不可变材料与冻结金额,渠道退款流水是资金事实,均无法由回滚重建。
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_refund_request_attempt) THEN
RAISE EXCEPTION '存在退款审批尝试记录,禁止回滚退款审批尝试模式';
END IF;
IF EXISTS (
SELECT 1 FROM tb_refund_request
WHERE channel_refund_status <> 0 OR channel_refund_no <> '' OR channel_refund_request_no <> ''
) THEN
RAISE EXCEPTION '存在渠道退款事实,禁止回滚退款渠道退款字段';
END IF;
END $$;
DROP INDEX IF EXISTS idx_refund_request_channel_recovery;
DROP INDEX IF EXISTS uk_refund_request_active_order;
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_status;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_status
CHECK (status IN (1, 2, 3, 4));
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_amounts;
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_anomaly;
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_failure_reason;
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_channel_refund_status;
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_method;
ALTER TABLE tb_refund_request
DROP COLUMN IF EXISTS anomaly_reason,
DROP COLUMN IF EXISTS anomaly_flag,
DROP COLUMN IF EXISTS failure_message,
DROP COLUMN IF EXISTS failure_reason,
DROP COLUMN IF EXISTS channel_refunded_at,
DROP COLUMN IF EXISTS channel_submitted_at,
DROP COLUMN IF EXISTS channel_refund_amount,
DROP COLUMN IF EXISTS channel_refund_request_no,
DROP COLUMN IF EXISTS channel_refund_no,
DROP COLUMN IF EXISTS channel_refund_status,
DROP COLUMN IF EXISTS customer_account_info,
DROP COLUMN IF EXISTS frozen_actual_received_amount,
DROP COLUMN IF EXISTS method,
DROP COLUMN IF EXISTS latest_approval_instance_id,
DROP COLUMN IF EXISTS latest_attempt_id;
COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回';
DROP TABLE IF EXISTS tb_refund_request_attempt;

View File

@@ -0,0 +1,173 @@
-- 退款审批尝试记录与渠道原路退款事实。
-- 每次提交或重提新增一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、
-- 客户收款信息、凭证与套餐使用快照;尝试记录主键同时作为通用审批业务标识,
-- 使同一退款单的每次提交各自持有独立审批实例,历史材料不被覆盖。
-- 退款单只保存最新尝试与最新实例引用用于展示,其既有 approval_instance_id 语义与唯一约束保持不变。
-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。
CREATE TABLE tb_refund_request_attempt (
id BIGSERIAL PRIMARY KEY,
refund_id BIGINT NOT NULL,
attempt_no INTEGER NOT NULL,
method VARCHAR(20) NOT NULL,
refund_amount BIGINT NOT NULL,
frozen_actual_received_amount BIGINT NOT NULL,
refund_reason TEXT NOT NULL DEFAULT '',
customer_account_info TEXT NOT NULL DEFAULT '',
customer_voucher_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
package_usage_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
channel_refund_request_no VARCHAR(64) NOT NULL DEFAULT '',
submitted_by_account_id BIGINT NOT NULL,
approval_instance_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_refund_request_attempt_no UNIQUE (refund_id, attempt_no),
CONSTRAINT chk_refund_request_attempt_ids CHECK (refund_id > 0 AND submitted_by_account_id > 0),
CONSTRAINT chk_refund_request_attempt_no_positive CHECK (attempt_no >= 1),
CONSTRAINT chk_refund_request_attempt_method CHECK (method <> ''),
CONSTRAINT chk_refund_request_attempt_amount CHECK (refund_amount > 0 AND frozen_actual_received_amount >= 0),
CONSTRAINT chk_refund_request_attempt_vouchers CHECK (jsonb_typeof(customer_voucher_keys) = 'array'),
CONSTRAINT chk_refund_request_attempt_usage CHECK (jsonb_typeof(package_usage_snapshot) = 'object'),
CONSTRAINT chk_refund_request_attempt_instance CHECK (approval_instance_id IS NULL OR approval_instance_id > 0)
);
-- 尝试记录是通用审批业务标识,其审批实例引用必须唯一。
CREATE UNIQUE INDEX uk_refund_request_attempt_instance
ON tb_refund_request_attempt (approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
CREATE INDEX idx_refund_request_attempt_refund
ON tb_refund_request_attempt (refund_id, attempt_no DESC);
COMMENT ON TABLE tb_refund_request_attempt IS '退款审批尝试记录,每次提交或重提一条不可变方式、金额与材料快照';
COMMENT ON COLUMN tb_refund_request_attempt.id IS '主键审批尝试记录ID同时作为通用审批业务ID';
COMMENT ON COLUMN tb_refund_request_attempt.refund_id IS '所属退款申请ID';
COMMENT ON COLUMN tb_refund_request_attempt.attempt_no IS '第几次提交,从 1 递增,申请内唯一';
COMMENT ON COLUMN tb_refund_request_attempt.method IS '本次退款方式original_route 原路退款、customer_account 客户收款信息、asset_wallet 退回原资产钱包、agent_wallet 退回原代理钱包';
COMMENT ON COLUMN tb_refund_request_attempt.refund_amount IS '本次申请退款金额(分),即企业微信授权金额与最终允许退款金额';
COMMENT ON COLUMN tb_refund_request_attempt.frozen_actual_received_amount IS '本次冻结的权威实收金额(分),退款金额上限与后续回溯比例分母';
COMMENT ON COLUMN tb_refund_request_attempt.refund_reason IS '本次退款原因快照';
COMMENT ON COLUMN tb_refund_request_attempt.customer_account_info IS '本次客户收款信息自由文本快照,仅客户收款信息方式有值';
COMMENT ON COLUMN tb_refund_request_attempt.customer_voucher_keys IS '本次客户收款凭证对象存储 Key 列表jsonb 数组)';
COMMENT ON COLUMN tb_refund_request_attempt.package_usage_snapshot IS '本次冻结的关联套餐使用情况快照jsonb 对象)';
COMMENT ON COLUMN tb_refund_request_attempt.channel_refund_request_no IS '本次尝试的渠道退款请求号,同一次尝试重试复用,重提更换';
COMMENT ON COLUMN tb_refund_request_attempt.submitted_by_account_id IS '本次实际提交账号ID';
COMMENT ON COLUMN tb_refund_request_attempt.approval_instance_id IS '本次尝试关联的通用审批实例ID创建后不可修改';
COMMENT ON COLUMN tb_refund_request_attempt.created_at IS '创建时间';
-- 退款单追加展示引用、冻结事实、渠道退款结果与异常标记。
-- 既有 approval_instance_id 语义(首次接入企业微信审批的实例)保持不变。
ALTER TABLE tb_refund_request
ADD COLUMN IF NOT EXISTS latest_attempt_id BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS latest_approval_instance_id BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS method VARCHAR(20) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS frozen_actual_received_amount BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS customer_account_info TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS channel_refund_status SMALLINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS channel_refund_no VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS channel_refund_request_no VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS channel_refund_amount BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS channel_refunded_at TIMESTAMPTZ,
-- 提交认领时点:渠道退款请求「至多提交一次」的持久化依据。
-- 提交前先条件认领本列,认领失败表示本次尝试已提交过,只允许查询、不得再次提交。
ADD COLUMN IF NOT EXISTS channel_submitted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(32) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS failure_message VARCHAR(500) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS anomaly_flag SMALLINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS anomaly_reason VARCHAR(500) NOT NULL DEFAULT '';
-- 存量退款单没有渠道退款事实:冻结实收沿用既有实收字段,方法留空表示未接入方式的存量申请。
UPDATE tb_refund_request
SET frozen_actual_received_amount = actual_received_amount
WHERE frozen_actual_received_amount = 0 AND actual_received_amount > 0;
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_method;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_method
CHECK (method IN ('', 'original_route', 'customer_account', 'asset_wallet', 'agent_wallet'));
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_channel_refund_status;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_channel_refund_status
CHECK (channel_refund_status IN (0, 1, 2, 3));
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_failure_reason;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_failure_reason
CHECK (failure_reason IN ('', 'channel_rejected', 'credential_invalid', 'insufficient_balance',
'timeout_unknown', 'approval_rejected', 'revoked_after_approved',
'payment_fact_invalid'));
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_anomaly;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_anomaly
CHECK (anomaly_flag IN (0, 1));
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_amounts;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_amounts
CHECK (frozen_actual_received_amount >= 0 AND channel_refund_amount >= 0);
-- 退款状态新增原路退款处理中(5)与原路退款失败(6);既有 1-4 语义不变。
ALTER TABLE tb_refund_request
DROP CONSTRAINT IF EXISTS chk_refund_request_status;
ALTER TABLE tb_refund_request
ADD CONSTRAINT chk_refund_request_status
CHECK (status IN (1, 2, 3, 4, 5, 6));
COMMENT ON COLUMN tb_refund_request.latest_attempt_id IS '最新审批尝试记录ID仅用于列表投影与历史事实定位';
COMMENT ON COLUMN tb_refund_request.latest_approval_instance_id IS '最新通用审批实例ID仅用于列表投影';
COMMENT ON COLUMN tb_refund_request.method IS '退款方式:空表示未接入方式的存量申请,取值同审批尝试记录';
COMMENT ON COLUMN tb_refund_request.frozen_actual_received_amount IS '冻结的权威实收金额(分),由系统从原成功支付记录或订单实际收款派生';
COMMENT ON COLUMN tb_refund_request.customer_account_info IS '当前客户收款信息自由文本快照,仅客户收款信息方式有值';
COMMENT ON COLUMN tb_refund_request.channel_refund_status IS '渠道退款状态0 未发起或不适用、1 处理中、2 明确成功、3 明确失败';
COMMENT ON COLUMN tb_refund_request.channel_refund_no IS '渠道退款流水号,仅渠道明确成功时写入';
COMMENT ON COLUMN tb_refund_request.channel_refund_request_no IS '本次渠道退款请求号快照,重试复用、重提更换';
COMMENT ON COLUMN tb_refund_request.channel_refund_amount IS '渠道退款金额快照(分)';
COMMENT ON COLUMN tb_refund_request.channel_refunded_at IS '渠道明确退款成功时间';
COMMENT ON COLUMN tb_refund_request.channel_submitted_at IS '渠道退款请求提交认领时间,非空表示该尝试已提交过渠道退款请求,重投只允许查询';
COMMENT ON COLUMN tb_refund_request.failure_reason IS '结构化失败分类稳定编码,空表示无失败';
COMMENT ON COLUMN tb_refund_request.failure_message IS '失败安全摘要,不记录凭证内容或凭证原文';
COMMENT ON COLUMN tb_refund_request.anomaly_flag IS '正交异常标记0 无异常、1 企业微信通过后撤销或渠道结果永久未知,转人工处理';
COMMENT ON COLUMN tb_refund_request.anomaly_reason IS '异常原因,供退款详情展示与人工处理';
COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回 5-原路退款处理中 6-原路退款失败';
-- 活动退款集合与 Go 常量对应:
-- 1 = RefundStatusPending待审批
-- 5 = RefundStatusChannelProcessing原路退款处理中
-- 6 = RefundStatusChannelFailed原路退款失败
-- 活动集合与常量漂移会导致并发重复申请,修改常量时必须同步修改本索引谓词。
-- 创建索引前先探测活动集合内的重复订单;存在重复时明确失败并中止整次迁移,不自动改写历史数据。
DO $$
DECLARE
duplicated_order BIGINT;
BEGIN
SELECT order_id INTO duplicated_order
FROM tb_refund_request
WHERE deleted_at IS NULL AND status IN (1, 5, 6)
GROUP BY order_id
HAVING COUNT(*) > 1
LIMIT 1;
IF duplicated_order IS NOT NULL THEN
RAISE EXCEPTION '订单 % 存在多张活动退款申请,禁止创建活动退款唯一索引', duplicated_order;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uk_refund_request_active_order
ON tb_refund_request (order_id)
WHERE deleted_at IS NULL AND status IN (1, 5, 6);
-- 渠道退款执行与恢复按处理中状态扫描;该索引只覆盖需要恢复的少量记录。
CREATE INDEX IF NOT EXISTS idx_refund_request_channel_recovery
ON tb_refund_request (id)
WHERE deleted_at IS NULL AND status = 5 AND channel_refund_status = 1;

View File

@@ -0,0 +1,6 @@
-- 回滚微信支付 v2 退款所需的 API 客户端证书凭证列。
-- 证书内容可由管理员重新上传,属可重建配置,因此本回滚不做存在性阻断。
ALTER TABLE tb_wechat_config
DROP COLUMN IF EXISTS wx_client_key_content,
DROP COLUMN IF EXISTS wx_client_cert_content;

View File

@@ -0,0 +1,13 @@
-- 为微信支付 v2 商户补充退款接口所需的 API 客户端证书凭证。
-- 微信支付 v2 退款接口(/secapi/pay/refund请求需要双向证书因此仅有 wx_api_v2_key 不足以退款;
-- 新增两列分别保存商户 API 证书apiclient_cert.pem与证书私钥apiclient_key.pem内容。
-- 商户池新单的凭证保存在 tb_payment_merchant.credentialsJSONB无需改表
-- 本迁移只补齐历史综合支付配置表,使按 payment_config_id 读取的历史 v2 支付同样可判定退款能力。
-- 存量行两列为空,其退款能力按凭证不完整判定为不可用,需由管理员补录后生效;不回填、不改写既有凭证。
ALTER TABLE tb_wechat_config
ADD COLUMN IF NOT EXISTS wx_client_cert_content TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS wx_client_key_content TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN tb_wechat_config.wx_client_cert_content IS '微信支付 API 客户端证书内容apiclient_cert.pemv2 退款双向证书所需';
COMMENT ON COLUMN tb_wechat_config.wx_client_key_content IS '微信支付 API 客户端证书私钥内容apiclient_key.pemv2 退款双向证书所需';