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

127 lines
9.6 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.
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 端用户(有虚拟号的卡)
5. 作为一个持有有虚拟号 IoT 卡的 C 端用户,我的所有现有功能不受本次改动影响,以便升级对我透明无感知。
6. 作为一个持有有虚拟号 IoT 卡的 C 端用户,当我的绑定关系被禁用后,我不能通过该绑定关系访问资产,以便系统安全边界得到保障。
### 后台运营人员(换货)
7. 作为后台运营人员,我想将一张有虚拟号的旧卡换货为一张无虚拟号的新卡,并且换货能正常完成,以便不再因无虚拟号而被系统错误拦截。
8. 作为后台运营人员,当旧卡有客户绑定记录、新卡无虚拟号时,我希望系统在换货完成后自动将客户绑定迁移到新卡的 ICCID以便客户换货后仍能访问新卡。
9. 作为后台运营人员,当旧卡没有任何客户绑定记录时,无论旧卡是否有虚拟号,换货都应该正常完成,以便换货流程不被无意义的条件拦截。
### 系统(数据一致性)
10. 作为系统,当客户首次绑定一张无虚拟号的卡时,应正确将该卡的 `asset_status` 从在库更新为已销售,以便轮询系统能感知到该卡有真实用户在使用。
11. 作为系统,当客户的绑定关系被禁用(`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 应为 0`tb_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_device``virtual_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+ 张无虚拟号的卡手动补充了虚拟号作为应急措施,以保障线上可用性。数据回滚由业务方自行处理,不在本次实现范围内。