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 通过
163 lines
15 KiB
Markdown
163 lines
15 KiB
Markdown
## 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/O(ENG-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 的 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
|
||
|
||
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` 失败。
|