feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s

- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
This commit is contained in:
2026-09-15 11:54:56 +08:00
parent 93e072e1e2
commit 70e680eb0a
67 changed files with 3951 additions and 147 deletions

View File

@@ -0,0 +1,25 @@
-- 回滚手机号—资产关联表。
-- 失效关系行同样承载业务事实:审计只写脱敏手机号,完整手机号只能由关系行本身证明,
-- 且批量解绑任务的逐行结果依赖解绑当时的关联快照,删表即不可重建。
-- 因此存在任一记录(含已失效行)时禁止破坏性回滚,需人工核对后向前修复。
BEGIN;
-- 同一事务内先取排他锁再检查并删表,避免检查与删表之间写入新事实。
DO $$
BEGIN
IF to_regclass('tb_phone_asset_association') IS NOT NULL THEN
LOCK TABLE tb_phone_asset_association IN ACCESS EXCLUSIVE MODE;
IF EXISTS (SELECT 1 FROM tb_phone_asset_association LIMIT 1) THEN
RAISE EXCEPTION 'tb_phone_asset_association 已存在手机号—资产关联事实(含已失效行),禁止删表回滚,请人工核对后向前修复';
END IF;
END IF;
END
$$;
DROP INDEX IF EXISTS idx_phone_asset_association_asset;
DROP INDEX IF EXISTS idx_phone_asset_association_phone;
DROP INDEX IF EXISTS uq_phone_asset_association_valid;
DROP TABLE IF EXISTS tb_phone_asset_association;
COMMIT;

View File

@@ -0,0 +1,66 @@
-- 手机号—资产当前有效关联。
-- 既有事实只有「客户↔手机号」与「客户↔资产」两条互不相交的链路,无法表达单项资产的验证关系,
-- 后台查看、按资产解除、换绑迁移与批量解绑都缺少可依据的关联事实。
-- 关联只能由 H5 短信验证建立source 固定 h5_sms_verification后台不提供创建或补录入口。
-- iot_card 与 device 的 ID 空间独立,资产身份一律取 (asset_type, asset_id),同号互不覆盖也不合并计数。
-- 失效走 status 置 0 并同时写入失效时间、方式与操作人,关系行本身保留为业务事实,不做物理或软删除。
-- 单手机号最多 10 项有效关系,上限只统计 status = 1 的行;本迁移不回填任何历史关系。
-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。
CREATE TABLE tb_phone_asset_association (
id BIGSERIAL PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_id BIGINT NOT NULL,
status SMALLINT NOT NULL DEFAULT 1,
source VARCHAR(30) NOT NULL DEFAULT 'h5_sms_verification',
established_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invalidated_at TIMESTAMPTZ,
invalidation_method VARCHAR(30) NOT NULL DEFAULT '',
invalidation_reason VARCHAR(500) NOT NULL DEFAULT '',
invalidator BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT chk_phone_asset_association_phone CHECK (phone <> ''),
CONSTRAINT chk_phone_asset_association_asset_type CHECK (asset_type IN ('iot_card', 'device')),
CONSTRAINT chk_phone_asset_association_asset CHECK (asset_id > 0),
CONSTRAINT chk_phone_asset_association_status CHECK (status IN (0, 1)),
CONSTRAINT chk_phone_asset_association_source CHECK (source = 'h5_sms_verification'),
-- 有效关系不得带失效信息,已失效关系必须带失效时间:两者互为充要条件,避免出现半失效状态。
CONSTRAINT chk_phone_asset_association_invalidation CHECK (
(status = 1 AND invalidated_at IS NULL)
OR (status = 0 AND invalidated_at IS NOT NULL)
)
);
-- 同一手机号与同一 (资产类型, 资产ID) 同时只能有一条有效关系。
-- 部分唯一索引既是幂等建联的结构性保证,也是并发同资产建联的兜底:冲突由应用映射为幂等成功。
-- 迁移与「新号已存在的同资产有效关系」冲突时由本索引报错,换绑整次回滚。
CREATE UNIQUE INDEX uq_phone_asset_association_valid
ON tb_phone_asset_association (phone, asset_type, asset_id)
WHERE status = 1 AND deleted_at IS NULL;
-- 十项上限按手机号计数,计数只读有效关系。
CREATE INDEX idx_phone_asset_association_phone
ON tb_phone_asset_association (phone, status)
WHERE deleted_at IS NULL;
-- 列表、详情与两类导出按本批资产集合一次 IN 批量聚合,禁止逐资产查询。
CREATE INDEX idx_phone_asset_association_asset
ON tb_phone_asset_association (asset_type, asset_id, status)
WHERE deleted_at IS NULL;
COMMENT ON TABLE tb_phone_asset_association IS '手机号—资产当前有效关联,仅由 H5 短信验证建立,单手机号最多 10 项有效关系';
COMMENT ON COLUMN tb_phone_asset_association.id IS '主键';
COMMENT ON COLUMN tb_phone_asset_association.phone IS '已验证手机号,计数与唯一口径均以本列为准';
COMMENT ON COLUMN tb_phone_asset_association.asset_type IS '资产类型 iot_card-物联网卡 device-设备,与 pkg/constants.AssetTypeIotCard/AssetTypeDevice 一致';
COMMENT ON COLUMN tb_phone_asset_association.asset_id IS '资产ID与 asset_type 共同构成资产身份两类资产ID空间独立';
COMMENT ON COLUMN tb_phone_asset_association.status IS '状态 0-已失效 1-有效10 项上限只统计有效关系';
COMMENT ON COLUMN tb_phone_asset_association.source IS '建立来源,固定为 h5_sms_verification后台无创建或补录入口';
COMMENT ON COLUMN tb_phone_asset_association.established_at IS '关联建立时间,即短信验证通过时间';
COMMENT ON COLUMN tb_phone_asset_association.invalidated_at IS '失效时间,仅 status = 0 时非空';
COMMENT ON COLUMN tb_phone_asset_association.invalidation_method IS '失效方式 backend_single-后台单项 backend_batch-后台批量 csv_import-CSV导入';
COMMENT ON COLUMN tb_phone_asset_association.invalidation_reason IS '失效原因后台解除必填最长500字符';
COMMENT ON COLUMN tb_phone_asset_association.invalidator IS '失效操作人账号ID系统任务为 0';
COMMENT ON COLUMN tb_phone_asset_association.created_at IS '创建时间';
COMMENT ON COLUMN tb_phone_asset_association.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_phone_asset_association.deleted_at IS '软删除时间,本表不使用:失效走 status 置 0 并保留关系行';

