Files
junhong_cmp_fiber/openspec/changes/archive/2026-09-15-add-phone-asset-associations/design.md
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 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 通过
2026-09-15 11:54:56 +08:00

163 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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` 改为:
1. 无主手机号 → `true`
2. 有主手机号但当前手机号未与当前访问资产存在有效关系 → `true`
3. 已存在有效关系 → `false`
4. 全局开关关闭 → 恒 `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/OENG-TX-001
### 5. 换绑原子迁移与冲突边界
在同一事务内、加锁之后:
1. 计算「新号码现有有效关系数 + 旧号码待迁移有效关系数」;
2. 超过十项 → 返回「该手机号最多关联10项有效资产」并整次失败旧、新关系均不变
3. 通过 → 将旧号码全部有效关系原子迁移至新号码(关系本身保持有效,只换归属号码);
4. **若迁移与「新号码已存在的同资产有效关系」冲突**(例如新号码曾是他人已停用手机号记录持有者的验证号码),部分唯一索引报错 → 整次换绑失败并回滚,旧、新关系均不变。该边界是**有意接受**的:失败是安全的、可人工处理的,好过产生两条并存的有效关系。
事务边界与既有实现一致:验证码在事务外校验(消费即删除),关系迁移与账号手机号改写同事务。**不写遗留列 `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 的 B1B16 全清单)。
- **任务明细持久化解绑当时的完整手机号快照**:解绑后关系即失效,只有快照能事后满足「批量任务结果展示完整手机号」;任务明细是业务事实表,读接口按数据范围保护,不属于日志。
- **上传格式 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`路径主键即指定一条关系必须二次确认并填写原因1500 字符);锁定关系后复核资产数据范围,标记失效并逐条审计。
- `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
1. 新增成对迁移(`.up.sql`/`.down.sql`)创建关联表与三类索引,编号按实施时 `migrations/` 目录最大编号顺延;`down` 带有效性守卫。
2. 不回填历史。开关关闭时不查询、不创建、不删除关系。
3. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证fixture 只增删本 Change 自己的记录,禁止重置整库。
4. 单批发布:新表对旧代码无影响;本 Change 与 H5 必须同批上线。
## 归档后动作(不属实施任务)
实施阶段不修改 `docs/verification/context-reset/requirement-evidence.json``entry-capability-requirement-matrix.json`。归档并同步主 Spec含新路由索引之后必须补齐这两个文件否则 `scripts/context-health.sh` 失败。