- 新增成对迁移 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 通过
15 KiB
Context
以下为开工核查确认的现状约束,全部来自实际代码、配置与生效库:
need_bind_phone全仓只有一个计算点internal/service/client_auth/service.go:892-931,口径是「客户无is_primary=true AND status=1的tb_personal_customer_phone记录」且全局开关cfg.Client.RequirePhoneBinding为真;入参assetType/assetID只写入 JWT(:910),不参与任何判定。开关读取点全仓仅:899一处,默认true(pkg/config/config.go:107、defaults/config.yaml:110)。need_bind_phone在后端没有任何消费方;internal/middleware/personal_auth.go:34-113只做 JWT 验签与 Redis token 比对,不读claims.Phone做判断。- 登录 JWT 已含
customer_id/phone/asset_type/asset_id(pkg/auth/jwt.go:10-16),资产类型取值为iot_card/device(pkg/constants/iot.go:142-143),请求上下文可经middleware.GetCurrentAsset(personal_auth.go:128-133)取出。 BindPhone(service.go:294-378)现状在客户已有主手机号时立即返回CodeAlreadyBoundPhone(:301-308);成功后只写tb_personal_customer_phone,不触碰任何资产关系。ChangePhone(service.go:381-471)两个验证码在事务外校验(:410、:415,校验即消费),事务只包「锁定主号行 → 查新号占用 → 原地UPDATE phone列(:448-453)→ 审计」。- 手机号唯一事实在
tb_personal_customer_phone(internal/model/personal_customer_phone.go:11-23);PersonalCustomer模型无 phone 字段(internal/model/personal_customer.go:9-17);生效库仍有遗留列tb_personal_customer.phone,无 Go 模型映射(internal/store/postgres/personal_customer_store.go:42注释)。 - 资产读侧 DTO(
internal/model/dto/{iot_card,device,asset}_dto.go)中手机号零命中,没有完整手机号投影先例。 - 设备导出的列组反解依赖固定常量:
internal/exporter/device_scene.go:14-18(base 6 / group 5 / tail 5)与cardGroupCountFromHeaders:427-434的整除判断。 - 审计 writer 对既有手机号资源写完整手机号(
internal/infrastructure/audit/writer.go:397-403、:614-618);pkg/sanitizer/sanitizer.go:16-22的字段名脱敏清单不含 phone,访问日志不会自动脱敏手机号。 - 仓库不存在统一导入任务框架,集中的只有
QueueForTaskType、worker 注册表与发布门禁未完成任务表清单三处(结论出处openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md:6,非 AUG26-008)。 - 「手机号—资产」关联事实在全仓代码、迁移与生效库中命中数为 0:既有关系只有「客户↔手机号」与「客户↔资产」两条互不相交的链路。
- 换货完成链路(
internal/service/exchange/service.go:1084-1086→internal/service/customer_binding/service.go:354-390)只迁移 device/iccid 绑定,全链路手机号零命中;后台不存在通用「编辑资产基本信息」接口。
Goals / Non-Goals
Goals:
- 以一条 phone×asset 直连事实承载「仅由 H5 验证建立」的当前有效关系,并使其计数、迁移、解绑在单事务内闭合。
- H5 契约变更有明确的上线顺序约束,不产生「提示要验证却无法建联」的死锁窗口。
- 后台读侧在资产数据范围内展示完整手机号,同时保证审计、日志与错误只出现脱敏值。
Non-Goals:
- 不新增后端授权 gate,不把关联变成资产业务入口的前置条件。
- 不重构既有
无权限操作该资源或资源不存在内联字面量(44 处),不重构既有三处私有手机号脱敏函数。 - 不隔离短信验证码场景(现状
scene仅为 DTO 字面量,验证码 Redis key 仅按手机号区分)。 - 不写遗留列
tb_personal_customer.phone。 - 不实现 H5 页面本身;只交付后端接口与其契约约束。
Decisions
1. 关联表结构以 phone 字符串为计数口径
新表保存 asset_type、asset_id、phone(字符串)、status、建立时间、建立来源与失效信息。
- 必须含
asset_type:iot_card与device的 ID 空间独立,同号会互相覆盖。资产身份一律取(asset_type, asset_id),与 JWT 及既有customer_binding的资产口径一致。 phone存字符串:PRD 的计数与唯一口径都是「手机号」,且换绑是原地改写手机号行,字符串口径使「上限 = 该号码的有效关系数」与换绑迁移都退化为直白的集合运算。- 建立来源固定为
h5_sms_verification:后台不得补录,来源字段是这一约束的可核对事实。 - 索引三件套:部分唯一索引
(phone, asset_type, asset_id) WHERE status = <有效>;按phone计数的索引;按(asset_type, asset_id)集合批量查询的索引。 status用 int 表达有效/失效(ENG-STATE-001),失效信息记录失效时间与失效方式。
2. need_bind_phone 三支判定,且明确定位为提示型字段
issueLoginToken 改为:
- 无主手机号 →
true; - 有主手机号但当前手机号未与当前访问资产存在有效关系 →
true; - 已存在有效关系 →
false; - 全局开关关闭 → 恒
false,且不查询、不创建、不删除任何关系。
关联查询使用 JWT 的 asset_type/asset_id,不得依赖 phone claim:phone 是登录时快照,换绑后到下次登录前仍是旧号。
开关关闭分支必须完全短路,否则会出现「关闭开关却写库」的越权写入。
本项不新增后端 gate。 现状确认「未绑定不得访问资产业务入口」由前端实施:need_bind_phone 无任何后端消费方,认证中间件不读手机号,手机号在链路上只用于展示(internal/handler/app/client_asset.go:224)。关系缺失不改变任何资源授权——授权始终由 OwnsAsset 与 ApplyShopFilter 决定。
3. bind-phone 扩展语义与上线顺序(G1 裁定)
BindPhone 的分支改为:
| 客户状态 | 提交号码 | 处理 |
|---|---|---|
| 无主手机号 | 任意未被他人占用 | 建立账号手机号,并在存在资产身份时建立关联 |
| 已有主手机号 | 等于主手机号 | 验证码有效 → 幂等建联;已有关联则返回成功;不修改账号手机号 |
| 已有主手机号 | 不等于主手机号 | 仍拒绝(换号必须走 change-phone) |
| 任意 | 请求不含当前访问资产身份 | 只完成账号手机号绑定或幂等成功,不建立关联 |
建联路径在事务内锁定手机号行后先计数:达到十项返回「该手机号最多关联10项有效资产」,不写关联也不改手机号。
上线顺序约束(必须写进发布说明): 后端不再对「已有主号」直接拒绝,因此本 Change 与 H5 必须同批上线;H5 必须支持预填已在账号上的手机号并对该号码发验证码。若后端先行或 H5 未改造,存量客户会落入 need_bind_phone=true 但提交任何号码都被拒的死锁。
4. 并发与加锁
- 固定加锁顺序:按手机号行
id ASC加锁(等价的实现是固定「旧号 → 新号」,但必须与id ASC结果一致,避免两条路径得出相反顺序)。bind-phone只锁一行,change-phone锁两行;统一顺序后两者不会形成 A→B / B→A 死锁环。既有先例:AUG26-008 的「统一加锁顺序」实施决策。 - 同号并发建联以手机号行
FOR UPDATE串行:计数与插入在同一临界区内,保证不越过十项上限。 - 同
(phone, asset_type, asset_id)并发由部分唯一索引兜底,唯一冲突映射为幂等成功而非报错。 - 加锁顺序规则写在用例层(Application/旧 Service),事务内不得再发起外部 I/O(ENG-TX-001)。
5. 换绑原子迁移与冲突边界
在同一事务内、加锁之后:
- 计算「新号码现有有效关系数 + 旧号码待迁移有效关系数」;
- 超过十项 → 返回「该手机号最多关联10项有效资产」并整次失败,旧、新关系均不变;
- 通过 → 将旧号码全部有效关系原子迁移至新号码(关系本身保持有效,只换归属号码);
- 若迁移与「新号码已存在的同资产有效关系」冲突(例如新号码曾是他人已停用手机号记录持有者的验证号码),部分唯一索引报错 → 整次换绑失败并回滚,旧、新关系均不变。该边界是有意接受的:失败是安全的、可人工处理的,好过产生两条并存的有效关系。
事务边界与既有实现一致:验证码在事务外校验(消费即删除),关系迁移与账号手机号改写同事务。不写遗留列 tb_personal_customer.phone(无模型映射,写它等于制造无法读取的事实漂移)。
审计只写脱敏手机号。
6. 读侧投影、脱敏与导出列组兼容
- 列表与详情一次
IN聚合:新增按(asset_type, []assetID)的批量读,返回map[assetID][]phone,列表按当页资产集合一次查询后装配,禁止逐资产查询。详情为单资产单次查询。 - 两个导出场景都补「关联手机号」列(
internal/exporter/iot_card_scene.go:39-41与internal/exporter/device_scene.go:47),导出按本批资产集合批量查询。 - 设备导出新列并入尾部并令
deviceExportTailHeaderCount递增,buildDeviceExportRow同步追加。反解必须保留旧表头兼容分支:先用新尾列数试整除,不整除时回退到旧尾列数再判定;否则历史任务凭ResolvedHeaders重导出时列组数会算成 0。 - 完整手机号只在读侧:资产列表、详情、两类导出与批量任务结果向具备资产数据权限的账号返回完整值。
- 审计、日志与错误只写脱敏值:新建动作码与资源定义,不得复用会写明文手机号的既有审计资源路径(
writer.go:397-403、:614-618对既有手机号资源写完整值)。脱敏口径沿用既有前 3 位 +****+ 后 4 位。
7. 批量任务与 CSV 导入
- 无统一导入框架,新增第 6 个独立场景(详见 tasks 2.5 的 B1–B16 全清单)。
- 任务明细持久化解绑当时的完整手机号快照:解绑后关系即失效,只有快照能事后满足「批量任务结果展示完整手机号」;任务明细是业务事实表,读接口按数据范围保护,不属于日志。
- 上传格式 CSV,沿用既有 BOM 去除与 GBK 回退解码。
- 列:资产标识(必填,复用既有资产标识解析)+ 备注(可选);解绑原因由任务级必填字段与二次确认提供,不设行内原因列。
- 逐行独立事务、成功行提交、失败行保留原状、不设行数硬上限、任务级失败不产生行明细;行号自数据首行起计。
8. 权限与统一文案
- 路由组级 gate 沿用既有超管/平台先例(
internal/routes/wecom.go:16-21)。 - 业务层每次解绑仍按资产数据范围复核(ENG-AUTHZ-001),不得只依赖路由角色中间件。
- 单项解绑以路径主键即「必须指定一条关系」;批量与 CSV 按资产解除全部有效关系,逐资产独立结果。
- 三态(越权 / 资产不存在 / 已无有效关系)统一为
无权限操作该资源或资源不存在,新增常量供新用例使用;既有 44 处内联字面量按 As-Is 保留,不在本 Change 统一。三态收敛同时满足「无权限与不存在不得形成可枚举差异」。
动作契约
H5
POST /api/c/v1/auth/bind-phone(既有):已有主号且提交号码与主号一致、验证码有效时幂等建立当前资产关联;不一致仍拒绝;无资产身份时只处理账号手机号。响应形状不变。POST /api/c/v1/auth/change-phone(既有):新号总数校验通过后原子迁移旧号全部有效关系;冲突或超限整次失败并回滚。- 登录响应
need_bind_phone按第 2 节三支判定;字段语义扩宽但形状不变。
后台
GET /phone-asset-associations:仅超级管理员、平台用户,先按资产数据范围过滤;支持资产标识、手机号、关联状态、创建时间筛选,返回资产、手机号、建立时间、建立来源与状态。DELETE /phone-asset-associations/:id:路径主键即指定一条关系;必须二次确认并填写原因(1~500 字符);锁定关系后复核资产数据范围,标记失效并逐条审计。POST /phone-asset-associations/batch-unbind:去重的资产集合、原因与二次确认;每项资产独立解除全部有效关系,返回成功数、失败数与逐项结果。- CSV 解绑导入:任务级原因与二次确认必填;逐行按资产标识处理,行内失败不影响其他行。
- 不提供创建或补录入口。
Risks / Trade-offs
- [H5 未同批上线导致死锁] → 契约与发布说明同时声明同批上线;后端不再直接拒绝已有主号是本 Change 的显式破坏性变更。
- [存量客户提示面扩大] →
need_bind_phone取值面扩大是预期行为,必须与 H5 同批;不通过灰度开关掩盖。 - [并发建联越过十项上限] → 手机号行
FOR UPDATE串行化计数与插入;唯一索引兜底同资产并发并映射为幂等成功。 - [换绑迁移撞唯一索引] → 整次换绑失败回滚,旧、新关系均不变;该边界有意接受,不引入自动合并或丢弃。
- [禁用手机号记录被复用导致编号碰撞] → 同一失败边界覆盖,事务回滚后由人工处理,不产生并存有效关系。
- [设备导出列组反解回归] → 新列并入尾部并递增尾列数,同时保留旧表头兼容分支,回归覆盖历史任务重导出。
- [审计继承明文手机号] → 新动作码与资源定义独立,不复用既有写明文值的手机号资源路径。
- [脱敏被访问日志绕过] → 字段名脱敏清单不含 phone,故新代码必须显式脱敏,不能依赖通用清理器。
- [列表 N+1] → 强制一次批量聚合;验收以查询次数而非响应内容为准。
- [换货/导入/后台编辑误写关系] → 三处路径已确认零手机号逻辑,采用反向断言守护,不接受「顺手补写」。
- [上下文证据缺失] → 实施不触碰证据文件,归档并同步主 Spec(含新路由索引)后必须补齐,否则
scripts/context-health.sh失败。
溯源说明
「仓库不存在统一导入任务框架」的结论出自 openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md:6;AUG26-008 的归档文档通篇不涉及导入框架。本 Change 的导入场景改动面以 add-shop-salesperson-groups 已落地的 shop_business_owner_import 为模板。
Migration Plan
- 新增成对迁移(
.up.sql/.down.sql)创建关联表与三类索引,编号按实施时migrations/目录最大编号顺延;down带有效性守卫。 - 不回填历史。开关关闭时不查询、不创建、不删除关系。
- 按 ENG-TEST-001 在维护者指定的
junhong_cmp_testPostgreSQL + Redis DB 6 验证;fixture 只增删本 Change 自己的记录,禁止重置整库。 - 单批发布:新表对旧代码无影响;本 Change 与 H5 必须同批上线。
归档后动作(不属实施任务)
实施阶段不修改 docs/verification/context-reset/requirement-evidence.json 与 entry-capability-requirement-matrix.json。归档并同步主 Spec(含新路由索引)之后,必须补齐这两个文件,否则 scripts/context-health.sh 失败。