-- 套餐真流量达量预警:规则表与预警快照表,以及扫描用的有效套餐部分索引。 -- 背景:运营需要按「同一资产全部当前有效套餐的真流量汇总」判断达量,并向触发时资产所属店铺的 -- 有效平台业务员投递站内通知。既有事实里没有任何承载「套餐商品真流量预警规则」的表, -- 也没有可冻结触发阈值、汇总用量、归属与资产展示快照的预警表,因此本迁移新增两张表; -- 既有 tb_notification 的类别 CHECK 已允许 expiry,通知类型与受控资源类型只在代码注册表登记, -- 故不改通知表结构;tb_package_usage 只新增一个非唯一部分索引,不改任何既有列与数据。 -- -- 设计选择: -- 1. 规则表不设 deleted_at:本能力只提供创建、修改阈值与启停,没有删除入口, -- 因此 package_id 可以直接用「非部分」唯一约束表达「每个套餐商品至多一条当前规则」, -- 避免部分唯一索引在 OnConflict 未声明谓词时的失败(KNOWN-ISSUE-001)。 -- 2. 阈值用 NUMERIC(5,2) 表示 1%~100% 的小数百分比;判定在应用层用整数基点比较,不使用浮点。 -- 3. 预警表以「主套餐使用记录 + 阈值快照」为唯一键:一个资产的一个阈值至多一条预警, -- 重复扫描由唯一冲突视为已处理;降低阈值产生新阈值快照属预期补建,不修改既有预警快照。 -- 4. 触发快照列(用量、额度、比例、阈值、到期时间、资产标识、卡标识、对端标识、设备类型与型号、 -- 店铺与业务员)在创建预警时冻结,列表、详情与导出一律读快照,不随之后的归属或绑定变化改写。 -- 5. notification_event_id 只在写入可靠通知事件时填充;无有效业务员时保持为空字符串, -- 表示该预警不产生通知且不在未来补发。 -- 6. 不使用数据库外键;资产、店铺、账号与规则均以 ID 保存并由应用层显式校验。 CREATE TABLE tb_package_traffic_alert_rule ( id BIGSERIAL PRIMARY KEY, package_id BIGINT NOT NULL, threshold_percent NUMERIC(5, 2) NOT NULL, enabled SMALLINT NOT NULL DEFAULT 0, remark VARCHAR(500) NOT NULL DEFAULT '', 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(), CONSTRAINT uq_package_traffic_alert_rule_package UNIQUE (package_id), CONSTRAINT ck_package_traffic_alert_rule_threshold CHECK (threshold_percent >= 1 AND threshold_percent <= 100), CONSTRAINT ck_package_traffic_alert_rule_enabled CHECK (enabled IN (0, 1)) ); COMMENT ON TABLE tb_package_traffic_alert_rule IS '套餐真流量预警规则,每个套餐商品至多一条当前规则,仅超级管理员与平台账号维护'; COMMENT ON COLUMN tb_package_traffic_alert_rule.id IS '主键'; COMMENT ON COLUMN tb_package_traffic_alert_rule.package_id IS '套餐商品ID;唯一约束保证每个商品至多一条当前规则'; COMMENT ON COLUMN tb_package_traffic_alert_rule.threshold_percent IS '真流量预警阈值百分比,取值 1~100,允许两位小数'; COMMENT ON COLUMN tb_package_traffic_alert_rule.enabled IS '状态 0-禁用 1-启用;停用后扫描不再创建新预警,既有预警快照不变'; COMMENT ON COLUMN tb_package_traffic_alert_rule.remark IS '备注,最多 500 字符'; COMMENT ON COLUMN tb_package_traffic_alert_rule.creator IS '创建人账号ID'; COMMENT ON COLUMN tb_package_traffic_alert_rule.updater IS '最近更新人账号ID'; COMMENT ON COLUMN tb_package_traffic_alert_rule.created_at IS '创建时间'; COMMENT ON COLUMN tb_package_traffic_alert_rule.updated_at IS '最近更新时间,阈值修改与启停均必须刷新'; CREATE TABLE tb_package_traffic_alert ( id BIGSERIAL PRIMARY KEY, package_usage_id BIGINT NOT NULL, package_id BIGINT NOT NULL, rule_id BIGINT NOT NULL, asset_type VARCHAR(16) NOT NULL, asset_id BIGINT NOT NULL, asset_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '', card_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '', counterpart_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '', device_type_snapshot VARCHAR(50) NOT NULL DEFAULT '', device_model_snapshot VARCHAR(100) NOT NULL DEFAULT '', package_name_snapshot VARCHAR(255) NOT NULL DEFAULT '', used_mb_snapshot BIGINT NOT NULL DEFAULT 0, limit_mb_snapshot BIGINT NOT NULL, usage_percent_snapshot NUMERIC(9, 2) NOT NULL DEFAULT 0, threshold_percent_snapshot NUMERIC(5, 2) NOT NULL, expires_at_snapshot TIMESTAMPTZ, triggered_at TIMESTAMPTZ NOT NULL, shop_id_snapshot BIGINT NOT NULL DEFAULT 0, shop_name_snapshot VARCHAR(100) NOT NULL DEFAULT '', business_owner_account_id_snapshot BIGINT, business_owner_name_snapshot VARCHAR(64) NOT NULL DEFAULT '', notification_event_id VARCHAR(64) NOT NULL DEFAULT '', 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(), CONSTRAINT uq_package_traffic_alert_usage_threshold UNIQUE (package_usage_id, threshold_percent_snapshot), CONSTRAINT ck_package_traffic_alert_asset_type CHECK (asset_type IN ('iot_card', 'device')), CONSTRAINT ck_package_traffic_alert_threshold CHECK (threshold_percent_snapshot >= 1 AND threshold_percent_snapshot <= 100), CONSTRAINT ck_package_traffic_alert_limit CHECK (limit_mb_snapshot > 0), CONSTRAINT ck_package_traffic_alert_used CHECK (used_mb_snapshot >= 0), CONSTRAINT ck_package_traffic_alert_percent CHECK (usage_percent_snapshot >= 0) ); COMMENT ON TABLE tb_package_traffic_alert IS '套餐真流量达量预警事实,触发时冻结阈值、汇总用量与资产归属快照'; COMMENT ON COLUMN tb_package_traffic_alert.id IS '主键'; COMMENT ON COLUMN tb_package_traffic_alert.package_usage_id IS '主套餐使用记录ID(master_usage_id 为空且按优先级/生效时间/编号取第一条),预警锚点'; COMMENT ON COLUMN tb_package_traffic_alert.package_id IS '阈值来源的套餐商品ID快照'; COMMENT ON COLUMN tb_package_traffic_alert.rule_id IS '触发时的预警规则ID快照'; COMMENT ON COLUMN tb_package_traffic_alert.asset_type IS '资产类型 iot_card-物联网卡 device-设备'; COMMENT ON COLUMN tb_package_traffic_alert.asset_id IS '资产ID;取自使用记录的绑定资产,是权威归属'; COMMENT ON COLUMN tb_package_traffic_alert.asset_identifier_snapshot IS '资产标识快照:卡取 ICCID,设备取虚拟号→IMEI→SN 的稳定优先级'; COMMENT ON COLUMN tb_package_traffic_alert.card_identifier_snapshot IS '卡标识快照:卡资产为自身 ICCID,设备资产为触发时当前绑定卡 ICCID'; COMMENT ON COLUMN tb_package_traffic_alert.counterpart_identifier_snapshot IS '对应标识符快照:卡资产为触发时当前绑定设备标识,设备资产为触发时当前绑定卡 ICCID'; COMMENT ON COLUMN tb_package_traffic_alert.device_type_snapshot IS '设备类型快照:设备资产取自身,卡资产取触发时当前绑定设备'; COMMENT ON COLUMN tb_package_traffic_alert.device_model_snapshot IS '设备型号快照:设备资产取自身,卡资产取触发时当前绑定设备'; COMMENT ON COLUMN tb_package_traffic_alert.package_name_snapshot IS '套餐名称快照,优先使用记录快照,缺失时回落商品名称'; COMMENT ON COLUMN tb_package_traffic_alert.used_mb_snapshot IS '触发时该资产全部当前有效套餐的真已用量汇总(MB)'; COMMENT ON COLUMN tb_package_traffic_alert.limit_mb_snapshot IS '触发时该资产全部当前有效套餐的真总量快照汇总(MB)'; COMMENT ON COLUMN tb_package_traffic_alert.usage_percent_snapshot IS '触发时汇总比例快照,单位百分比,保留两位小数'; COMMENT ON COLUMN tb_package_traffic_alert.threshold_percent_snapshot IS '触发阈值快照,单位百分比;与使用记录组成唯一键'; COMMENT ON COLUMN tb_package_traffic_alert.expires_at_snapshot IS '主套餐使用记录到期时间快照;为空时导出到期时间与剩余天数为空'; COMMENT ON COLUMN tb_package_traffic_alert.triggered_at IS '触发时间,列表与导出按该列筛选'; COMMENT ON COLUMN tb_package_traffic_alert.shop_id_snapshot IS '触发时资产所属店铺ID快照'; COMMENT ON COLUMN tb_package_traffic_alert.shop_name_snapshot IS '触发时店铺名称快照'; COMMENT ON COLUMN tb_package_traffic_alert.business_owner_account_id_snapshot IS '触发时店铺业务员账号ID快照;无有效业务员为空'; COMMENT ON COLUMN tb_package_traffic_alert.business_owner_name_snapshot IS '触发时业务员账号名快照;无有效业务员为空字符串'; COMMENT ON COLUMN tb_package_traffic_alert.notification_event_id IS '可靠通知事件ID;仅在写入通知事件时填充,无有效业务员时为空'; COMMENT ON COLUMN tb_package_traffic_alert.creator IS '创建人账号ID,扫描任务写入 0'; COMMENT ON COLUMN tb_package_traffic_alert.updater IS '最近更新人账号ID,预警事实创建后不再改写'; COMMENT ON COLUMN tb_package_traffic_alert.created_at IS '创建时间'; COMMENT ON COLUMN tb_package_traffic_alert.updated_at IS '最近更新时间'; -- 列表默认按触发时间倒序;店铺、套餐与资产筛选各自走独立索引。 CREATE INDEX idx_package_traffic_alert_triggered ON tb_package_traffic_alert (triggered_at DESC, id DESC); CREATE INDEX idx_package_traffic_alert_shop ON tb_package_traffic_alert (shop_id_snapshot, triggered_at DESC, id DESC); CREATE INDEX idx_package_traffic_alert_package ON tb_package_traffic_alert (package_id, triggered_at DESC, id DESC); CREATE INDEX idx_package_traffic_alert_asset ON tb_package_traffic_alert (asset_type, asset_id); CREATE INDEX idx_package_traffic_alert_event ON tb_package_traffic_alert (notification_event_id); -- 扫描只看当前有效套餐集合:status IN (1,2)、未退款、未软删。 -- 该索引为非唯一部分索引,只服务只读聚合,不与任何 OnConflict 组合,因此不涉及部分唯一索引谓词问题。 CREATE INDEX idx_package_usage_alert_scope ON tb_package_usage (iot_card_id, device_id, master_usage_id) WHERE deleted_at IS NULL AND refund_id IS NULL AND status IN (1, 2); COMMENT ON INDEX idx_package_usage_alert_scope IS '套餐真流量达量扫描的有效套餐集合部分索引';