Files
junhong_cmp_fiber/.scratch/customer-binding-architecture/PRD.md
2026-06-22 12:15:40 +09:00

9.6 KiB
Raw Blame History

Status: ready-for-agent

PRD客户绑定体系重构与资产归属验证统一

Problem Statement

C 端用户在使用没有虚拟号的 IoT 卡时,整个业务流程完全失效:登录后无法实名认证、无法购买套餐、无法操作设备。根本原因是系统的客户绑定机制依赖虚拟号(virtual_no)作为唯一标识,而虚拟号在 IoT 卡上是可选字段,线上有 12,000+ 张无虚拟号的卡且确认会流向 C 端用户。

与此同时,换货流程在旧卡有虚拟号、新卡无虚拟号时会被错误拦截,即使旧卡实际上没有任何客户绑定记录,换货依然报错"新资产无法承接客户绑定"。

从设计层面看,tb_personal_customer_iccid 表(按 ICCID 绑定早已建好Store 层也实现完整,但从未被任何 Service 或 Handler 接入。同时,"判断某张卡/设备是否属于某个客户"这个核心安全规则,散落在 6 个文件中以 4 种不同方式实现,部分实现缺少对已禁用绑定记录的过滤,存在安全缺口。

Solution

建立统一的 CustomerBinding 模块,封装客户与资产之间的绑定创建和归属验证逻辑,屏蔽"有虚拟号走 tb_personal_customer_device / 无虚拟号走 tb_personal_customer_iccid"的路由细节。所有调用方只需传入资产信息,不感知底层用了哪张表。

在此基础上,修复换货服务中校验与执行逻辑混淆的 bug并将资产解析和 IoT 卡状态字段整理为后续阶段任务。

User Stories

C 端用户(无虚拟号的卡)

  1. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在首次登录时成功绑定我的卡,以便后续能正常使用所有 C 端功能。
  2. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在绑定卡后能正常提交实名认证,以便激活卡的完整功能。
  3. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想购买流量套餐,以便为我的卡充值续费。
  4. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在换货后仍能访问新卡并正常操作,以便换货不影响我的使用体验。

C 端用户(有虚拟号的卡)

  1. 作为一个持有有虚拟号 IoT 卡的 C 端用户,我的所有现有功能不受本次改动影响,以便升级对我透明无感知。
  2. 作为一个持有有虚拟号 IoT 卡的 C 端用户,当我的绑定关系被禁用后,我不能通过该绑定关系访问资产,以便系统安全边界得到保障。

后台运营人员(换货)

  1. 作为后台运营人员,我想将一张有虚拟号的旧卡换货为一张无虚拟号的新卡,并且换货能正常完成,以便不再因无虚拟号而被系统错误拦截。
  2. 作为后台运营人员,当旧卡有客户绑定记录、新卡无虚拟号时,我希望系统在换货完成后自动将客户绑定迁移到新卡的 ICCID以便客户换货后仍能访问新卡。
  3. 作为后台运营人员,当旧卡没有任何客户绑定记录时,无论旧卡是否有虚拟号,换货都应该正常完成,以便换货流程不被无意义的条件拦截。

系统(数据一致性)

  1. 作为系统,当客户首次绑定一张无虚拟号的卡时,应正确将该卡的 asset_status 从在库更新为已销售,以便轮询系统能感知到该卡有真实用户在使用。
  2. 作为系统,当客户的绑定关系被禁用(status=0)后,归属验证应拒绝该客户访问对应资产,以便被撤销的绑定不再赋予访问权限。

Implementation Decisions

1. 新增 CustomerBinding 模块

internal/service/ 下建立 customer_binding 包,对外暴露两个核心接口:

  • Bind(ctx, tx, customerID, assetType, assetID) — 创建或更新客户与资产的绑定关系;对于卡资产,有虚拟号写 tb_personal_customer_device,无虚拟号写 tb_personal_customer_iccid
  • OwnsAsset(ctx, customerID, assetType, assetID) bool — 验证客户是否持有该资产的有效绑定(status=1);按资产类型路由到对应绑定表查询
  • Migrate(ctx, tx, oldAsset, newAsset) — 换货时迁移绑定关系;处理四种组合(有→有、有→无、无→有、无→无)

OwnsAsset 内部统一强制 status=1 过滤,解决当前部分实现缺少此过滤的安全缺口。

2. 绑定路由规则(卡资产)

旧卡 新卡 Migrate 行为
有虚拟号,有 pcd 绑定 有虚拟号 更新 pcd 记录的 virtual_no
有虚拟号,有 pcd 绑定 无虚拟号 禁用旧 pcd 记录 + 创建 pci ICCID 绑定
有虚拟号,无绑定 无虚拟号 跳过(无需迁移)
无虚拟号 任意 迁移 pci 记录(若存在)到新卡 ICCID

3. 接入 CustomerBinding 的调用点

以下调用点需要替换为 CustomerBinding 模块:

  • client_auth/service.go: bindAsset() — 使用 CustomerBinding.Bind()
  • client_auth/service.go: resolveAssetBindingKey() — 废弃,逻辑内聚到 CustomerBinding
  • handler/app/client_realname.go: ExistsByCustomerAndDevice() — 替换为 CustomerBinding.OwnsAsset()
  • handler/app/client_device.go: ExistsByCustomerAndDevice() — 替换为 CustomerBinding.OwnsAsset()
  • handler/app/client_wallet.go: isCustomerOwnAsset() — 替换为 CustomerBinding.OwnsAsset()
  • handler/app/client_asset.go: isCustomerOwnAsset() — 替换为 CustomerBinding.OwnsAsset()
  • service/client_order/service.go: checkAssetOwnership() — 替换为 CustomerBinding.OwnsAsset()
  • service/exchange/service.go: switchCustomerBindingWithTx() — 替换为 CustomerBinding.Migrate()

4. 修复 switchCustomerBindingWithTx 的校验混淆

当前 switchCustomerBindingWithTx(执行函数)在执行阶段重复做了 ensureNewAssetBindingAvailableWithTx(校验函数)的判断,且条件更严(不查实际记录数,只看 key 是否为空)。

修复方式:将 switchCustomerBindingWithTx 改为调用 CustomerBinding.Migrate(),移除函数内的 newKey=="" 拦截逻辑。ensureNewAssetBindingAvailableWithTx 同步更新:当 newKey=="" 时,不再拦截,因为 Migrate() 已能正确处理此情形。

5. asset_status 首销标记修复

bindAsset 中通过 firstEverBind(查 virtual_no="" 的记录数)来判断是否首次绑定并触发 markAssetAsSold()。无虚拟号的卡因为 key 为空,导致此逻辑失效,asset_status 永远停在在库。

修复方式:在 CustomerBinding.Bind() 内,对 IoT 卡分别按各自绑定表查询首绑状态,再触发 markAssetAsSold()

6. 不引入新的数据库表或字段

tb_personal_customer_iccid 表和 PersonalCustomerICCIDStore 已经存在且完整,本次只是接入,不需要迁移或 schema 变更。

7. 现有 personal_customer_device 数据不迁移

有虚拟号的卡现有绑定数据保持不变,继续走 tb_personal_customer_device 路径。只有无虚拟号的卡新登录时才写 tb_personal_customer_iccid

Testing Decisions

本项目禁止自动化测试,使用 PostgreSQL MCP 和 Postman/curl 手动验证。

验证重点场景:

  1. 无虚拟号卡首次登录:用无虚拟号卡的 ICCID 登录后,tb_personal_customer_iccid 应出现绑定记录,tb_iot_card.asset_status 应变为 2已销售
  2. 无虚拟号卡归属验证:登录后调用实名认证接口,应返回成功而非"无权限"
  3. 无虚拟号卡购买套餐:购买套餐接口应正常通过归属验证
  4. 有虚拟号换无虚拟号(旧卡有绑定):换货完成后,tb_personal_customer_device 旧记录 status 应为 0tb_personal_customer_iccid 应出现新卡 ICCID 的绑定记录
  5. 有虚拟号换无虚拟号(旧卡无绑定):换货应正常完成,无报错,不写入任何绑定记录
  6. 有虚拟号换有虚拟号(回归)现有流程不受影响pcd 表记录的 virtual_no 正确更新到新卡
  7. 禁用绑定后归属验证:将某条 pcd 或 pci 记录 status 设为 0对应客户调用归属验证应返回无权限
  8. 有虚拟号卡回归(全流程):确认有虚拟号卡的登录、实名、购买套餐流程无任何变化

Out of Scope

  • IoT 卡状态字段整理status / asset_status / activation_status 语义重叠问题):影响面广,单独规划
  • 资产解析函数统一12 个 resolve 变体合并):不影响当前 bug单独规划
  • tb_personal_customer_iccid 换货迁移的历史数据回填:历史上无虚拟号卡的用户无绑定记录,不做回填,用户需重新登录
  • tb_personal_customer_devicevirtual_no="" 的历史垃圾数据清理需先确认实际数量SQL 查询),作为独立数据修复任务

Further Notes

  • 线上有 12,000+ 张无虚拟号的 IoT 卡,这是合法业务数据,不是数据质量问题
  • tb_personal_customer_iccid 的 Store 已有完整的 CRUD 实现Create、ExistsByCustomerAndICCID、GetByCustomerID、CreateOrUpdateLastUsed 等),无需重写
  • 换货 bug 的直接触发路径:completeExchangeWithTx → switchCustomerBindingWithTx,在执行阶段因 newKey=="" 报错,即使旧卡实际无任何客户绑定记录
  • 建议在上线前执行:SELECT COUNT(*) FROM tb_personal_customer_device WHERE (virtual_no IS NULL OR virtual_no = '') AND deleted_at IS NULL 确认是否有历史脏数据需要清理
  • 临时线上补丁(知悉)2026-06-18 已将线上 12,000+ 张无虚拟号的卡手动补充了虚拟号作为应急措施,以保障线上可用性。数据回滚由业务方自行处理,不在本次实现范围内。