## 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` 失败。