View File

@@ -0,0 +1,23 @@
-- 回滚手机号—资产关联 CSV 解绑导入任务表。
-- 任务行保存逐行解绑结果与解绑当时的完整手机号快照,删表即不可重建,
-- 因此存在任一记录(含软删行)时禁止破坏性回滚,需人工核对后向前修复。
BEGIN;
-- 同一事务内先取排他锁再检查并删表,避免检查与删表之间写入新事实。
DO $$
BEGIN
IF to_regclass('tb_phone_asset_unbind_import_task') IS NOT NULL THEN
LOCK TABLE tb_phone_asset_unbind_import_task IN ACCESS EXCLUSIVE MODE;
IF EXISTS (SELECT 1 FROM tb_phone_asset_unbind_import_task LIMIT 1) THEN
RAISE EXCEPTION 'tb_phone_asset_unbind_import_task 已存在解绑导入任务事实(含软删行),禁止删表回滚,请人工核对后向前修复';
END IF;
END IF;
END
$$;
DROP INDEX IF EXISTS idx_phone_asset_unbind_import_task_status_created;
DROP INDEX IF EXISTS uq_phone_asset_unbind_import_task_no;
DROP TABLE IF EXISTS tb_phone_asset_unbind_import_task;
COMMIT;

View File

@@ -0,0 +1,63 @@
-- 手机号—资产关联 CSV 解绑导入任务。
-- 仓库不存在统一导入任务框架,解绑导入沿用已有独立导入场景的做法:独立成表、独立队列,
-- 与店铺负责人导入任务分开,避免复用其启动补偿与任务类型口径。
-- 任务级解绑原因是任务自身的输入事实Worker 载荷只带任务 ID失效原因必须能从任务行读回。
-- 逐行结果明细持久化解绑当时的完整手机号快照:解绑后关系立即失效,
-- 只有快照能事后满足「批量任务结果展示完整手机号」;任务明细是业务事实表,按数据范围保护,不属于日志。
-- 逐行独立事务,成功行提交、失败行保留原状,任务级失败不产生行明细,不设行数硬上限。
-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。
CREATE TABLE tb_phone_asset_unbind_import_task (
id BIGSERIAL PRIMARY KEY,
task_no VARCHAR(50) NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
storage_key VARCHAR(500) NOT NULL,
unbind_reason VARCHAR(500) NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
total_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
fail_count INTEGER NOT NULL DEFAULT 0,
result_items JSONB NOT NULL DEFAULT '[]'::jsonb,
error_message TEXT NOT NULL DEFAULT '',
creator_name VARCHAR(100) NOT NULL DEFAULT '',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
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 chk_phone_asset_unbind_import_task_status CHECK (status IN (1, 2, 3, 4)),
CONSTRAINT chk_phone_asset_unbind_import_task_counts CHECK (
total_count >= 0 AND success_count >= 0 AND fail_count >= 0
AND success_count + fail_count <= total_count
)
);
CREATE UNIQUE INDEX uq_phone_asset_unbind_import_task_no
ON tb_phone_asset_unbind_import_task (task_no)
WHERE deleted_at IS NULL;
CREATE INDEX idx_phone_asset_unbind_import_task_status_created
ON tb_phone_asset_unbind_import_task (status, created_at DESC)
WHERE deleted_at IS NULL;
COMMENT ON TABLE tb_phone_asset_unbind_import_task IS '手机号—资产关联 CSV 解绑导入任务,逐行独立事务并保留逐行解绑手机号快照';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.id IS '主键';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.task_no IS '导入任务编号,未删除记录内唯一';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.file_name IS '上传的源 CSV 文件名';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.storage_key IS '源 CSV 对象存储Key';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.unbind_reason IS '任务级解绑原因,写入每次解除的失效原因与审计;行内不填原因';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.status IS '任务状态 1-待处理 2-处理中 3-已完成 4-失败';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.total_count IS '任务数据行总数,任务级失败时为 0';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.success_count IS '处理成功行数';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.fail_count IS '处理失败行数';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.result_items IS '逐行结果明细(含解绑当时的完整手机号快照),行号自数据首行起计;任务级失败为空数组';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.error_message IS '任务级失败原因,与行级失败原因分开记录';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.creator_name IS '任务创建人名称快照';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.started_at IS '任务开始处理时间';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.completed_at IS '任务处理完成时间';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.creator IS '创建人账号ID';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.updater IS '最近更新人账号ID';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.created_at IS '创建时间';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_phone_asset_unbind_import_task.deleted_at IS '软删除时间';