-- 资产钱包自动续费:新增尝试记录表 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 '跨日尝试次数按资产累计索引';