diff --git a/.scratch/customer-binding-architecture/PRD.md b/.scratch/customer-binding-architecture/PRD.md new file mode 100644 index 0000000..5b3c68a --- /dev/null +++ b/.scratch/customer-binding-architecture/PRD.md @@ -0,0 +1,126 @@ +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+ 张无虚拟号的卡手动补充了虚拟号作为应急措施,以保障线上可用性。数据回滚由业务方自行处理,不在本次实现范围内。 diff --git a/.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md b/.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md new file mode 100644 index 0000000..1d33329 --- /dev/null +++ b/.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md @@ -0,0 +1,50 @@ +Status: done + +# CustomerBinding 模块骨架 + 有虚拟号路径替换(纯重构) + +## What to build + +建立 `internal/service/customer_binding` 包,对外暴露 `Bind()` 和 `OwnsAsset()` 两个接口,将现有有虚拟号路径的逻辑迁入。同时将代码库中 6 处归属验证调用点统一替换为新接口。 + +**本切片是纯重构,不改变任何现有行为。** + +### Bind(ctx, tx, customerID, assetType, assetID) + +封装创建客户与资产绑定记录的逻辑,当前只实现有虚拟号路径: +- IoT 卡有虚拟号 → 写 `tb_personal_customer_device`(与现在 `bindAsset` 行为一致) +- 设备 → 写 `tb_personal_customer_device`(设备必然有虚拟号) +- 首次绑定时触发 `markAssetAsSold()`(firstEverBind 逻辑保持不变) + +### OwnsAsset(ctx, customerID, assetType, assetID) bool + +封装归属验证逻辑,当前只实现有虚拟号路径: +- 查 `tb_personal_customer_device WHERE virtual_no = ? AND status = 1` +- 内部统一强制 `status = 1` 过滤(修复现有部分实现缺少此过滤的安全缺口) + +### 替换 6 处调用点 + +以下调用点替换为 `CustomerBinding` 的方法: + +| 调用点 | 替换目标 | +|--------|---------| +| `client_auth/service.go: bindAsset()` | `CustomerBinding.Bind()` | +| `handler/app/client_realname.go:92` | `CustomerBinding.OwnsAsset()` | +| `handler/app/client_device.go:82` | `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()` | + +exchange service 的 `customerOwnsAsset()` 在切片 3 中处理。 + +## Acceptance criteria + +- [ ] `internal/service/customer_binding` 包存在,对外暴露 `Bind` 和 `OwnsAsset` +- [ ] 有虚拟号的 IoT 卡登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致) +- [ ] 设备登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致) +- [ ] 被禁用(status=0)的绑定记录不能通过 `OwnsAsset` 验证(修复安全缺口) +- [ ] 6 处调用点全部替换完毕,原有私有方法(resolveAssetBindingKey、isCustomerOwnAsset 等)可删除 +- [ ] 有虚拟号卡的实名认证、购买套餐、设备操作全链路与现在行为一致 + +## Blocked by + +None - 可立即开始 diff --git a/.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md b/.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md new file mode 100644 index 0000000..cbe0d5d --- /dev/null +++ b/.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md @@ -0,0 +1,52 @@ +Status: done + +# 无虚拟号单卡 C 端完整链路 + +## Parent + +`.scratch/customer-binding-architecture/PRD.md` + +## What to build + +扩展 `CustomerBinding` 模块,使无虚拟号的独立 IoT 卡(单卡)能够完整走通 C 端业务流程:登录绑定、实名认证、购买套餐。 + +**仅影响 IoT 卡(单卡)。设备资产必然有虚拟号,不在本切片处理范围内。** + +### 扩展 Bind() + +当资产为 IoT 卡且 `virtual_no` 为空时: +- 写 `tb_personal_customer_iccid`(按 ICCID 绑定),而非 `tb_personal_customer_device` +- `PersonalCustomerICCIDStore` 已有完整实现,直接注入使用 + +当资产为 IoT 卡且 `virtual_no` 不为空时:行为与切片 1 一致,不变。 + +### 修复 firstEverBind + markAssetAsSold + +当前 `firstEverBind` 通过查 `tb_personal_customer_device WHERE virtual_no = ""` 来判断是否首次绑定,对无虚拟号的卡完全失效(所有无虚拟号的卡共用同一个空 key)。 + +修复方式:在 `Bind()` 内,按路径分别判断首绑: +- 有虚拟号路径:查 `tb_personal_customer_device WHERE virtual_no = ?` +- 无虚拟号路径:查 `tb_personal_customer_iccid WHERE iccid = ?` + +首次绑定后正常触发 `markAssetAsSold()`,将卡的 `asset_status` 从在库(1)更新为已销售(2)。 + +### 扩展 OwnsAsset() + +当资产为 IoT 卡时: +- 若卡有 `virtual_no`:查 `tb_personal_customer_device`(与切片 1 一致) +- 若卡无 `virtual_no`:查 `tb_personal_customer_iccid WHERE iccid = ? AND status = 1` + +当资产为设备时:行为不变,只查 `tb_personal_customer_device`。 + +## Acceptance criteria + +- [ ] 无虚拟号的单卡 C 端登录后,`tb_personal_customer_iccid` 中出现对应的绑定记录 +- [ ] 无虚拟号单卡首次登录后,`tb_iot_card.asset_status` 变为 2(已销售) +- [ ] 无虚拟号单卡登录后,实名认证接口返回成功(不报"无权限") +- [ ] 无虚拟号单卡登录后,购买套餐接口归属验证通过 +- [ ] 有虚拟号卡的全部行为与切片 1 完成后保持一致(无回归) +- [ ] 设备资产的全部行为不受影响 + +## Blocked by + +`.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md` diff --git a/.scratch/customer-binding-architecture/issues/03-exchange-binding-migration-fix.md b/.scratch/customer-binding-architecture/issues/03-exchange-binding-migration-fix.md new file mode 100644 index 0000000..a82b425 --- /dev/null +++ b/.scratch/customer-binding-architecture/issues/03-exchange-binding-migration-fix.md @@ -0,0 +1,45 @@ +Status: done + +# 换货绑定迁移修复 + +## Parent + +`.scratch/customer-binding-architecture/PRD.md` + +## What to build + +在 `CustomerBinding` 模块上实现 `Migrate(ctx, tx, oldAsset, newAsset)` 方法,替换换货服务中现有的 `switchCustomerBindingWithTx` 和 `ensureNewAssetBindingAvailableWithTx` 逻辑,修复有虚拟号旧卡换无虚拟号新卡时被误拦截的 bug。 + +### Migrate(ctx, tx, oldAsset, newAsset) + +处理四种虚拟号组合,仅涉及 IoT 卡(设备必然有虚拟号,只有有→有一种情形): + +| 旧卡 | 新卡 | 行为 | +|------|------|------| +| 有虚拟号,pcd 有绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no 为新卡虚拟号 | +| 有虚拟号,pcd 有绑定 | 无虚拟号 | 禁用旧 pcd 记录(status=0)+ 创建新 pci ICCID 绑定 | +| 有虚拟号,pcd 无绑定 | 无虚拟号 | 跳过,无需迁移 | +| 无虚拟号,pci 有绑定 | 任意 | 迁移 pci 记录到新卡 ICCID(或新卡虚拟号路径) | +| 任意 | 任意(无绑定) | 跳过 | + +### 修复换货服务 + +- `switchCustomerBindingWithTx` 替换为调用 `CustomerBinding.Migrate()`,移除函数内 `newKey == ""` 的误拦截逻辑 +- `ensureNewAssetBindingAvailableWithTx` 更新:当新卡无虚拟号时,不再拦截换货,因为 `Migrate()` 已能正确处理此情形(无绑定时跳过,有绑定时迁移到 pci) + +### 根本 bug 说明 + +原 `switchCustomerBindingWithTx` 在执行阶段做了 `if newKey == "" { return error }` 的检查,但没有先确认旧资产是否实际存在绑定记录。旧卡有虚拟号但无任何客户绑定时,也会被误拦截报错"新资产无法承接客户绑定"。 + +## Acceptance criteria + +- [ ] 旧卡有虚拟号 + 有客户绑定,换货为有虚拟号新卡:pcd 记录的 virtual_no 正确更新为新卡虚拟号 +- [ ] 旧卡有虚拟号 + 有客户绑定,换货为无虚拟号新卡:旧 pcd 记录 status 变为 0,pci 表出现新卡 ICCID 的绑定记录 +- [ ] 旧卡有虚拟号 + 无客户绑定,换货为无虚拟号新卡:换货正常完成,不报错,不写入任何绑定记录 +- [ ] 旧卡无虚拟号,换货为任意新卡:换货正常完成 +- [ ] 有虚拟号换有虚拟号(原有场景):行为与修复前一致,无回归 +- [ ] 换货完成后,客户通过新卡(无论有无虚拟号)能正常通过归属验证 + +## Blocked by + +`.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md`