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:
@@ -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;
|
||||
173
migrations/000218_add_refund_attempt_and_channel_refund.up.sql
Normal file
173
migrations/000218_add_refund_attempt_and_channel_refund.up.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 为微信支付 v2 商户补充退款接口所需的 API 客户端证书凭证。
|
||||
-- 微信支付 v2 退款接口(/secapi/pay/refund)请求需要双向证书,因此仅有 wx_api_v2_key 不足以退款;
|
||||
-- 新增两列分别保存商户 API 证书(apiclient_cert.pem)与证书私钥(apiclient_key.pem)内容。
|
||||
-- 商户池新单的凭证保存在 tb_payment_merchant.credentials(JSONB),无需改表;
|
||||
-- 本迁移只补齐历史综合支付配置表,使按 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.pem),v2 退款双向证书所需';
|
||||
COMMENT ON COLUMN tb_wechat_config.wx_client_key_content IS '微信支付 API 客户端证书私钥内容(apiclient_key.pem),v2 退款双向证书所需';
|
||||
Reference in New Issue
Block a user