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+ 张无虚拟号的卡手动补充了虚拟号作为应急措施，以保障线上可用性。数据回滚由业务方自行处理，不在本次实现范围内。
