All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230) - 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合 - 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试 - 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单 - 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用 - 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计 - tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
173 lines
15 KiB
SQL
173 lines
15 KiB
SQL
-- 资产钱包自动续费:新增尝试记录表 tb_asset_auto_renewal_attempt。
|
||
-- 背景:自动续费需要「每项资产每天至多一次尝试」的强约束,以及可追溯的资金、订单、复机与配置
|
||
-- 快照。既有 tb_recharge_order.auto_purchase_status 只表达一个充值单的成功/失败,无法承载
|
||
-- 「按资产按日唯一」「占位后中断可收敛」「复机结果分类」三类事实,因此新建本表。
|
||
--
|
||
-- 设计选择:
|
||
-- 1. 唯一键为 (asset_type, asset_id, trigger_date) 的部分唯一索引:trigger_date 是上海自然日,
|
||
-- 同一资产同一自然日至多一条尝试,「每天至多一次」由唯一冲突保证而不是靠应用层判断。
|
||
-- 索引必须是部分唯一索引(WHERE deleted_at IS NULL):CREATE 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 '资产ID(tb_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_reason;interrupted 不属于四类通知原因,不触发通知';
|
||
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 '触发时解析到的当前个人客户ID,0-无当前个人客户';
|
||
COMMENT ON COLUMN tb_asset_auto_renewal_attempt.shop_id IS '触发时资产所属店铺ID,NULL-资产无店铺归属';
|
||
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 '跨日尝试次数按资产累计索引';
|