Files
junhong_cmp_fiber/migrations/000230_add_asset_auto_renewal_attempt.up.sql
break d52be16802
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00

173 lines
15 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 资产钱包自动续费:新增尝试记录表 tb_asset_auto_renewal_attempt。
-- 背景:自动续费需要「每项资产每天至多一次尝试」的强约束,以及可追溯的资金、订单、复机与配置
-- 快照。既有 tb_recharge_order.auto_purchase_status 只表达一个充值单的成功/失败,无法承载
-- 「按资产按日唯一」「占位后中断可收敛」「复机结果分类」三类事实,因此新建本表。
--
-- 设计选择:
-- 1. 唯一键为 (asset_type, asset_id, trigger_date) 的部分唯一索引trigger_date 是上海自然日,
-- 同一资产同一自然日至多一条尝试,「每天至多一次」由唯一冲突保证而不是靠应用层判断。
-- 索引必须是部分唯一索引WHERE deleted_at IS NULLCREATE TABLE 的表约束不支持 WHERE 谓词,
-- 而软删感知要求谓词使软删行不占用键位。该索引不与任何 GORM OnConflict 组合
-- (部分唯一索引谓词未声明时 OnConflict 无法命中,见 KNOWN-ISSUE-001
-- 应用层以「显式插入 + 23505 识别」表达「当日已尝试」。
-- 2. status 表达尝试生命周期1-处理中(占位) 2-成功 3-失败 4-跳过。占位行写入后进程中断会
-- 停在处理中,由后续扫描把「触发日期早于当日且非终态」的行按
-- status=3 + failure_reason='interrupted' 收敛(见第 3 点),当日不重试。
-- 生命周期状态按 ENG-STATE-001 使用整数编码。
-- 3. failure_reason 是「尝试记录的失败归类」,只承载实际进入执行后得出的失败:
-- insufficient_balance-余额不足 not_renewable-不可续费 order_failed-订单失败,
-- 外加 interrupted-中断收敛(见下)。复机失败不写 failure_reason按设计决策 11
-- 复机结果只落 resume_status/resume_failure_reason避免同一事实两处表达。
-- 规格的「失败原因枚举固定为四类」(余额不足/不可续费/订单失败/复机失败)是**通知口径**
-- 前三类由尝试失败产生通知第四类由复机失败resume_status=4产生通知
-- 复用同一常量作为通知原因标识,但绝不回写 failure_reason。
-- interrupted 是**非通知值**:占位后进程中断的记录由后续扫描收敛为
-- status=3 + failure_reason='interrupted',表达规格要求的「收敛为中断或未知」。
-- 该值不对应任何真实执行失败,因此通知投递判定显式排除它,收敛过程也不发送任何通知
-- (一个从未进入资金事务的占位行发「订单失败」通知是错的)。
-- skip_reason 取值 manual_renewed-人工已完成续购 manual_order_pending-人工订单在途。
-- 4. resume_status 表达复机子结果0-未评估 1-跳过 2-已投递 3-成功 4-失败 5-未知。
-- 已投递未提交、已提交待确认与未知都属于未收敛,恢复扫描只查询已确认结果回填,绝不重复发起复机。
-- resume_submitted_at 与 resume_anomaly_flag 照抄第 13 项既有形态:
-- internal/domain/carrierthreshold/command.go 的 SubmissionQueryWindow/SubmissionExpired、
-- internal/application/carrierthreshold/cycle.go:274-296 的「窗口内继续等待,超期标记异常退出自动扫描」、
-- internal/application/carrierthreshold/lock_store.go 的 markAnomaly
-- 以及 migrations/000227_add_carrier_traffic_threshold.up.sql 的 anomaly_flag 列与
-- idx_carrier_traffic_threshold_lock_unresolved 部分索引(谓词含 anomaly_flag = 0
-- 本表以同样方式实现「有界查询窗口 + 超期退出自动扫描」,不新增异常记录页、不自动删除记录。
-- 5. trigger_date 是 DATE 列:应用层与扫描的一切跨日比较必须用显式日期参数
-- trigger_date < ?::date不得把 time.Time 直接与东八区零点比较——DATE 读回后是当日零点,
-- 直接比较会因时区换算差一天。「当日不重试」与「收敛历史非终态」使用同一日期口径。
-- 6. 资金与订单字段全部是触发时冻结的快照wallet_transaction_id 即规格中的钱包流水号,
-- 指向 tb_asset_wallet_transaction.id扣款前/后余额用于人工核对资金事实。
-- 跳过态与失败态的行这些字段保持 0/空。
-- 7. 配置与窗口快照config_version、window_days、final_expires_at只记录触发时的值
-- 配置变更不影响已产生的尝试记录,不重算。
-- 8. operator_type/operator_id 恒为系统任务无人工处理入口attempt_seq 记录该资产截至本次
-- 尝试当日的跨日累计尝试次数。
-- 9. 不存凭证、令牌或个人敏感信息ENG-LOG-001不使用数据库外键关联由应用层显式校验。
CREATE TABLE tb_asset_auto_renewal_attempt (
id BIGSERIAL PRIMARY KEY,
asset_type VARCHAR(20) NOT NULL,
asset_id BIGINT NOT NULL,
trigger_date DATE NOT NULL,
status SMALLINT NOT NULL DEFAULT 1,
failure_reason VARCHAR(32) NOT NULL DEFAULT '',
failure_detail VARCHAR(500) NOT NULL DEFAULT '',
skip_reason VARCHAR(32) NOT NULL DEFAULT '',
customer_id BIGINT NOT NULL DEFAULT 0,
shop_id BIGINT,
config_version BIGINT NOT NULL DEFAULT 0,
window_days INTEGER NOT NULL DEFAULT 0,
final_expires_at TIMESTAMPTZ,
current_usage_id BIGINT NOT NULL DEFAULT 0,
current_package_id BIGINT NOT NULL DEFAULT 0,
renew_package_id BIGINT NOT NULL DEFAULT 0,
renew_price BIGINT NOT NULL DEFAULT 0,
wallet_id BIGINT NOT NULL DEFAULT 0,
wallet_transaction_id BIGINT NOT NULL DEFAULT 0,
deduct_amount BIGINT NOT NULL DEFAULT 0,
balance_before BIGINT NOT NULL DEFAULT 0,
balance_after BIGINT NOT NULL DEFAULT 0,
order_id BIGINT NOT NULL DEFAULT 0,
order_no VARCHAR(64) NOT NULL DEFAULT '',
resume_status SMALLINT NOT NULL DEFAULT 0,
resume_submitted_at TIMESTAMPTZ,
resume_integration_id VARCHAR(64) NOT NULL DEFAULT '',
resume_failure_reason VARCHAR(500) NOT NULL DEFAULT '',
resume_anomaly_flag SMALLINT NOT NULL DEFAULT 0,
operator_type VARCHAR(32) NOT NULL DEFAULT 'system_task',
operator_id VARCHAR(64) NOT NULL DEFAULT '',
attempt_seq INTEGER NOT NULL DEFAULT 1,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT ck_asset_auto_renewal_attempt_asset_type CHECK (asset_type IN ('iot_card', 'device')),
CONSTRAINT ck_asset_auto_renewal_attempt_status CHECK (status IN (1, 2, 3, 4)),
CONSTRAINT ck_asset_auto_renewal_attempt_failure_reason CHECK (
failure_reason IN ('', 'insufficient_balance', 'not_renewable', 'order_failed', 'interrupted')
),
CONSTRAINT ck_asset_auto_renewal_attempt_skip_reason CHECK (
skip_reason IN ('', 'manual_renewed', 'manual_order_pending')
),
CONSTRAINT ck_asset_auto_renewal_attempt_resume_status CHECK (resume_status IN (0, 1, 2, 3, 4, 5)),
CONSTRAINT ck_asset_auto_renewal_attempt_resume_anomaly CHECK (resume_anomaly_flag IN (0, 1)),
CONSTRAINT ck_asset_auto_renewal_attempt_operator_type CHECK (operator_type = 'system_task'),
CONSTRAINT ck_asset_auto_renewal_attempt_amounts CHECK (
renew_price >= 0 AND deduct_amount >= 0 AND balance_before >= 0 AND balance_after >= 0
),
-- 语义列不允许出现「无因失败」或「有因成功」:状态与原因必须成对。
-- 处理中与成功不得带任何原因;失败必须带失败归类;跳过必须带跳过原因且不得带失败归类。
CONSTRAINT ck_asset_auto_renewal_attempt_reason_shape CHECK (
(status IN (1, 2) AND failure_reason = '' AND skip_reason = '')
OR (status = 3 AND failure_reason IN ('insufficient_balance', 'not_renewable', 'order_failed', 'interrupted') AND skip_reason = '')
OR (status = 4 AND failure_reason = '' AND skip_reason IN ('manual_renewed', 'manual_order_pending'))
)
);
CREATE UNIQUE INDEX uq_asset_auto_renewal_attempt_key
ON tb_asset_auto_renewal_attempt (asset_type, asset_id, trigger_date)
WHERE deleted_at IS NULL;
-- 终态收敛按「触发日期早于当日且仍为处理中」扫描。
CREATE INDEX idx_asset_auto_renewal_attempt_unfinished
ON tb_asset_auto_renewal_attempt (status, trigger_date)
WHERE deleted_at IS NULL;
-- 复机恢复扫描只看未收敛的复机子结果:已投递未提交(事件已写但尚未执行)、
-- 已提交待确认(进程中断)与结果未知都必须继续按只读查询收敛。
CREATE INDEX idx_asset_auto_renewal_attempt_resume_unresolved
ON tb_asset_auto_renewal_attempt (resume_status, resume_submitted_at)
WHERE deleted_at IS NULL AND resume_anomaly_flag = 0
AND (resume_status = 5 OR (resume_status = 2 AND resume_submitted_at IS NOT NULL));
-- 跨日尝试次数按资产累计。
CREATE INDEX idx_asset_auto_renewal_attempt_asset ON tb_asset_auto_renewal_attempt (asset_type, asset_id);
COMMENT ON TABLE tb_asset_auto_renewal_attempt IS '资产钱包自动续费尝试记录,一项资产一个上海自然日至多一条,承载资金/订单/复机/配置快照';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.id IS '主键';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.asset_type IS '资产类型 iot_card-物联网卡 device-设备,与资产钱包资源类型一致';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.asset_id IS '资产IDtb_iot_card.id 或 tb_device.id';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.trigger_date IS '触发日期上海自然日与资产类型、资产ID 共同构成唯一键';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.status IS '尝试状态 1-处理中 2-成功 3-失败 4-跳过';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.failure_reason IS '尝试失败归类 insufficient_balance-余额不足 not_renewable-不可续费 order_failed-订单失败 interrupted-占位后中断收敛;成功与跳过时为空;复机失败只落 resume_status/resume_failure_reasoninterrupted 不属于四类通知原因,不触发通知';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.failure_detail IS '可安全展示的失败说明,不写渠道报文、凭证或内部错误细节';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.skip_reason IS '跳过原因 manual_renewed-人工已完成续购 manual_order_pending-人工订单在途';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.customer_id IS '触发时解析到的当前个人客户ID0-无当前个人客户';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.shop_id IS '触发时资产所属店铺IDNULL-资产无店铺归属';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.config_version IS '触发时配置版本快照,不随配置变更重算';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.window_days IS '触发时到期前天数快照';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.final_expires_at IS '触发时最终到期时间快照';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.current_usage_id IS '触发时当前主套餐使用记录ID';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.current_package_id IS '触发时当前套餐商品ID';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.renew_package_id IS '待续购套餐商品ID与当前套餐商品相同';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.renew_price IS '执行时该渠道当前可售续费价(分)';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.wallet_id IS '扣款资产钱包ID跳过与失败时为 0';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.wallet_transaction_id IS '钱包流水号tb_asset_wallet_transaction.id跳过与失败时为 0';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.deduct_amount IS '扣款金额(分);跳过与失败时为 0';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.balance_before IS '扣款前钱包余额(分)';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.balance_after IS '扣款后钱包余额(分)';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.order_id IS '续费订单ID跳过与失败时为 0';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.order_no IS '续费订单号快照;跳过与失败时为空';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_status IS '复机状态 0-未评估 1-跳过 2-已投递 3-成功 4-失败 5-未知;已投递与未知由恢复扫描收敛';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_submitted_at IS '复机执行提交认领时刻;由消费者以「为空」条件更新取得至多一次的外部调用权,恢复扫描据此判断查询窗口';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_integration_id IS '复机外部交互标识Gateway 调用的 Integration Log 标识),便于人工核对';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_failure_reason IS '可安全展示的复机失败原因,不写渠道报文原文';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_anomaly_flag IS '复机异常标记 0-正常 1-查询窗口超期仍无法确认,退出自动扫描转人工核对';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.operator_type IS '操作者类型,恒为 system_task本能力无人工处理入口';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.operator_id IS '操作者标识,取触发时的计划任务类型';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.attempt_seq IS '跨日尝试次数(该资产截至本次触发日的累计尝试次数)';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.creator IS '创建人账号ID系统写入为 0';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.updater IS '最近更新人账号ID系统写入为 0';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.created_at IS '创建时间';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.deleted_at IS '软删除时间,正常流程不删除,仅数据清理使用';
COMMENT ON INDEX uq_asset_auto_renewal_attempt_key IS '自动续费尝试唯一键(软删感知):一项资产一个上海自然日至多一条,冲突即当日已尝试';
COMMENT ON INDEX idx_asset_auto_renewal_attempt_unfinished IS '终态收敛扫描索引:触发日期早于当日的非终态记录';
COMMENT ON INDEX idx_asset_auto_renewal_attempt_resume_unresolved IS '复机恢复扫描索引:已投递与未知两类未收敛复机结果';
COMMENT ON INDEX idx_asset_auto_renewal_attempt_asset IS '跨日尝试次数按资产累计索引';