Files
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

15 KiB
Raw Permalink Blame History

Context

以下为开工核查确认的现状约束,全部来自实际代码、配置与生效库:

  • need_bind_phone 全仓只有一个计算点 internal/service/client_auth/service.go:892-931,口径是「客户无 is_primary=true AND status=1tb_personal_customer_phone 记录」且全局开关 cfg.Client.RequirePhoneBinding 为真;入参 assetType/assetID 只写入 JWT:910),不参与任何判定。开关读取点全仓仅 :899 一处,默认 truepkg/config/config.go:107defaults/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_idpkg/auth/jwt.go:10-16),资产类型取值为 iot_card / devicepkg/constants/iot.go:142-143),请求上下文可经 middleware.GetCurrentAssetpersonal_auth.go:128-133)取出。
  • BindPhoneservice.go:294-378)现状在客户已有主手机号时立即返回 CodeAlreadyBoundPhone:301-308);成功后只写 tb_personal_customer_phone,不触碰任何资产关系。
  • ChangePhoneservice.go:381-471)两个验证码在事务外校验(:410:415,校验即消费),事务只包「锁定主号行 → 查新号占用 → 原地 UPDATE phone 列(:448-453)→ 审计」。
  • 手机号唯一事实在 tb_personal_customer_phoneinternal/model/personal_customer_phone.go:11-23PersonalCustomer 模型无 phone 字段(internal/model/personal_customer.go:9-17);生效库仍有遗留列 tb_personal_customer.phone,无 Go 模型映射(internal/store/postgres/personal_customer_store.go:42 注释)。
  • 资产读侧 DTOinternal/model/dto/{iot_card,device,asset}_dto.go)中手机号零命中,没有完整手机号投影先例。
  • 设备导出的列组反解依赖固定常量:internal/exporter/device_scene.go:14-18base 6 / group 5 / tail 5cardGroupCountFromHeaders:427-434 的整除判断。
  • 审计 writer 对既有手机号资源写完整手机号internal/infrastructure/audit/writer.go:397-403:614-618pkg/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-1086internal/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_typeasset_idphone(字符串)、status、建立时间、建立来源与失效信息。

  • 必须含 asset_typeiot_carddevice 的 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 claimphone 是登录时快照,换绑后到下次登录前仍是旧号。

开关关闭分支必须完全短路,否则会出现「关闭开关却写库」的越权写入。

本项不新增后端 gate。 现状确认「未绑定不得访问资产业务入口」由前端实施:need_bind_phone 无任何后端消费方,认证中间件不读手机号,手机号在链路上只用于展示(internal/handler/app/client_asset.go:224)。关系缺失不改变任何资源授权——授权始终由 OwnsAssetApplyShopFilter 决定。

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-41internal/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:6AUG26-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.jsonentry-capability-requirement-matrix.json。归档并同步主 Spec含新路由索引之后必须补齐这两个文件否则 scripts/context-health.sh 失败。