fix: 修正零售价架构错误 + 清理旧微信配置 + 归档提案 + 前端接口文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s

1. 修正 retail_price 架构:
   - 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
     (上级只能改下级成本价,不能改零售价)
   - 新增 PATCH /api/admin/packages/:id/retail-price 接口
     (代理自己改自己的零售价,校验 retail_price >= cost_price)

2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
   - 删除 config.yaml 中 wechat.official_account 配置节
   - 删除 NewOfficialAccountApp() 旧工厂函数
   - 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
   - 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释

3. 归档四个已完成提案到 openspec/changes/archive/

4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)

5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
This commit is contained in:
2026-03-19 17:39:43 +08:00
parent 9bd55a1695
commit b9733c4913
98 changed files with 3665 additions and 571 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

@@ -0,0 +1,164 @@
## Context
### 当前状态
系统即将启动客户端C 端)接口体系开发(`/api/c/v1`),但存在以下阻塞项:
1. **价格计算错误**`ShopPackageAllocation` 缺少 `retail_price` 字段,`GetPurchasePrice()``validatePackages()` 始终使用 `Package.SuggestedRetailPrice`,代理无法设定自己的零售价
2. **佣金误触发**:后台所有订单(包括代理自购)都可能触发一次性佣金,缺少订单来源区分
3. **充值事务不一致**`HandlePaymentCallback` 中状态更新和支付信息更新使用 `s.db` 而非事务内 `tx`,存在半提交风险
4. **基础字段缺失**:客户端接口和换货系统依赖 `asset_status``generation``operator_type` 等字段,目前模型中均不存在
5. **旧接口残留**`/api/h5` 下的旧接口使用 B 端认证体系,与新的 C 端体系冲突,需完整清理
### 约束
- 所有字段新增使用 `NOT NULL DEFAULT` 确保存量数据兼容
- 数据库迁移可在线执行,不需停机
- 旧接口删除后 bootstrap、路由注册、文档生成器必须同步清理否则编译失败
- 本提案新增 1 个后台接口:`PATCH /api/admin/packages/:id/retail-price`(代理修改自己的零售价)
## Goals / Non-Goals
**Goals:**
- 修复 BUG-1代理零售价、BUG-2佣金误触发、BUG-4充值事务
- 为 IotCard/Device 新增 `asset_status``generation` 字段
- 为 Order/PackageUsage/AssetRechargeRecord 新增 `generation` 字段
- 为 Order 新增 `source` 字段
- 为 AssetRechargeRecord 新增 `operator_type` 和强充关联字段
- 为 Carrier 新增实名链接配置字段
- 变更 PersonalCustomer.wx_open_id 索引
- 完整删除旧 H5 接口和旧个人客户登录接口
- 生成数据库迁移文件
**Non-Goals:**
- 不实现任何客户端 API 接口(属于提案 1~3
- 不实现 ExchangeOrder 换货模型(属于提案 3
- 不实现 PersonalCustomerOpenID 模型(属于提案 1
- 不修改后台管理界面
-`PATCH /api/admin/packages/:id/retail-price` 外,不新增其他 API 路由
- 不实现 asset_status 的状态流转逻辑(仅新增字段,流转逻辑在后续提案中实现)
## Decisions
### 决策 1retail_price 字段设计
**选择**`tb_shop_package_allocation` 新增 `retail_price bigint NOT NULL DEFAULT 0`。分配创建时自动设为 `Package.SuggestedRetailPrice`。约束 `retail_price >= cost_price`
**理由**:最小变更原则。字段放在分配表而非套餐表,因为每个代理可以有不同的零售价。默认值设为建议零售价,确保存量数据行为不变。
**价格计算修正**
- `GetPurchasePrice()`:代理渠道返回 `allocation.retail_price`,平台渠道返回 `Package.SuggestedRetailPrice`
- `validatePackages()` 第 148 行:`totalPrice += pkg.SuggestedRetailPrice` 改为按渠道取价
- 代理渠道额外校验:`retail_price < cost_price` 时该套餐不展示(防止亏损售卖)
**cost_price 分配锁定**:存在下级分配记录时,上级禁止修改该套餐的 `cost_price`。实现方式:修改 cost_price 前查询 `ShopPackageAllocation WHERE allocator_shop_id = 当前店铺 AND package_id = 目标套餐`,有记录则拒绝。
**备选方案**:在套餐表新增 `agent_retail_price` 字段。但每个代理需要不同的零售价,单字段不够,放分配表更合理。
### 决策 2Order.source 字段设计
**选择**`tb_order` 新增 `source varchar(20) NOT NULL DEFAULT 'admin'`,值域 `admin` | `client`
**理由**:默认 `admin` 确保存量订单行为不变(不触发一次性佣金)。佣金触发条件改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,实现双重判断。
**影响点**
- `commission_calculation/service.go``triggerOneTimeCommissionForCardInTx``triggerOneTimeCommissionForDeviceInTx` 中的 `IsPurchaseOnBehalf` 判断,增加 `order.Source == "client"` 条件
- `order/service.go``CreateAdminOrder` 设置 `Source: "admin"`(默认值已满足,可不显式设置)
### 决策 3充值回调事务修复
**选择**`AssetRechargeStore``UpdateStatusWithOptimisticLock``UpdatePaymentInfo` 方法新增 `tx *gorm.DB` 参数,回调函数内使用事务 `tx` 调用。
**理由**:当前这两个方法直接使用 `s.db.WithContext(ctx)`,不在事务保护范围内。改为接受 `tx` 参数,确保充值状态变更、支付信息更新、钱包入账在同一事务内完成。
**备选方案**:使用 GORM 的 `Session(&gorm.Session{NewDB: true})` 从 ctx 中提取事务。但显式传 tx 更清晰,符合项目现有模式(参考 `order/service.go` 中的事务用法)。
### 决策 4generation 字段设计
**选择**`generation int NOT NULL DEFAULT 1`,在 IotCard、Device、Order、PackageUsage、AssetRechargeRecord 五个表新增。创建关联记录时从资产当前 generation 复制(写时快照)。
**理由**:写时快照方案简单可靠,无需 JOIN 查询。客户端按 generation 过滤只需加一个 WHERE 条件。后台不受影响(不加 generation 过滤)。钱包流水通过 wallet_id 天然隔离,无需 generation 字段。
**本次范围**:仅新增字段和 DEFAULT 值。快照逻辑和查询过滤在后续提案中实现。
### 决策 5asset_status 字段设计
**选择**`asset_status int NOT NULL DEFAULT 1`,在 IotCard 和 Device 新增。值域1-在库 2-已销售 3-已换货 4-已停用。与 `network_status` 完全独立。
**理由**`network_status` 反映运营商侧网络状态Gateway 同步),`asset_status` 反映 CMP 内部业务生命周期。两者关注点不同,互不干扰。默认值 1在库符合导入后的初始状态。
**本次范围**:仅新增字段和常量定义。状态流转逻辑在后续提案中实现。
### 决策 6AssetRechargeRecord 扩展字段
**选择**:新增以下字段:
- `operator_type varchar(20) NOT NULL DEFAULT 'admin_user'`:操作人类型,配合 `user_id` 区分后台用户和个人客户
- `generation int NOT NULL DEFAULT 1`:资产世代快照
- `linked_package_ids jsonb DEFAULT '[]'`:强充关联套餐 ID 列表
- `linked_order_type varchar(20)`:关联订单类型
- `linked_carrier_type varchar(20)`:关联载体类型
- `linked_carrier_id bigint`:关联载体 ID
**理由**`operator_type` 解决后台用户和个人客户共享 `user_id` 字段的 ID 体系歧义。强充关联字段支持两阶段处理(充值回调后异步创建套餐订单)。
### 决策 7Carrier 实名链接配置
**选择**`tb_carrier` 新增 `realname_link_type varchar(20) NOT NULL DEFAULT 'none'``realname_link_template varchar(500) DEFAULT ''`
**理由**实名链接有三种模式不支持none、模板 URLtemplate支持 `{iccid}`/`{msisdn}`/`{virtual_no}` 占位符、Gateway 接口gateway。配置在运营商级别同一运营商下所有卡共享同一实名方式。
**本次范围**:仅新增字段。实名跳转接口在提案 2 中实现。
### 决策 8旧接口清理策略
**选择**:一次性删除所有旧 H5 接口文件和旧个人客户登录方法,同步清理所有引用点。
**清理清单**
- **删除文件**8 个):`internal/handler/h5/` 全部 5 个文件 + `internal/routes/h5.go``h5_enterprise_device.go``h5_package_usage.go`
- **修改文件**7 个):
- `internal/routes/routes.go`:移除 `/api/h5` 挂载
- `internal/routes/order.go`:移除 `registerH5OrderRoutes` 函数
- `internal/routes/recharge.go`:移除 `registerH5RechargeRoutes` 函数
- `internal/bootstrap/handlers.go`:移除 H5 Handler 构造H5Auth、EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge
- `internal/bootstrap/types.go`:移除 H5 Handler 字段
- `internal/bootstrap/middlewares.go`:移除 `createH5AuthMiddleware` 和 H5 跳过路径
- `pkg/openapi/handlers.go`:移除文档生成中的 H5 Handler 构造
- `cmd/api/main.go`:移除 `/api/h5` 限流挂载
- **清理旧登录方法**`internal/handler/app/personal_customer.go` 中删除 Login、SendCode、WechatOAuthLogin、BindWechat 方法,保留 UpdateProfile 和 GetProfile如有后续使用
- **路由清理**`internal/routes/personal.go` 中移除指向已删除方法的路由注册
**理由**:一次性清理比分批清理更安全,避免残留引用导致编译错误或运行时异常。
### 决策 9PersonalCustomer.wx_open_id 索引变更
**选择**:将 `wx_open_id` 的唯一索引改为普通索引。
**理由**:后续提案 1 引入 `PersonalCustomerOpenID` 表后,唯一性约束迁移到新表。`wx_open_id` 保留为普通索引供兼容查询使用。
**迁移方式**DROP 旧唯一索引 + CREATE 新普通索引,在同一迁移文件中执行。
## Risks / Trade-offs
**[风险] retail_price 默认值 0 与约束冲突** → 分配创建时 Service 层显式设值为 `Package.SuggestedRetailPrice`,不依赖数据库默认值。存量数据通过迁移脚本批量更新 `retail_price = (SELECT suggested_retail_price FROM tb_package WHERE id = package_id)`
**[风险] 存量 Order 无 source 字段导致佣金重算** → 默认值 `admin` 确保存量订单不触发一次性佣金,与修复前行为一致(虽然修复前有 BUG但存量佣金已发放的不回收
**[风险] 删除 H5 接口导致在用功能中断** → 需确认前端已不使用旧 H5 接口。旧接口使用 B 端认证AdminAuthC 端用户无法调用,实际上已不可用。
**[风险] wx_open_id 索引变更影响查询性能** → 普通索引与唯一索引查询性能无差异,仅丢失数据库层面的唯一性保证。新的唯一性由 PersonalCustomerOpenID 表保证。
**[风险] generation/asset_status 字段仅新增不使用** → 这些字段在本提案中仅完成数据库结构准备,实际使用逻辑在后续提案中实现。风险是字段闲置占用存储,但 int 字段开销可忽略。
## Migration Plan
1. **生成迁移文件**:单个迁移文件包含所有 ALTER TABLE 语句7 张表 15+ 字段)
2. **存量数据修复**:迁移中包含 UPDATE 语句,将 `ShopPackageAllocation.retail_price` 批量设为对应套餐的 `SuggestedRetailPrice`
3. **部署顺序**:先执行迁移 → 再部署新代码(新增字段有默认值,旧代码不受影响)
4. **回滚策略**:可安全回滚代码(字段有默认值),如需回滚迁移则 DROP COLUMN不可逆需确认
5. **旧接口清理**:代码部署即生效,无需额外操作
## Open Questions
无。所有设计决策基于需求说明文档中的明确定义。

View File

@@ -0,0 +1,67 @@
## Why
系统存在 4 个影响资金安全和业务正确性的 BUG且即将启动客户端C 端)接口体系开发。本提案作为客户端接口系列提案的**前置基础**,解决三类问题:
1. **资金/业务 BUG 修复**代理零售价缺失导致价格计算错误BUG-1、后台订单误触发一次性佣金BUG-2、充值回调事务半提交风险BUG-4
2. **基础字段准备**:为客户端接口和换货系统新增必要的模型字段(`asset_status``generation``source``operator_type``realname_link_type`
3. **旧接口清理**:删除基于 B 端认证体系的旧 H5 接口和旧个人客户登录接口,为新的 `/api/c/v1` 体系腾出空间
## What Changes
### BUG 修复
- **BUG-1 代理零售价修复**`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice``validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price`BatchUpdatePricing` 接口仅支持成本价批量调整;新增独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己的零售价;**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价)
- **BUG-2 一次性佣金触发修复**`Order` 新增 `source` 字段(`admin`/`client`);佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,确保只有客户端个人客户购买才触发
- **BUG-4 充值回调事务修复**`HandlePaymentCallback``UpdateStatusWithOptimisticLock``UpdatePaymentInfo``s.db.WithContext(ctx)` 改为事务内 `tx`,确保充值单状态变更和钱包入账原子完成
### 新增模型字段
- **`IotCard`/`Device` 新增 `asset_status`**业务生命周期状态1-在库 2-已销售 3-已换货 4-已停用),与运营商 `network_status` 独立
- **`IotCard`/`Device` 新增 `generation`**:资产世代编号,换货转新时 +1客户端按当前 generation 过滤历史数据
- **`Order`/`PackageUsage`/`AssetRechargeRecord` 新增 `generation`**:创建时快照资产当前 generation客户端查询按此字段过滤
- **`AssetRechargeRecord` 新增 `operator_type`**:区分操作人类型(`admin_user`/`personal_customer`),配合 `user_id` 区分不同 ID 体系
- **`AssetRechargeRecord` 新增强充关联字段**`linked_package_ids``linked_order_type``linked_carrier_type``linked_carrier_id`,支持强充两阶段处理
- **`Carrier` 新增实名链接配置**`realname_link_type`none/template/gateway`realname_link_template`(支持 `{iccid}`/`{msisdn}`/`{virtual_no}` 占位符)。**同步更新 Carrier admin DTO**`CarrierCreateRequest`/`CarrierUpdateRequest`)包含这两个字段,使后台管理员可通过 API 配置运营商实名链接方式
- **`PersonalCustomer` 索引变更**`wx_open_id` 从唯一索引改为普通索引(支持后续多 OpenID 方案)
### 旧接口删除
- **删除全部旧 H5 接口**`internal/handler/h5/` 下所有文件auth、order、recharge、package_usage、enterprise_device`internal/routes/h5*.go` 路由注册
- **删除旧个人客户登录接口**`internal/handler/app/personal_customer.go` 中的 Login、SendCode、WechatOAuthLogin、BindWechat、Profile 方法
- **同步清理**bootstrap 中 H5 Handler 注册、docs.go/gendocs 中引用
## Capabilities
### New Capabilities
- `asset-lifecycle-status`资产业务生命周期状态管理。IotCard/Device 新增 `asset_status` 字段(在库→已销售→已换货→已停用),定义状态流转规则,与运营商 `network_status` 完全独立
- `asset-generation`资产世代机制。IotCard/Device 的 `generation` 字段关联表Order/PackageUsage/AssetRechargeRecord的 generation 写时快照规则,客户端按世代过滤、后台不过滤的查询规则
- `carrier-realname-config`运营商实名链接配置。Carrier 模型新增 `realname_link_type`/`realname_link_template` 字段,支持 none/template/gateway 三种模式URL 模板占位符替换。**Carrier admin DTO 同步更新**,后台可通过现有运营商管理接口配置实名链接
- `agent-retail-price`代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`cost_price 分配锁定规则。新增独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己的零售价;**代理套餐列表展示 retail_price****利润计算修正**为 `RetailPrice - CostPrice`
- `asset-manual-deactivation`:资产手动停用。新增后台接口 `PATCH /api/admin/iot-cards/:id/deactivate``PATCH /api/admin/devices/:id/deactivate`,将 `asset_status` 设为 4已停用`asset_status=1`(在库)或 `asset_status=2`(已销售)时可操作
- `h5-legacy-cleanup`:旧 H5 接口和旧登录接口的完整删除,包括 handler、route、bootstrap 注册、文档生成器引用的清理
### Modified Capabilities
- `package-purchase-validation``GetPurchasePrice()` 价格来源改为按渠道区分代理→retail_price平台→SuggestedRetailPrice`validatePackages()` 价格累加逻辑同步修正
- `package-list`:代理查询套餐列表时,`PackageResponse` 新增 `retail_price` 字段;`ProfitMargin` 计算从 `SuggestedRetailPrice - CostPrice` 改为 `RetailPrice - CostPrice`
- `batch-pricing``BatchUpdatePricing` 接口仅支持 `cost_price` 批量调整;保留 `cost_price` 锁定校验(存在下级分配时不可修改)
- `one-time-commission-trigger`:触发条件增加 `order.Source == "client"` 判断,确保仅客户端个人客户购买才触发
- `wallet-recharge``HandlePaymentCallback` 事务一致性修复Store 方法支持传入事务 `tx`
- `iot-order`Order 模型新增 `source`(订单来源)和 `generation`(世代)字段;`CreateAdminOrder()` 创建订单时从资产快照当前 `generation` 写入订单(而非依赖默认值 1
- `iot-card`IotCard 模型新增 `asset_status``generation` 字段
- `device`Device 模型新增 `asset_status``generation` 字段
- `personal-customer``wx_open_id` 索引从唯一改为普通索引
- `asset-recharge-adaptation`AssetRechargeRecord 新增 `operator_type``generation`、强充关联字段
## Impact
- **模型文件**`shop_package_allocation.go``carrier.go``order.go``iot_card.go``device.go``package_usage.go``asset_recharge_record.go``personal_customer.go`
- **Service 文件**`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`(仅成本价批量调价 + cost_price 锁定)、`shop_series_grant/service.go`cost_price 锁定)、`order/service.go`source 设置 + generation 快照)、`package/service.go`(新增代理改零售价接口逻辑 + 利润计算修正 + PackageResponse 新增 retail_price
- **Handler/DTO 文件**`shop_package_batch_pricing.go` Handler仅保留成本价批量调价`shop_package_batch_pricing_dto.go`(移除 `pricing_target` 字段)、`package.go` Handler新增 `PATCH /packages/:id/retail-price`)、`package_dto.go``PackageResponse` 新增 `retail_price` + 新增更新零售价请求 DTO`carrier_dto.go`(新增实名链接字段)
- **Store 文件**`asset_recharge_store.go`(支持事务传入)
- **删除文件**`internal/handler/h5/` 全部5 个文件)、`internal/routes/h5*.go`3 个文件)、`internal/handler/app/personal_customer.go` 中旧方法
- **数据库迁移**7 张表共 15+ 个字段变更1 个索引变更
- **文档生成器**`cmd/api/docs.go``cmd/gendocs/main.go` 移除 H5 Handler 引用
- **Bootstrap**:移除 H5 Handler 注册
- **性能**:所有变更为字段新增/修复,无查询性能影响;新增字段均带 DEFAULT 值,迁移可在线执行

View File

@@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: 分配零售价字段定义
系统 MUST 在 `ShopPackageAllocation` 新增 `retail_price bigint NOT NULL DEFAULT 0` 字段。
#### Scenario: 新字段存在且非空
- **WHEN** 执行分配记录建表或迁移
- **THEN** `retail_price` MUST 为非空整型字段,默认值为 `0`
---
### Requirement: 分配创建默认零售价规则
系统 MUST 在创建分配记录时将 `retail_price` 自动设置为对应 `Package.SuggestedRetailPrice`
#### Scenario: 创建分配自动带出建议零售价
- **WHEN** 平台给代理创建套餐分配记录
- **THEN** 新记录的 `retail_price` MUST 等于该套餐的 `suggested_retail_price`
---
### Requirement: 零售价约束规则
系统 MUST 强制校验:`retail_price >= cost_price`
#### Scenario: 零售价低于成本价
- **WHEN** 代理设置 `retail_price < cost_price`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
### Requirement: 成本价分配锁定规则
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`
#### Scenario: 存在下级分配时修改成本价
- **WHEN** 上级分配记录已被继续分配到下级店铺
- **THEN** 系统 MUST 拒绝对该记录的 `cost_price` 修改
---
### Requirement: 代理零售价可调与存量迁移
系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
#### Scenario: 代理调整自己的零售价
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束
- **THEN** 系统 MUST 允许更新
#### Scenario: 存量数据回填零售价
- **WHEN** 执行本次数据迁移
- **THEN** 系统 MUST 将历史 `ShopPackageAllocation.retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`

View File

@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: 资产表新增代际字段
系统 MUST 在资产主表新增 `generation int NOT NULL DEFAULT 1` 字段,覆盖 `IotCard``Device`
#### Scenario: 新资产默认代际为 1
- **WHEN** 创建新的 IoT 卡或设备
- **THEN** 系统 MUST 将 `generation` 初始化为 `1`
---
### Requirement: 关联业务表新增代际字段
系统 MUST 在以下关联业务表新增 `generation int NOT NULL DEFAULT 1` 字段:`Order``PackageUsage``AssetRechargeRecord`
#### Scenario: 新关联记录默认代际为 1
- **WHEN** 创建订单、套餐使用记录或资产充值记录
- **THEN** 系统 MUST 将记录的 `generation` 默认为 `1`
---
### Requirement: 写时快照代际规则
系统 MUST 在创建关联记录时执行代际写时快照从当前资产IoT 卡/设备)的 `generation` 复制到新建的 `Order``PackageUsage``AssetRechargeRecord` 记录。
#### Scenario: 创建订单时复制资产代际
- **WHEN** 某资产当前 `generation=3`,并基于该资产创建订单
- **THEN** 该订单记录的 `generation` MUST 写入为 `3`
---
### Requirement: 查询过滤规则
系统 MUST 支持客户端按 `generation` 过滤历史数据;后台管理侧 MUST 不默认按 `generation` 过滤。
本提案阶段 MUST 仅新增字段定义,具体过滤逻辑在后续提案实现。
#### Scenario: 客户端按代际查看历史
- **WHEN** 客户端请求携带指定 `generation`
- **THEN** 系统 MUST 仅返回该代际的数据(在后续提案中实现)
#### Scenario: 后台查询不按代际裁剪
- **WHEN** 管理端查询订单或充值记录且未显式指定 `generation`
- **THEN** 系统 MUST 返回全部代际数据
---
### Requirement: 钱包流水不引入代际字段
系统 MUST NOT 在钱包流水相关表新增 `generation` 字段,因为钱包流水已通过 `wallet_id` 天然隔离。
#### Scenario: 钱包流水按钱包隔离
- **WHEN** 查询某资产钱包流水
- **THEN** 系统 MUST 仅依赖 `wallet_id` 完成数据隔离,不新增 `generation` 参与过滤

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: 资产生命周期状态字段定义
系统 MUST 在 `IotCard``Device` 数据模型中新增 `asset_status int NOT NULL DEFAULT 1` 字段,用于表达资产生命周期状态。
状态值域 MUST 固定为:`1-在库``2-已销售``3-已换货``4-已停用`
#### Scenario: 新建资产默认在库
- **WHEN** 系统创建新的 IoT 卡或设备记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 非法状态值被拒绝
- **WHEN** 写入 `asset_status``0``5` 或其他非约定值
- **THEN** 系统 MUST 拒绝该写入并提示状态值不合法
---
### Requirement: 资产生命周期状态常量定义
系统 MUST 在 `pkg/constants/` 中定义资产生命周期状态常量,并统一由业务层引用,禁止在业务代码中硬编码状态值。
#### Scenario: 业务代码引用常量
- **WHEN** Service 层执行资产状态判断或赋值
- **THEN** 代码 MUST 使用 `pkg/constants/` 中定义的资产状态常量而不是硬编码数字
---
### Requirement: 资产状态与网络状态独立
系统 MUST 保证 `asset_status` 与运营商侧 `network_status` 完全独立,二者不互相推导、不互相覆盖。
本提案阶段 MUST 仅新增字段与常量定义,状态流转逻辑(导入→在库、首次绑定/分配→已销售、换货完成→已换货、转新→在库且代际+1、手动停用→已停用在后续提案实现。
#### Scenario: 网络状态变化不影响资产状态
- **WHEN** Gateway 同步将 `network_status` 从开机改为停机
- **THEN** 系统 MUST 保持 `asset_status` 不变
#### Scenario: 资产状态变化不强制修改网络状态
- **WHEN** 管理端将资产手动停用(`asset_status=4`
- **THEN** 系统 MUST 不自动改写 `network_status`

View File

@@ -0,0 +1,24 @@
## ADDED Requirements
### Requirement: 资产充值记录扩展字段(操作人与代际)
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `operator_type` | varchar(20) | ✅ | 操作人类型,枚举 `admin_user` / `personal_customer`,默认 `admin_user` |
| `generation` | int | ✅ | 资产代际,默认 `1` |
| `linked_package_ids` | jsonb | ❌ | 关联套餐 ID 列表,默认 `'[]'` |
| `linked_order_type` | varchar(20) | ❌ | 关联订单类型 |
| `linked_carrier_type` | varchar(20) | ❌ | 关联载体类型(如 iot_card/device |
| `linked_carrier_id` | bigint | ❌ | 关联载体 ID |
#### Scenario: 新建充值记录默认字段值
- **WHEN** 系统创建新的资产充值记录且未显式传入新增字段
- **THEN** `operator_type` MUST 默认为 `admin_user`
- **THEN** `generation` MUST 默认为 `1`
- **THEN** `linked_package_ids` MUST 默认为空数组 `[]`
#### Scenario: 写入关联上下文信息
- **WHEN** 充值记录由订单或套餐联动产生
- **THEN** 系统 MUST 可写入 `linked_order_type``linked_carrier_type``linked_carrier_id` 作为关联上下文

View File

@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: 运营商实名链接配置字段定义
系统 MUST 在 Carrier 模型新增以下字段:
- `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`
- `realname_link_template varchar(500) DEFAULT ''`
#### Scenario: 默认配置为不支持在线实名
- **WHEN** 创建新的运营商记录且未显式设置实名链接配置
- **THEN** 系统 MUST 将 `realname_link_type` 设为 `none``realname_link_template` 设为空字符串
---
### Requirement: 实名链接三种模式
系统 MUST 支持并仅支持以下实名链接模式:
- `none`:不支持在线实名
- `template`:使用模板 URL 生成实名链接
- `gateway`:通过 Gateway 接口动态获取实名链接
#### Scenario: none 模式
- **WHEN** `realname_link_type=none`
- **THEN** 系统 MUST 视为不支持在线实名跳转
#### Scenario: template 模式
- **WHEN** `realname_link_type=template`
- **THEN** 系统 MUST 使用 `realname_link_template` 作为实名链接模板
#### Scenario: gateway 模式
- **WHEN** `realname_link_type=gateway`
- **THEN** 系统 MUST 通过 Gateway 能力获取实名链接
---
### Requirement: 模板占位符规则
`realname_link_type=template` 时,系统 MUST 支持模板中的占位符 `{iccid}``{msisdn}``{virtual_no}`
本提案阶段 MUST 仅新增字段,不实现实名跳转接口逻辑。
#### Scenario: 模板占位符可被解析
- **WHEN** 模板 URL 包含 `{iccid}``{msisdn}``{virtual_no}`
- **THEN** 系统 MUST 在后续实名跳转实现中按占位符语义进行参数替换

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 设备实体定义
系统 SHALL 在 `Device` 模型新增以下字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建设备默认资产状态
- **WHEN** 创建新的设备记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建设备默认代际
- **WHEN** 创建新的设备记录
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: 旧 H5 接口文件删除清单
系统 MUST 完整删除以下旧 H5 文件:
- `internal/handler/h5/auth.go`
- `internal/handler/h5/order.go`
- `internal/handler/h5/recharge.go`
- `internal/handler/h5/package_usage.go`
- `internal/handler/h5/enterprise_device.go`
- `internal/routes/h5.go`
- `internal/routes/h5_enterprise_device.go`
- `internal/routes/h5_package_usage.go`
#### Scenario: 旧 H5 文件不存在
- **WHEN** 执行本提案改造完成后检查仓库
- **THEN** 上述文件 MUST 全部不存在
---
### Requirement: 旧 H5 与旧登录引用清理清单
系统 MUST 清理以下代码引用:
- bootstrap`handlers.go``H5Auth``EnterpriseDeviceH5``H5PackageUsage``H5Order``H5Recharge`
- bootstrap`types.go` 对应字段
- bootstrap`middlewares.go``createH5AuthMiddleware`
- 路由:`routes.go``/api/h5` 挂载
- 路由:`order.go``registerH5OrderRoutes`
- 路由:`recharge.go``registerH5RechargeRoutes`
- 文档:`pkg/openapi/handlers.go` 中 H5 Handler 构造
- 限流:`cmd/api/main.go``/api/h5` 限流配置
- 旧登录方法:`internal/handler/app/personal_customer.go``Login``SendCode``WechatOAuthLogin``BindWechat`
- 旧登录路由:`internal/routes/personal.go` 中指向已删除方法的路由
#### Scenario: 编译期无已删除符号引用
- **WHEN** 清理完成后执行编译
- **THEN** 系统 MUST 不再出现对上述已删除 Handler、路由或方法的引用
---
### Requirement: 清理后编译通过
系统 MUST 在完成文件删除与引用清理后保持工程可编译。
#### Scenario: 全量编译验证通过
- **WHEN** 执行构建命令
- **THEN** 工程 MUST 编译通过且无 H5 旧接口残留导致的编译错误

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: IoT 卡资产生命周期字段
系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建 IoT 卡默认资产状态
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建 IoT 卡默认代际
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 订单来源与代际字段
系统 SHALL 在订单Order实体新增来源与代际字段
- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建订单默认后台来源
- **WHEN** 系统创建订单且未显式指定来源
- **THEN** `source` MUST 默认为 `admin`
#### Scenario: 客户端下单写入客户端来源
- **WHEN** 客户端入口创建订单
- **THEN** `source` MUST 写入为 `client`
#### Scenario: 新建订单默认代际为 1
- **WHEN** 系统创建订单且未显式指定代际
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 一次性佣金触发条件
系统 SHALL 在满足一次性佣金阈值规则的前提下,仅对客户端订单触发一次性佣金。
完整触发判断 MUST 为:`!order.IsPurchaseOnBehalf && order.Source == "client"`
#### Scenario: 客户端自购订单触发
- **WHEN** 订单满足阈值条件,且 `order.IsPurchaseOnBehalf=false``order.Source="client"`
- **THEN** 系统 SHALL 触发一次性佣金计算
#### Scenario: 代购订单不触发
- **WHEN** 订单满足阈值条件,但 `order.IsPurchaseOnBehalf=true`
- **THEN** 系统 SHALL 不触发一次性佣金
#### Scenario: 后台订单不触发
- **WHEN** 订单满足阈值条件,且 `order.Source="admin"`
- **THEN** 系统 SHALL 不触发一次性佣金

View File

@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: 代理渠道购买价格规则
系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道使用分配零售价
- **WHEN** 客户通过代理渠道购买套餐
- **THEN** 系统 MUST 使用 `allocation.retail_price` 作为支付金额
#### Scenario: 平台渠道使用套餐建议零售价
- **WHEN** 客户通过平台自营渠道购买套餐
- **THEN** 系统 MUST 使用 `Package.SuggestedRetailPrice` 作为支付金额
---
### Requirement: validatePackages 价格累加与展示校验
系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的价格来源进行累加计算,并在代理渠道增加价格展示可见性校验。
#### Scenario: 代理渠道累加使用 retail_price
- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的 `allocation.retail_price`
#### Scenario: 平台渠道累加使用 SuggestedRetailPrice
- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道过滤异常零售价
- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price`
- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验

View File

@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: 微信标识索引策略
系统 MUST 将 `tb_personal_customer.wx_open_id` 的索引从唯一索引调整为普通索引:删除 `uniqueIndex`,改为 `index`
#### Scenario: 多条记录允许相同 wx_open_id
- **WHEN** 数据库中写入两条具有相同 `wx_open_id` 的个人客户记录
- **THEN** 数据库层 MUST 不再因唯一约束报错
#### Scenario: 查询性能仍受索引保障
- **WHEN** 按 `wx_open_id` 执行查询
- **THEN** 系统 MUST 继续命中普通索引以保障查询性能

View File

@@ -0,0 +1,24 @@
## ADDED Requirements
### Requirement: 充值回调事务一致性
`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行,保证充值状态与支付信息的原子性。
#### Scenario: 回调处理中状态更新与支付信息更新同事务
- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback`
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdateStatusWithOptimisticLock`
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdatePaymentInfo`
#### Scenario: 事务失败整体回滚
- **WHEN** 回调处理中任一步骤失败
- **THEN** 系统 MUST 回滚该事务,保证订单状态与支付信息不出现部分成功
---
### Requirement: Store 方法签名支持事务参数
系统 MUST 调整充值相关 Store 方法签名,支持显式传入 `*gorm.DB tx` 参数,以保证事务边界可控。
#### Scenario: Service 传入事务句柄
- **WHEN** Service 在事务上下文调用 Store 更新充值记录
- **THEN** Store 方法 MUST 接收并使用传入的 `tx` 执行数据库操作

View File

@@ -0,0 +1,111 @@
## 1. 常量定义
- [x] 1.1 在 `pkg/constants/` 新增 `asset_status.go`,定义资产业务状态常量:`AssetStatusInStock = 1`(在库)、`AssetStatusSold = 2`(已销售)、`AssetStatusExchanged = 3`(已换货)、`AssetStatusDeactivated = 4`(已停用),每个常量必须有中文注释
- [x] 1.2 在 `pkg/constants/` 新增 `order_source.go`,定义订单来源常量:`OrderSourceAdmin = "admin"`(后台)、`OrderSourceClient = "client"`(客户端),每个常量必须有中文注释
- [x] 1.3 在 `pkg/constants/` 新增 `operator_type.go`,定义操作人类型常量:`OperatorTypeAdminUser = "admin_user"`(后台用户)、`OperatorTypePersonalCustomer = "personal_customer"`(个人客户),每个常量必须有中文注释
- [x] 1.4 在 `pkg/constants/` 新增 `realname_link.go`,定义实名链接类型常量:`RealnameLinkTypeNone = "none"``RealnameLinkTypeTemplate = "template"``RealnameLinkTypeGateway = "gateway"`,每个常量必须有中文注释
## 2. 模型字段新增
- [x] 2.1 在 `internal/model/iot_card.go``IotCard` 结构体中新增 `AssetStatus int` 字段gorm: `column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用`)和 `Generation int` 字段gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`
- [x] 2.2 在 `internal/model/device.go``Device` 结构体中新增 `AssetStatus int``Generation int` 字段gorm tag 同 2.1
- [x] 2.3 在 `internal/model/order.go``Order` 结构体中新增 `Source string` 字段gorm: `column:source;type:varchar(20);not null;default:'admin';comment:订单来源 admin-后台 client-客户端`)和 `Generation int` 字段gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`
- [x] 2.4 在 `internal/model/package.go``PackageUsage` 结构体中新增 `Generation int` 字段gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`
- [x] 2.5 在 `internal/model/asset_wallet.go``AssetRechargeRecord` 结构体中新增以下字段:`OperatorType string``column:operator_type;type:varchar(20);not null;default:'admin_user';comment:操作人类型`)、`Generation int``column:generation;type:int;not null;default:1;comment:资产世代编号`)、`LinkedPackageIDs datatypes.JSON``column:linked_package_ids;type:jsonb;default:'[]';comment:强充关联套餐ID列表`)、`LinkedOrderType string``column:linked_order_type;type:varchar(20);comment:关联订单类型`)、`LinkedCarrierType string``column:linked_carrier_type;type:varchar(20);comment:关联载体类型`)、`LinkedCarrierID *uint``column:linked_carrier_id;type:bigint;comment:关联载体ID`
- [x] 2.6 在 `internal/model/carrier.go``Carrier` 结构体中新增 `RealnameLinkType string``column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口`)和 `RealnameLinkTemplate string``column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL`
- [x] 2.7 在 `internal/model/shop_package_allocation.go``ShopPackageAllocation` 结构体中新增 `RetailPrice int64` 字段gorm: `column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)`
- [x] 2.8 在 `internal/model/personal_customer.go` 中将 `WxOpenID` 字段的 gorm tag 从 `uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL` 改为 `index:idx_personal_customer_wx_open_id`(唯一索引改为普通索引)
## 3. 数据库迁移
- [x] 3.1 创建迁移文件(使用 golang-migrate 工具),包含以下 ALTER TABLE 语句:
- `tb_iot_card` ADD `asset_status int NOT NULL DEFAULT 1`、ADD `generation int NOT NULL DEFAULT 1`
- `tb_device` ADD `asset_status int NOT NULL DEFAULT 1`、ADD `generation int NOT NULL DEFAULT 1`
- `tb_order` ADD `source varchar(20) NOT NULL DEFAULT 'admin'`、ADD `generation int NOT NULL DEFAULT 1`
- `tb_package_usage` ADD `generation int NOT NULL DEFAULT 1`
- `tb_asset_recharge_record` ADD `operator_type varchar(20) NOT NULL DEFAULT 'admin_user'`、ADD `generation int NOT NULL DEFAULT 1`、ADD `linked_package_ids jsonb DEFAULT '[]'`、ADD `linked_order_type varchar(20)`、ADD `linked_carrier_type varchar(20)`、ADD `linked_carrier_id bigint`
- `tb_carrier` ADD `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`、ADD `realname_link_template varchar(500) DEFAULT ''`
- `tb_shop_package_allocation` ADD `retail_price bigint NOT NULL DEFAULT 0`
- [x] 3.2 在同一迁移文件中添加存量数据修复 SQL`UPDATE tb_shop_package_allocation spa SET retail_price = (SELECT suggested_retail_price FROM tb_package p WHERE p.id = spa.package_id) WHERE retail_price = 0`
- [x] 3.3 在同一迁移文件中添加索引变更DROP `idx_personal_customer_wx_open_id` 唯一索引CREATE 普通索引 `idx_personal_customer_wx_open_id` ON `tb_personal_customer(wx_open_id)`
- [x] 3.4 编写 down 迁移文件(回滚用),包含对应的 DROP COLUMN 和索引恢复语句
- [x] 3.5 执行迁移,使用 PostgreSQL MCP 工具验证所有字段已添加、存量 `retail_price` 已更新、索引已变更
## 4. BUG-1 修复:代理零售价
- [x] 4.1 修改 `internal/service/purchase_validation/service.go``GetPurchasePrice()` 方法(约第 172 行):增加渠道判断,代理渠道(`card.ShopID > 0`)时查询 `ShopPackageAllocation` 获取 `RetailPrice` 并返回,平台渠道继续返回 `Package.SuggestedRetailPrice`
- [x] 4.2 修改 `internal/service/purchase_validation/service.go``validatePackages()` 方法(约第 148 行):将 `totalPrice += pkg.SuggestedRetailPrice` 改为按渠道取价逻辑(复用 4.1 的渠道判断),代理渠道额外校验 `allocation.RetailPrice >= allocation.CostPrice`,不满足则该套餐视为不可购买
- [x] 4.3 修改 `internal/service/shop_package_batch_allocation/service.go`:创建分配记录时设置 `RetailPrice``Package.SuggestedRetailPrice`(约第 84-105 行创建 allocation 的位置)
- [x] 4.4 修改 `internal/service/shop_series_grant/service.go`:创建/更新 `ShopPackageAllocation` 时同步设置 `RetailPrice`(约第 302-352 行和第 614-685 行)
- [x] 4.5 在 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法中新增 cost_price 锁定检查:更新每条 allocation 的 cost_price 前,查询是否存在下级分配记录(`ShopPackageAllocation WHERE allocator_shop_id = allocation.ShopID AND package_id = allocation.PackageID`),存在则跳过该条并记录到响应的 `skipped` 列表,附带原因"存在下级分配记录,请先回收后再修改成本价"
- [x] 4.6 在 `internal/service/shop_series_grant/service.go` 中同步添加 cost_price 锁定检查:更新现有 allocation 的 CostPrice 时(约第 634 行),查询是否存在下级分配记录,存在则拒绝修改并返回错误
## 5. 代理零售价后台管理
- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go``BatchUpdateCostPriceRequest`:删除 `PricingTarget` 字段,批量调价接口仅保留成本价路径
- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法:删除 `retail_price` 分支与默认分流逻辑,仅保留 `cost_price` 调整(包含 4.5 的锁定检查)
- [x] 5.3 在 `internal/model/dto/package_dto.go` 新增 `UpdateRetailPriceRequest``UpdateRetailPriceParams`,用于代理修改自己零售价
- [x] 5.4 在 `internal/store/postgres/shop_package_allocation_store.go` 新增 `UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error`
- [x] 5.5 在 `internal/service/package/service.go` 新增 `UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error`:仅代理可调用、校验 `retail_price >= cost_price`
- [x] 5.6 在 `internal/handler/admin/package.go` 新增 `UpdateRetailPrice`,并在 `internal/routes/package.go` 注册 `PATCH /api/admin/packages/:id/retail-price`
- [x] 5.7 修改 `internal/model/dto/package_dto.go``PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
- [x] 5.8 修改 `internal/service/package/service.go``toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice`
- [x] 5.9 修改 `internal/service/package/service.go``toResponseWithAllocation()` 方法(约第 595-603 行):同 5.8,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算
## 6. Carrier 管理 DTO 更新
- [x] 6.1 修改 `internal/model/dto/carrier_dto.go`(或 Carrier 相关 DTO 文件):在 `CarrierCreateRequest``CarrierUpdateRequest` 中新增 `RealnameLinkType *string` 字段(`json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`)和 `RealnameLinkTemplate *string` 字段(`json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
- [x] 6.2 修改 Carrier Service 的 Create/Update 方法:将 DTO 中的 `RealnameLinkType``RealnameLinkTemplate` 写入 Carrier 模型Update 时 `realname_link_type``template` 时校验 `realname_link_template` 非空
- [x] 6.3 修改 Carrier 的响应 DTO`CarrierResponse`):新增 `RealnameLinkType``RealnameLinkTemplate` 字段,后台列表/详情可展示
## 7. 后台订单 generation 快照
- [x] 7.1 修改 `internal/service/order/service.go``CreateAdminOrder()` 方法:创建 Order 时从资产IotCard/Device获取当前 `Generation` 值并设入 `order.Generation`,不再依赖数据库默认值 1
## 8. 资产手动停用
- [x] 8.1 在 `internal/handler/admin/asset.go`(或新建 `internal/handler/admin/asset_lifecycle.go`)新增 `DeactivateAsset` Handler 方法:接收资产类型和 ID调用 Service 将 `asset_status` 设为 `constants.AssetStatusDeactivated`4
- [x] 8.2 在 `internal/service/asset/service.go`(或相关 Service新增 `Deactivate()` 方法:校验当前 `asset_status` 为 1在库或 2已销售才允许停用已换货3或已停用4的拒绝操作使用条件更新 `WHERE asset_status IN (1, 2)` 确保幂等
- [x] 8.3 在 `internal/routes/` 注册停用路由:`PATCH /api/admin/iot-cards/:id/deactivate``PATCH /api/admin/devices/:id/deactivate`(或统一为 `PATCH /api/admin/assets/:type/:id/deactivate`
- [x] 8.4 更新 `cmd/api/docs.go``cmd/gendocs/main.go`:如新增了 Handler 则同步注册到文档生成器
## 9. BUG-2 修复:一次性佣金触发条件
- [x] 9.1 修改 `internal/service/commission_calculation/service.go` 中的 `triggerOneTimeCommissionForCardInTx` 方法:将 `if order.IsPurchaseOnBehalf` 的跳过逻辑改为 `if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient`,即代购订单或非客户端来源订单均跳过一次性佣金
- [x] 9.2 修改 `internal/service/commission_calculation/service.go` 中的 `triggerOneTimeCommissionForDeviceInTx` 方法:同 9.1 逻辑
## 10. BUG-4 修复:充值回调事务一致性
- [x] 10.1 修改 `internal/store/postgres/asset_recharge_store.go``UpdateStatusWithOptimisticLock` 方法:新增 `tx *gorm.DB` 参数(方法签名变更),内部使用传入的 `tx` 替代 `s.db.WithContext(ctx)`;同时保留原方法签名的兼容版本或修改所有调用点
- [x] 10.2 修改 `internal/store/postgres/asset_recharge_store.go``UpdatePaymentInfo` 方法:同 10.1,新增 `tx *gorm.DB` 参数
- [x] 10.3 修改 `internal/service/recharge/service.go``HandlePaymentCallback` 方法(约第 308-321 行):在事务闭包内调用 Store 方法时传入事务 `tx`,确保 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` 和钱包入账在同一事务内
- [x] 10.4 检查并更新 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` 的其他调用点(如有),确保传入正确的 db 或 tx 参数
## 11. 旧 H5 接口清理
- [x] 11.1 删除 `internal/handler/h5/` 目录下全部 5 个文件:`auth.go``order.go``recharge.go``package_usage.go``enterprise_device.go`
- [x] 11.2 删除 `internal/routes/h5.go``internal/routes/h5_enterprise_device.go``internal/routes/h5_package_usage.go`
- [x] 11.3 修改 `internal/routes/routes.go`:移除 `/api/h5` 路由组挂载(约第 31-33 行)
- [x] 11.4 修改 `internal/routes/order.go`:移除 `registerH5OrderRoutes` 函数(约第 56-105 行)
- [x] 11.5 修改 `internal/routes/recharge.go`:移除 `registerH5RechargeRoutes` 函数(约第 11-44 行)
- [x] 11.6 修改 `internal/bootstrap/handlers.go`:移除 H5 Handler 构造H5Auth、EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge约第 24、31、44、49、50 行)
- [x] 11.7 修改 `internal/bootstrap/types.go`:移除 Handlers 结构体中的 H5 Handler 字段(约第 22、29、42、47、48 行)
- [x] 11.8 修改 `internal/bootstrap/middlewares.go`:移除 `createH5AuthMiddleware` 函数和 H5 跳过路径配置(约第 72-95 行)
- [x] 11.9 修改 `pkg/openapi/handlers.go`:移除文档生成中的 H5 Handler 构造EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge
- [x] 11.10 修改 `cmd/api/main.go`:移除 `/api/h5` 限流挂载(约第 250-257 行)
## 12. 旧个人客户登录接口清理
- [x] 12.1 修改 `internal/handler/app/personal_customer.go`:删除 Login约第 79 行、SendCode约第 35 行、WechatOAuthLogin约第 114 行、BindWechat约第 134 行)方法。保留 UpdateProfile 和 GetProfile
- [x] 12.2 修改 `internal/routes/personal.go`移除指向已删除方法的路由注册Login、SendCode、WechatOAuthLogin、BindWechat 的路由)
- [x] 12.3 检查 `internal/bootstrap/handlers.go``internal/bootstrap/types.go`:如果 PersonalCustomer Handler 有初始化引用已删除方法的依赖,同步清理
## 13. 验证
- [x] 13.1 执行 `go build ./...` 确认编译通过,无任何编译错误
- [x] 13.2 对所有修改的文件执行 `lsp_diagnostics` 确认无错误和警告
- [x] 13.3 使用 PostgreSQL MCP 工具验证数据库:确认 7 张表的新字段存在、默认值正确、存量 `retail_price` 已填充、`wx_open_id` 索引已变更
- [x] 13.4 验证删除的 H5 路由不再注册:检查代码中无 `/api/h5` 相关路由残留
- [x] 13.5 验证 `BatchUpdatePricing` 接口仅支持成本价调整;并验证 `PATCH /api/admin/packages/:id/retail-price` 可供代理修改自己的零售价
- [x] 13.6 验证代理套餐列表:确认 `PackageResponse` 包含 `retail_price` 字段,`profit_margin` 计算基于 `retail_price - cost_price`
- [x] 13.7 撰写功能总结文档 `docs/client-api-data-model-fixes/功能总结.md`,记录所有变更内容

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

@@ -0,0 +1,171 @@
# client-auth-system 设计文档
## Context
当前个人客户认证状态如下:
- 个人客户当前使用纯 JWT`pkg/auth/jwt.go`),未做 Redis token 状态存储,服务端无法主动失效。
- 微信配置已在数据库 `tb_wechat_config` 中存在(`WechatConfig`),但现有能力中仍存在 YAML 静态配置依赖。
- 个人客户相关模型已存在:`PersonalCustomer``PersonalCustomerPhone``PersonalCustomerDevice`
- 现有 `/api/c/v1` 路由将被本次完整认证体系替换为新的 `/api/c/v1/auth/*` 七个端点。
## Goals / Non-Goals
### Goals
- 交付完整 C 端认证系统,覆盖 A1~A7 七个接口:资产验证、微信登录(公众号/小程序)、验证码发送、手机号绑定、手机号换绑、退出登录。
- 建立有状态 JWTJWT + Redis机制支持服务端主动失效。
- 建立 OpenID 多记录模型,兼容公众号与小程序不同 AppID 场景。
### Non-Goals
- 不实现业务域 API如充值、套餐、订单等
- 不包含兑换系统exchange相关设计与实现。
## Decisions
### 1) asset_token 设计
- 方案:`asset_token` 使用短时效 JWT5 分钟payload 仅包含 `asset_type` + `asset_id`,并使用独立于主登录 JWT 的签名密钥。
- WhyA1 是无认证接口,若直接暴露内部 `asset_id` 会造成可枚举风险;短时效 + 独立密钥可降低 token 泄露影响范围。
### 2) Stateful JWT with Redis
- 方案:登录成功签发 JWT 后,将 token 状态写入 Redis 并设置 TTL每次请求在中间件同时校验 JWT 与 Redis 状态。
- Redis Key`RedisPersonalCustomerTokenKey(customerID)`
- Why纯 JWT 无法服务端撤销Redis 状态可支持封禁、强制下线、单点退出等主动失效场景。
### 3) OpenID multi-record strategy
- 方案:新增 `PersonalCustomerOpenID` 表,约束 `UNIQUE(app_id, open_id)`(软删条件下唯一);客户查找逻辑采用:
1. 先按 `(app_id, open_id)` 精确命中;
2. 未命中时按 `unionid` 回查并合并;
3. 仍未命中则创建新客户。
- Why公众号与小程序可能不在同一开放平台需支持“一客户多 OpenID 记录”。
### 4) WechatConfig dynamic loading + SDK 实例工厂
- 方案:登录时动态读取 `tb_wechat_config WHERE is_active=true`,使用工厂函数按需创建 SDK 实例:
- OfficialAccount 使用 `oa_app_id + oa_app_secret`
- Miniapp 使用 `miniapp_app_id + miniapp_app_secret`
- Payment 使用 `wx_mch_id + wx_api_v3_key + wx_cert_content + wx_key_content + wx_serial_no`
- Why避免 YAML 静态配置导致多环境切换和配置漂移,支持运营侧动态切换。
**现有 SDK 能力盘点(`pkg/wechat/`**
| 文件 | 已有能力 | 客户端接口需要 |
|------|---------|--------------|
| `official_account.go` | `GetUserInfo(code)`snsapi_base`GetUserInfoDetailed(code)`snsapi_userinfo`GetUserInfoByToken()` | A2 公众号登录 ✅ 直接复用 `GetUserInfoDetailed` |
| `payment.go` | `CreateJSAPIOrder()``CreateH5Order()``QueryOrder()``CloseOrder()``HandlePaymentNotify()` | 提案 2 支付 ✅ |
| `config.go` | `NewOfficialAccountApp(cfg)` — 仅从 YAML 创建 | ❌ 需新增 DB 动态工厂 |
| **缺失** `miniapp.go` | 无 | ❌ A3 小程序登录需要 |
**需要新增的 SDK 代码**
1. **`pkg/wechat/miniapp.go`** — 小程序服务封装:
```go
// MiniAppService 微信小程序服务实现
type MiniAppService struct {
appID string
appSecret string
logger *zap.Logger
}
// MiniAppServiceInterface 微信小程序服务接口
type MiniAppServiceInterface interface {
Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
}
// Code2Session 通过小程序 login code 换取 openid + session_key
// 调用微信 https://api.weixin.qq.com/sns/jscode2session 接口
// 注意: 小程序无法通过 code 直接获取用户信息(昵称/头像由前端授权后传入)
func (s *MiniAppService) Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
```
2. **`pkg/wechat/config.go`** — 新增 DB 动态工厂函数:
```go
// NewOfficialAccountAppFromConfig 从 WechatConfig DB 记录创建公众号应用实例
func NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error)
// NewPaymentAppFromConfig 从 WechatConfig DB 记录创建支付应用实例
// appID 参数决定支付关联的应用:公众号传 oa_app_id小程序传 miniapp_app_id
func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache kernel.CacheInterface, logger *zap.Logger) (*payment.Payment, error)
// NewMiniAppServiceFromConfig 从 WechatConfig DB 记录创建小程序服务实例
func NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger *zap.Logger) (*MiniAppService, error)
```
3. **`pkg/wechat/wechat.go`** — 新增 `MiniAppServiceInterface` 接口定义和编译时类型检查。
**A2/A3 登录时 SDK 调用链路**
```
A2 公众号登录:
client_auth.Service
→ wechatConfigService.GetActiveConfig() // 从 DB/Redis 缓存获取配置
→ wechat.NewOfficialAccountAppFromConfig(config) // 动态创建公众号实例
→ wechat.NewOfficialAccountService(app) // 包装为 Service
→ officialAccountService.GetUserInfoDetailed(code) // 现有方法,直接复用
→ 返回 openID + unionID + nickname + avatar
A3 小程序登录:
client_auth.Service
→ wechatConfigService.GetActiveConfig()
→ wechat.NewMiniAppServiceFromConfig(config) // 新增方法
→ miniAppService.Code2Session(code) // 新增方法
→ 返回 openID + unionID + sessionKey
→ nickname/avatar 从请求体获取(前端授权后传入)
```
**关键约束**:小程序 `Code2Session` 不调用 PowerWeChat SDK该 SDK 主要封装公众号和支付),而是直接 HTTP 请求微信 `jscode2session` 接口。这更简单可控。
### 5) Phone binding config
- 方案:手机号绑定策略使用 Viper 配置项 `client.require_phone_binding`bool在登录时实时读取不新增 DB 配置表。
- Why该策略属于部署级开关配置中心化更轻量减少数据库复杂度。
### 6) Asset binding on login
- 方案:每次登录都创建 `PersonalCustomerDevice` 绑定记录;同一资产允许被多个客户绑定,不做覆盖写入。
- Why业务上存在转手、共用、历史归属追踪需求强唯一会丢失使用关系。
### 7) Rate limiting strategy
- A1IP 级限频 `30/min`
- A4手机号维度 `60s` 冷却 + IP 维度 `20/hour` + 手机号维度 `10/day`
- WhyA1 主要防资产暴力枚举A4 主要防短信轰炸与资源滥用,采用多维限流降低绕过概率。
## Risks / Trade-offs
1. **Redis 强依赖风险**
- 风险Redis 异常会导致 token 校验失败、登录态不可用。
- 缓解:中间件区分“无效 token”与“Redis 不可用”并记录告警;部署 Redis 高可用;关键路径加入超时与重试上限。
2. **OpenID 合并误关联风险**
- 风险:若第三方返回异常 unionid可能出现错误合并。
- 缓解:仅在 unionid 非空且满足格式校验时启用回退合并记录合并审计日志customer_id、app_id、openid、unionid
3. **资产多人绑定带来的业务歧义**
- 风险:后续业务查询若默认“单资产单用户”,可能读取歧义。
- 缓解:规范下游以“当前登录 customer_id + asset”联合查询在文档中明确“资产可多客户绑定”语义。
4. **动态微信配置切换风险**
- 风险:运营误切换 `is_active` 导致登录瞬时失败。
- 缓解:限制仅单条激活、增加配置健康检查与缓存短 TTL、错误回退到最近一次可用配置。
## Migration Plan
1. **数据库迁移**
- 新增 `tb_personal_customer_openid` 表(含 `customer_id/app_id/open_id/union_id` 等字段)。
- 创建唯一索引:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
2. **配置更新**
-`pkg/config/defaults/config.yaml` 增加:
- `client.require_phone_binding: true|false`
3. **灰度切换顺序**
- 先上线迁移与新配置;
- 再上线新认证接口与中间件增强;
- 最后切换前端调用到 `/api/c/v1/auth/*`

View File

@@ -0,0 +1,135 @@
## Why
系统需要一套面向个人客户C 端)的完整认证体系,替代已删除的旧 H5 登录接口。客户端(微信公众号 H5 / 微信小程序)的登录流程与 B 端完全不同:基于**资产标识符**而非用户账号密码,先验证资产 → 再微信授权 → 自动绑定资产 → 可选绑定手机号。同时,公众号和小程序可能使用不同 AppID 且不一定绑定同一微信开放平台,需要支持多 OpenID 管理。
**前置依赖**:提案 0`client-api-data-model-fixes`)已完成 PersonalCustomer.wx_open_id 索引变更和旧接口删除。
## What Changes
### 新增模型
- **PersonalCustomerOpenID**:个人客户 OpenID 关联表,支持同一客户在不同 AppID公众号/小程序)下的多 OpenID 记录。唯一索引 `UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
### 认证接口(`/api/c/v1/auth/`
- **A1 验证资产标识符** `POST /verify-asset`:无需认证。输入 SN/IMEI/虚拟号/ICCID/MSISDN → 返回 `asset_token`(短时效 JWT5 分钟过期payload 含 asset_type + asset_id。IP 级别限频30 次/分钟)防暴力枚举。不暴露内部 asset_id
- **A2 微信公众号登录** `POST /wechat-login`:无需认证。用微信 OAuth code + asset_token → 查找/创建客户 → 绑定资产 → 签发有状态 JWT TokenRedis 存储)→ 返回 token + 是否需要绑定手机号
- **A3 微信小程序登录** `POST /miniapp-login`:无需认证。用小程序 jscode2session + asset_token → 同 A2 后续流程
- **A4 发送验证码** `POST /send-code`:无需认证。限频:同手机号 60s、同 IP 20 次/小时、每手机号 10 次/天
- **A5 绑定手机号** `POST /bind-phone`:需 JWT。首次绑定检查重复
- **A6 换绑手机号** `POST /change-phone`:需 JWT。双重验证码旧+新手机)
- **A7 退出登录** `POST /logout`:需 JWT。删除 Redis token 记录
### 基础设施
- **有状态 JWT Token 管理**JWT payload 仅含 `customer_id` + `exp`Redis 存储 token 有效状态,支持服务端主动失效(封禁/强制下线)
- **PersonalAuthMiddleware 增强**:增加 Redis 有效性检查token 不在 Redis 中则拒绝
- **统一资产解析公共方法** `resolveAssetFromIdentifier()`:个人客户调用不走 shop_id 数据权限过滤
- **OpenID 安全规范**:所有需要 OpenID 的接口支付、充值OpenID 由后端根据 `customer_id` + `app_type` 查 PersonalCustomerOpenID 表获取,禁止客户端传入
- **手机号绑定配置**:通过 Viper 配置 `client.require_phone_binding`boolean登录时检查并返回 `need_bind_phone` 标识
### 登录完整流程
```
用户打开客户端
输入资产标识符SN/IMEI/虚拟号/ICCID
[A1] POST /verify-asset ──→ 返回 asset_token5分钟有效
微信授权(前端完成)
├─── 公众号 ──→ [A2] POST /wechat-login (code + asset_token)
└─── 小程序 ──→ [A3] POST /miniapp-login (code + asset_token)
┌──────────────────┐
│ 解析 asset_token │
│ 获取微信 openid │
│ 查找/创建客户 │
│ 绑定资产 │
│ 签发 JWT + Redis │
└──────┬───────────┘
返回 { token, need_bind_phone, is_new_user }
need_bind_phone == true?
│ │
YES NO
│ │
▼ ▼
[A4] 发送验证码 进入主页面
[A5] 绑定手机号
进入主页面
```
### 客户查找/创建逻辑A2/A3 共享)
```
收到 openid + (可选)unionid
查 PersonalCustomerOpenID WHERE app_id=当前AppID AND open_id=openid
├── 找到 → 获取 customer_id → 已有客户
└── 没找到
有 unionid
├── YES → 查 PersonalCustomerOpenID WHERE union_id=unionid
│ │
│ ├── 找到 → 获取 customer_id → 新增当前 AppID 的 openid 记录
│ │
│ └── 没找到 → 创建新客户 + openid 记录
└── NO → 创建新客户 + openid 记录
```
## Capabilities
### New Capabilities
- `client-asset-token`资产验证令牌机制。A1 接口、asset_token JWT 生成/验证、IP 限频、安全规范(不暴露 asset_id
- `client-wechat-login`:微信登录(公众号+小程序。A2/A3 接口、OAuth/jscode2session 对接、客户查找/创建/合并逻辑、资产绑定(**首次绑定时触发 `asset_status` 从 1→2**、OpenID 多记录管理
- `client-phone-bindng`:手机号绑定/换绑。A4/A5/A6 接口、验证码发送/校验、限频规则、绑定/换绑逻辑
- `client-token-management`:有状态 JWT Token 管理。签发、Redis 存储、有效性检查、退出登录A7、服务端主动失效
- `personal-customer-openid`PersonalCustomerOpenID 模型定义、唯一索引、与 PersonalCustomer 的关系
### Modified Capabilities
- `personal-customer`PersonalCustomer 模型行为变化——登录逻辑从手机号+验证码改为微信授权wx_open_id 字段保留但逻辑迁移到 PersonalCustomerOpenID 表
- `asset-lifecycle-status`:首次客户绑定资产时,`asset_status` 从 1在库自动更新为 2已销售使用条件更新确保幂等
- `wechat-official-account`OAuth 配置来源变化——从 YAML 静态配置改为从 WechatConfig 表动态读取公众号/小程序 AppID+AppSecret
### 微信 SDK 使用说明
本提案使用项目中已有的微信 SDK`pkg/wechat/`,基于 PowerWeChat v3同时需要扩展小程序能力
| 场景 | SDK 方法 | 文件 | 状态 |
|------|---------|------|------|
| A2 公众号登录 | `OfficialAccountService.GetUserInfoDetailed(code)` | `pkg/wechat/official_account.go:69` | ✅ 已有,直接复用 |
| A3 小程序登录 | `MiniAppService.Code2Session(code)` | `pkg/wechat/miniapp.go` | ❌ **需新建**,直接 HTTP 调用微信 jscode2session |
| SDK 实例创建 | `NewOfficialAccountAppFromConfig(wechatConfig)` | `pkg/wechat/config.go` | ❌ **需新增**,从 DB 动态创建 |
| SDK 实例创建 | `NewMiniAppServiceFromConfig(wechatConfig)` | `pkg/wechat/config.go` | ❌ **需新增** |
| SDK 实例创建 | `NewPaymentAppFromConfig(wechatConfig, appID)` | `pkg/wechat/config.go` | ❌ **需新增**,供提案 2 支付使用 |
**现有 `NewOfficialAccountApp(cfg)` 从 YAML 创建实例,客户端场景需要从 `tb_wechat_config` DB 动态加载。**
## Impact
- **新增文件**`internal/model/personal_customer_openid.go`(模型)、`internal/handler/app/client_auth.go`(认证 Handler`internal/service/client_auth/service.go`(认证 Service`internal/store/postgres/personal_customer_openid_store.go`Store、**`pkg/wechat/miniapp.go`(小程序 SDK 封装)**、DTO 文件、迁移文件、常量定义
- **修改文件**`internal/middleware/personal_auth.go`(增加 Redis 检查)、`internal/routes/personal.go`(新增路由)、`internal/bootstrap/`(注册新模块)、`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)、`pkg/config/defaults/config.yaml`(新增 client 配置节)、`internal/model/system.go`AutoMigrate 注册新模型)、**`pkg/wechat/config.go`(新增 3 个 DB 动态工厂函数)**、**`pkg/wechat/wechat.go`(新增 MiniAppServiceInterface**
- **新增 API 路由**`/api/c/v1/auth/` 下 7 个端点
- **数据库变更**:新建 `tb_personal_customer_openid`
- **新增依赖**:无(微信 SDK 已有 PowerWeChat v3小程序 jscode2session 为纯 HTTP 调用)
- **配置变更**config.yaml 新增 `client.require_phone_binding` 配置项

View File

@@ -0,0 +1,71 @@
# client-asset-token Specification
## ADDED Requirements
### Requirement: A1 资产标识符验证接口
系统 MUST 提供无认证资产验证接口 `POST /api/c/v1/auth/verify-asset`,用于将外部资产标识符兑换为短时效 `asset_token`
- HTTP Method + Path: `POST /api/c/v1/auth/verify-asset`
- 请求体字段:
- `identifier` stringMUST资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
- 响应体字段:
- `asset_token` stringMUST5 分钟有效
- `expires_in` intMUST单位秒
- 错误码:
- `1006` 参数错误(标识符为空或格式非法)
- `1404` 资产不存在
- `1003` 请求过于频繁
#### Scenario: 资产验证成功并返回 asset_token
- **WHEN** 客户端提交合法且存在的资产标识符
- **THEN** 系统 SHALL 解析并定位资产
- **THEN** 系统 SHALL 签发 5 分钟有效的 `asset_token`
- **THEN** 系统 SHALL 返回 `{asset_token, expires_in}`
#### Scenario: 输入参数非法
- **WHEN** 客户端提交空字符串或不支持格式的标识符
- **THEN** 系统 MUST 返回参数错误码 `1006`
### Requirement: A1 输入校验与安全约束
系统 SHALL 对标识符进行白名单校验,并在 A1 响应中禁止暴露内部 `asset_id`
- 输入校验规则:
- MUST 去除前后空格并做长度限制
- MUST 仅允许预定义字符集(数字、字母、必要分隔符)
- MUST 拒绝 SQL 片段/控制字符
- 输出安全规则:
- MUST NOT 返回 `asset_id`
- MUST NOT 返回内部表名/字段名
#### Scenario: 防止内部主键泄露
- **WHEN** A1 接口返回成功响应
- **THEN** 返回体 MUST 只包含 `asset_token` 与有效期信息
- **THEN** 返回体 MUST NOT 包含 `asset_id`
### Requirement: A1 资产令牌签发规范
`asset_token` SHALL 使用独立签名密钥签发,且 payload 仅包含 `asset_type``asset_id`
- JWT 约束:
- `exp` = 当前时间 + 5 分钟
- payload MUST 包含 `asset_type``asset_id`
- payload MUST NOT 包含手机号、OpenID 等敏感信息
#### Scenario: token 结构与时效符合规范
- **WHEN** 服务端签发 `asset_token`
- **THEN** token MUST 使用资产令牌专用签名密钥
- **THEN** token MUST 在 5 分钟后过期
### Requirement: A1 IP 级限频
系统 SHALL 对 A1 实施 IP 维度限频:`30 次/分钟`
#### Scenario: 限频内请求通过
- **WHEN** 同一 IP 在 1 分钟内请求次数不超过 30 次
- **THEN** 系统 SHALL 正常处理请求
#### Scenario: 超过限频阈值
- **WHEN** 同一 IP 在 1 分钟内请求次数超过 30 次
- **THEN** 系统 MUST 返回错误码 `1003`

View File

@@ -0,0 +1,94 @@
# client-phone-binding Specification
## ADDED Requirements
### Requirement: A4 发送验证码接口
系统 MUST 提供无认证验证码接口 `POST /api/c/v1/auth/send-code`,并复用现有验证码服务。
- HTTP Method + Path: `POST /api/c/v1/auth/send-code`
- 请求体字段:
- `phone` stringMUST手机号
- `scene` stringMUST业务场景`bind_phone` / `change_phone_old` / `change_phone_new`
- 响应体字段:
- `cooldown_seconds` intMUST本次发送后的冷却秒数
- 错误码:
- `1006` 参数错误
- `1003` 请求过于频繁(触发任一限流)
- `1050` 短信发送失败
#### Scenario: 发送成功
- **WHEN** 手机号格式合法且未触发限流
- **THEN** 系统 SHALL 发送验证码并返回冷却时间
### Requirement: A4 限频规则
系统 SHALL 对 A4 实施三层限频:手机号 60 秒冷却、同 IP 每小时 20 次、同手机号每日 10 次。
#### Scenario: 60 秒内重复发送
- **WHEN** 同一手机号在 60 秒冷却内再次请求
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同 IP 超过小时阈值
- **WHEN** 同一 IP 在 1 小时内发送次数超过 20
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同手机号超过日阈值
- **WHEN** 同一手机号在当日发送次数超过 10
- **THEN** 系统 MUST 返回 `1003`
### Requirement: A5 首次绑定手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/bind-phone`,仅允许首次绑定。
- HTTP Method + Path: `POST /api/c/v1/auth/bind-phone`
- 请求体字段:
- `phone` stringMUST新手机号
- `code` stringMUST验证码
- 响应体字段:
- `phone` stringMUST已绑定手机号
- `bound_at` stringMUST绑定时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 手机号已被绑定
- `1038` 已绑定手机号不可重复绑定
#### Scenario: 首次绑定成功
- **WHEN** 客户已登录、验证码正确且手机号未被占用
- **THEN** 系统 SHALL 完成手机号首次绑定并返回绑定信息
#### Scenario: 已绑定用户再次调用绑定
- **WHEN** 当前客户已存在绑定手机号
- **THEN** 系统 MUST 返回 `1038`
### Requirement: A6 换绑手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/change-phone`,并执行旧手机号与新手机号双验证码校验。
- HTTP Method + Path: `POST /api/c/v1/auth/change-phone`
- 请求体字段:
- `old_phone` stringMUST旧手机号
- `old_code` stringMUST旧手机号验证码
- `new_phone` stringMUST新手机号
- `new_code` stringMUST新手机号验证码
- 响应体字段:
- `phone` stringMUST换绑后的手机号
- `changed_at` stringMUST换绑时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 新手机号已被绑定
- `1039` 旧手机号不匹配
#### Scenario: 换绑成功
- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用
- **THEN** 系统 SHALL 更新绑定手机号为新手机号
#### Scenario: 旧手机号校验失败
- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误
- **THEN** 系统 MUST 拒绝换绑并返回对应错误码

View File

@@ -0,0 +1,57 @@
# client-token-management Specification
## ADDED Requirements
### Requirement: 登录 JWT 签发与 Redis 状态存储
系统 MUST 在 A2/A3 登录成功后签发个人客户 JWT并将 token 状态写入 Redis。
- JWT payload 字段:
- `customer_id` uintMUST
- `exp` int64MUST
- Redis Key`RedisPersonalCustomerTokenKey(customerID)`
- Redis Value当前有效 token或 token 集合,取决于实现)
- TTLMUST 与 JWT 过期时间一致
#### Scenario: 登录成功写入 Redis
- **WHEN** 客户完成微信登录
- **THEN** 系统 SHALL 签发 JWT
- **THEN** 系统 SHALL 将 token 写入 Redis 并设置 TTL
### Requirement: PersonalAuthMiddleware 双重校验
系统 SHALL 在个人客户认证中间件执行双重校验JWT 解析校验 + Redis 状态校验。
#### Scenario: JWT 与 Redis 均有效
- **WHEN** 请求携带有效 JWT 且 Redis 中存在有效状态
- **THEN** 中间件 SHALL 放行并写入 `customer_id` 到上下文
#### Scenario: JWT 有效但 Redis 不存在
- **WHEN** JWT 仍在有效期但 Redis 中不存在该客户 token 状态
- **THEN** 中间件 MUST 返回未认证错误 `1002`
### Requirement: A7 退出登录接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/logout`,用于删除 Redis token 状态。
- HTTP Method + Path: `POST /api/c/v1/auth/logout`
- 请求体字段:无
- 响应体字段:
- `success` boolMUST
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
#### Scenario: 退出登录成功
- **WHEN** 登录客户调用 A7
- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)`
- **THEN** 系统 SHALL 返回成功
### Requirement: 服务端主动失效能力
系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。
#### Scenario: 服务端主动踢出
- **WHEN** 管理动作触发客户强制下线
- **THEN** 系统 SHALL 删除对应 Redis token 状态
- **THEN** 该客户后续请求 MUST 被中间件拒绝

View File

@@ -0,0 +1,105 @@
# client-wechat-login Specification
## ADDED Requirements
### Requirement: A2 微信公众号登录接口
系统 MUST 提供 `POST /api/c/v1/auth/wechat-login`,使用公众号 OAuth code + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/wechat-login`
- 请求体字段:
- `code` stringMUST微信 OAuth 授权码
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST是否需要绑定手机号
- `is_new_user` boolMUST是否新创建用户
- 错误码:
- `1002` token 无效或过期asset_token/JWT
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 公众号登录成功
- **WHEN** 客户端提交有效 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用公众号 OAuth 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行客户查找/创建/合并逻辑
- **THEN** 系统 SHALL 绑定资产并签发登录 token
### Requirement: A3 微信小程序登录接口
系统 MUST 提供 `POST /api/c/v1/auth/miniapp-login`,使用小程序 `jscode2session` + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/miniapp-login`
- 请求体字段:
- `code` stringMUST小程序登录凭证
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST
- `is_new_user` boolMUST
- 错误码:
- `1002` token 无效或过期
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 小程序登录成功
- **WHEN** 客户端提交有效小程序 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用 `jscode2session` 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行与 A2 一致的客户查找/创建/合并、资产绑定与签发逻辑
### Requirement: asset_token 校验与资产解析
系统 SHALL 在 A2/A3 登录前强制校验 `asset_token`,并解析出 `asset_type` + `asset_id`
#### Scenario: asset_token 无效
- **WHEN** `asset_token` 签名不合法或已过期
- **THEN** 系统 MUST 拒绝登录并返回 `1002`
#### Scenario: asset_token 有效
- **WHEN** `asset_token` 可被成功解析
- **THEN** 系统 SHALL 使用解析出的资产信息继续登录流程
### Requirement: 客户查找/创建/合并逻辑
系统 MUST 按以下顺序处理客户归属:
1. 先查 `PersonalCustomerOpenID``(app_id, open_id)`
2. 未命中且存在 `unionid` 时按 `unionid` 回查并复用客户;
3. 仍未命中时创建新 `PersonalCustomer` 与 OpenID 记录。
#### Scenario: openid 命中既有客户
- **WHEN** `(app_id, open_id)` 已存在
- **THEN** 系统 SHALL 直接复用对应 `customer_id`
#### Scenario: openid 未命中但 unionid 命中
- **WHEN** `(app_id, open_id)` 不存在且 `unionid` 命中历史记录
- **THEN** 系统 SHALL 复用已存在客户
- **THEN** 系统 SHALL 新增当前 `app_id + open_id` 记录
#### Scenario: openid/unionid 均未命中
- **WHEN** 无任何匹配记录
- **THEN** 系统 SHALL 创建新客户并写入 OpenID 记录
### Requirement: 登录后资产绑定
系统 SHALL 在 A2/A3 每次登录时创建一条 `PersonalCustomerDevice` 绑定记录,且 MUST 允许同一资产被多个客户绑定。
#### Scenario: 已有绑定时再次登录
- **WHEN** 同一客户再次登录同一资产
- **THEN** 系统 SHALL 记录本次登录绑定关系(按实现可去重或追加历史)
#### Scenario: 不同客户绑定同一资产
- **WHEN** 资产已被其他客户绑定
- **THEN** 系统 MUST 允许新增绑定,不得覆盖已有客户绑定关系
### Requirement: 登录响应与手机号绑定开关
系统 MUST 在登录响应中返回 `need_bind_phone`,该值由 `client.require_phone_binding` 与客户手机号绑定状态共同决定。
#### Scenario: 要求手机号绑定且未绑定
- **WHEN** 配置 `client.require_phone_binding=true` 且客户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
#### Scenario: 已绑定手机号或配置关闭
- **WHEN** 客户已绑定手机号或 `client.require_phone_binding=false`
- **THEN** 登录响应 MUST 返回 `need_bind_phone=false`

View File

@@ -0,0 +1,37 @@
# personal-customer-openid Specification
## ADDED Requirements
### Requirement: PersonalCustomerOpenID 模型定义
系统 MUST 新增 `PersonalCustomerOpenID` 模型与数据表 `tb_personal_customer_openid`,用于保存客户在不同 AppID 下的 OpenID 记录。
- 关键字段:
- `id` uint主键
- `customer_id` uintMUST关联个人客户 ID
- `app_id` stringMUST微信应用标识
- `open_id` stringMUST当前应用下 OpenID
- `union_id` string可选开放平台统一标识
- `created_at`/`updated_at`/`deleted_at`
- 索引约束:
- MUST 存在唯一索引 `UNIQUE(app_id, open_id)`(软删条件下唯一)
#### Scenario: 新增 OpenID 记录成功
- **WHEN** 登录流程创建新 OpenID 关系
- **THEN** 系统 SHALL 插入一条包含 `customer_id/app_id/open_id` 的记录
#### Scenario: 重复 app_id + open_id 被拒绝
- **WHEN** 试图插入已存在的 `(app_id, open_id)` 组合
- **THEN** 系统 MUST 触发唯一约束并拒绝写入
### Requirement: 与 PersonalCustomer 的关系约束
系统 SHALL 通过 `customer_id``PersonalCustomer` 建立逻辑关联(不使用数据库外键约束)。
#### Scenario: 根据 customer_id 查询 OpenID 列表
- **WHEN** 业务根据 `customer_id` 查询 OpenID
- **THEN** 系统 SHALL 返回该客户在多 AppID 下的全部有效记录
#### Scenario: 软删除客户后的记录处理
- **WHEN** 客户逻辑删除或状态失效
- **THEN** 系统 MUST 支持按业务策略同步停用或软删除 OpenID 记录

View File

@@ -0,0 +1,52 @@
# personal-customer Specification
## ADDED Requirements
### Requirement: 个人客户登录主流程改为微信授权
系统 SHALL 将个人客户登录主流程从“手机号 + 验证码登录”调整为“资产验证 + 微信授权登录”。
- 新登录入口:
- `POST /api/c/v1/auth/verify-asset`A1无认证
- `POST /api/c/v1/auth/wechat-login`A2无认证
- `POST /api/c/v1/auth/miniapp-login`A3无认证
- 请求与响应要点:
- A2/A3 请求体 MUST 包含 `code``asset_token`
- A2/A3 响应体 MUST 包含 `token``need_bind_phone``is_new_user`
- 错误码:
- `1006` 参数错误
- `1002` token 无效或过期
- `1040` 微信授权失败
#### Scenario: 通过微信授权完成登录
- **WHEN** 用户先完成 A1再提交 A2 或 A3
- **THEN** 系统 SHALL 完成客户识别/创建、资产绑定并返回登录 token
#### Scenario: 不再支持旧手机号直登入口
- **WHEN** 客户端调用旧手机号登录路径(如 `/api/c/v1/login`
- **THEN** 系统 MUST 按新路由规范拒绝或迁移提示,不再作为主登录路径
### Requirement: 手机号从“登录凭据”调整为“登录后补充资料”
系统 MUST 将手机号能力调整为登录后绑定/换绑,而非登录入口。
- 相关接口:
- `POST /api/c/v1/auth/send-code`A4无认证
- `POST /api/c/v1/auth/bind-phone`A5需认证
- `POST /api/c/v1/auth/change-phone`A6需认证
- 响应字段:
- A5/A6 MUST 返回绑定后的 `phone`
#### Scenario: 首次登录后要求绑定手机号
- **WHEN** `client.require_phone_binding=true` 且用户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
- **THEN** 用户通过 A4+A5 完成绑定后进入业务页面
### Requirement: 微信身份字段迁移到 OpenID 关联能力
系统 SHALL 保留 `PersonalCustomer.wx_open_id``wx_union_id` 字段的兼容性,但新登录链路 MUST 以 `PersonalCustomerOpenID` 为主。
#### Scenario: 读取用户微信身份
- **WHEN** 登录流程需要按微信身份识别客户
- **THEN** 系统 MUST 优先查询 `PersonalCustomerOpenID`
- **THEN** 不再依赖 `PersonalCustomer` 单字段承载多 AppID 场景

View File

@@ -0,0 +1,47 @@
# wechat-official-account Specification
## ADDED Requirements
### Requirement: 微信配置源从 YAML 改为数据库动态读取
系统 MUST 将公众号/小程序授权配置源从 YAML 静态配置切换为数据库 `tb_wechat_config` 动态读取(`is_active=true`)。
- 配置读取规则:
- 公众号登录A2使用 `app_id` + `app_secret`
- 小程序登录A3使用 `miniapp_app_id` + `miniapp_app_secret`
- 适配接口:
- `POST /api/c/v1/auth/wechat-login`
- `POST /api/c/v1/auth/miniapp-login`
#### Scenario: 公众号登录读取数据库配置
- **WHEN** 调用 A2 执行 OAuth code 换取 OpenID
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活公众号配置
#### Scenario: 小程序登录读取数据库配置
- **WHEN** 调用 A3 执行 jscode2session
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活小程序配置
### Requirement: 配置缺失或无激活记录时失败
系统 MUST 在缺少有效数据库配置时拒绝微信登录请求,并返回统一错误。
- 错误码:
- `1041` 微信配置不可用
- `1040` 微信授权失败(第三方调用失败)
#### Scenario: 无激活配置
- **WHEN** `tb_wechat_config` 中不存在 `is_active=true` 记录
- **THEN** 系统 MUST 返回 `1041`
#### Scenario: 配置存在但第三方调用失败
- **WHEN** 已获取数据库配置但调用微信接口失败
- **THEN** 系统 MUST 返回 `1040`
### Requirement: 旧 YAML 配置不再作为登录凭据来源
系统 SHALL 停止在登录链路中使用 `wechat.official_account.*` 静态配置作为 AppID/AppSecret 来源。
#### Scenario: 配置切换后行为一致
- **WHEN** 运维在数据库中更新激活配置
- **THEN** 后续登录请求 SHALL 使用新配置生效
- **THEN** 无需重启服务加载 YAML

View File

@@ -0,0 +1,70 @@
# client-auth-system 实施任务清单
## 1. 模型与迁移
- [x] 1.1 新增 `internal/model/personal_customer_openid.go`,定义 PersonalCustomerOpenID 模型与 TableName
- [x] 1.2 创建迁移文件,新增 `tb_personal_customer_openid` 表及 `UNIQUE(app_id, open_id) WHERE deleted_at IS NULL` 索引
- [x] 1.3 在 `internal/model/system.go` 注册新模型以纳入 AutoMigrate
- [x] 1.4 更新 `pkg/config/defaults/config.yaml`,新增 `client.require_phone_binding` 配置项
## 2. PersonalAuthMiddleware 增强
- [x] 2.1 在 `pkg/constants/redis.go` 新增 `RedisPersonalCustomerTokenKey(customerID)` 常量函数
- [x] 2.2 增强 `internal/middleware/personal_auth.go`,增加 JWT + Redis 双重校验
- [x] 2.3 完成 token 不在 Redis 时的拒绝逻辑与统一错误返回
## 3. 资产验证令牌(A1)
- [x] 3.1 新增认证 DTOA1 请求/响应)并补齐 OpenAPI 标签
- [x] 3.2 新增 `internal/handler/app/client_auth.go``VerifyAsset` Handler
- [x] 3.3 新增 `internal/service/client_auth/service.go` 的资产解析与 `asset_token` 签发逻辑5 分钟)
- [x] 3.4 实现 A1 IP 限流30/min与错误码映射
## 4. 微信 SDK 扩展(小程序 + 动态配置工厂)
- [x] 4.1 新增 `pkg/wechat/miniapp.go`:定义 `MiniAppService` 结构体 + `MiniAppServiceInterface` 接口 + `Code2Session(ctx, code)` 方法(直接 HTTP 调用微信 `jscode2session` 接口,不依赖 PowerWeChat SDK
- [x] 4.2 在 `pkg/wechat/wechat.go` 中新增 `MiniAppServiceInterface` 接口定义和编译时类型检查 `var _ MiniAppServiceInterface = (*MiniAppService)(nil)`
- [x] 4.3 在 `pkg/wechat/config.go` 中新增 `NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache, logger)` 工厂函数——从 DB 记录的 `oa_app_id` + `oa_app_secret` 创建公众号实例(复用 PowerWeChat `officialAccount.NewOfficialAccount`
- [x] 4.4 在 `pkg/wechat/config.go` 中新增 `NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger)` 工厂函数——从 DB 记录的 `miniapp_app_id` + `miniapp_app_secret` 创建小程序服务
- [x] 4.5 在 `pkg/wechat/config.go` 中新增 `NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache, logger)` 工厂函数——从 DB 记录创建支付实例,`appID` 参数决定关联应用(公众号/小程序)
## 5. 微信登录(A2+A3)
- [x] 5.1 新增 A2/A3 请求响应 DTO公众号与小程序
- [x] 5.2 在 `client_auth/service.go` 中实现动态读取 `tb_wechat_config WHERE is_active=true` 的配置加载逻辑(优先走 WechatConfigService 的 Redis 缓存)
- [x] 5.3 实现公众号登录(A2):调用 `NewOfficialAccountAppFromConfig``NewOfficialAccountService``GetUserInfoDetailed(code)` 获取 openid+unionid+昵称+头像(复用现有 `official_account.go` 的方法,不重新实现)
- [x] 5.4 实现小程序登录(A3):调用 `NewMiniAppServiceFromConfig``Code2Session(code)` 获取 openid+unionid+sessionKey昵称/头像从请求体获取
- [x] 5.5 实现客户查找/创建/合并逻辑openid 优先unionid 回退)
- [x] 5.6 新增 `internal/store/postgres/personal_customer_openid_store.go` 与相关查询/写入方法
- [x] 5.7 实现每次登录创建 PersonalCustomerDevice 绑定记录(允许同资产多客户);**首次绑定时**(该资产此前无任何 PersonalCustomerDevice 记录),将资产的 `asset_status` 从 1在库更新为 2已销售使用条件更新 `WHERE asset_status = 1` 确保幂等(已是 2 或其他状态则不变)
- [x] 5.8 实现登录 JWT 签发、Redis 存储与 `need_bind_phone` 计算
## 6. 验证码与手机号(A4+A5+A6)
- [x] 6.1 复用现有验证码服务(`internal/service/verification/service.go``SendCode`)实现 A4 发送验证码
- [x] 6.2 实现 A4 限流:手机号 60s、IP 20/hour、手机号 10/day
- [x] 6.3 实现 A5 首次绑定手机号逻辑(已绑定拒绝)
- [x] 6.4 实现 A6 双验证码换绑逻辑(旧手机号+新手机号)
- [x] 6.5 增补手机号绑定/换绑错误码与中文错误信息
## 7. 退出登录(A7)
- [x] 7.1 新增 A7 请求响应 DTO
- [x] 7.2 实现 `POST /api/c/v1/auth/logout` Handler 与 Service
- [x] 7.3 在 A7 中删除 `RedisPersonalCustomerTokenKey(customerID)` 完成服务端失效
## 8. 路由注册与文档
- [x] 8.1 在 `internal/bootstrap/types.go` 增加 ClientAuth Handler 字段
- [x] 8.2 在 `internal/bootstrap/handlers.go` 实例化 ClientAuth Handler
- [x] 8.3 在 `internal/routes/personal.go` 使用 `Register()` 注册 `/api/c/v1/auth/*` 七个端点
- [x] 8.4 在 `cmd/api/docs.go` 注册新 Handler 供文档生成器使用
- [x] 8.5 在 `cmd/gendocs/main.go` 注册新 Handler 供文档生成器使用
- [x] 8.6 执行 `go run cmd/gendocs/main.go` 并确认新接口出现在 OpenAPI 文档
## 9. 验证
- [x] 9.1 执行 `go build ./...`,确保构建通过
- [x] 9.2 运行 `lsp_diagnostics`,确保修改文件无错误
- [x] 9.3 按数据库验证规范检查新表与索引存在且结构正确
- [x] 9.4 在 `docs/client-auth-system/` 补充中文功能总结文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

@@ -0,0 +1,177 @@
# 设计文档client-core-business-api
## Context
认证系统(提案 1就绪后客户端需要一套完整业务接口以覆盖资产查询、钱包充值、套餐购买、实名跳转与设备操作。当前后台接口的 Service 层大部分能力可复用,但客户端场景存在以下关键差异:
1. 个人客户访问资源不应受 shop_id 数据权限过滤影响,需要在调用链中显式绕过。
2. 资产操作必须先做归属校验(绑定关系),避免跨客户操作。
3. 历史数据查询需要按资产当前 generation 过滤,避免展示转手前历史。
4. 支付 OpenID 必须后端查表获取,禁止客户端传入,降低伪造风险。
现有代码参考(复用/改造基线):
- `asset.Service.Resolve()``internal/service/asset/service.go:71`
- `asset.Service.Refresh()``internal/service/asset/service.go:295`
- `asset.Service.GetPackages()``internal/service/asset/service.go:347`
- `recharge.Service.GetRechargeCheck()``internal/service/recharge/service.go:168`
- `recharge.Service.Create()``internal/service/recharge/service.go:83`
- `order.Service.CreateH5Order()``internal/service/order/service.go:632`
- `order.Service.checkForceRechargeRequirement()``internal/service/order/service.go:2216`
- `order.Service.WechatPayJSAPI()``internal/service/order/service.go:2095`
- `purchaseValidation.ValidateCardPurchase()``internal/service/purchase_validation/service.go:44`
- Gateway 设备能力:`internal/gateway/device.go:41-67`
- Gateway 实名链接:`internal/gateway/flow_card.go:44`
## Goals
本次变更目标是交付 `/api/c/v1/` 下 18 个客户端业务端点,覆盖 5 个模块:
- 模块 B资产信息B1~B4
- 模块 C钱包与充值C1~C5
- 模块 D套餐购买D1~D3
- 模块 E实名跳转E1
- 模块 F设备能力F1~F5
## Non-Goals
- 不改动后台管理端 API 行为与路由。
- 不引入或改造 exchange交易所体系。
- 不重做既有支付网关对接协议,仅在客户端入口补齐调用与安全约束。
## Decisions
### 1) Handler 组织
客户端 Handler 按模块拆分为 5 个文件,统一放在 `internal/handler/app/`
- `client_asset.go`
- `client_wallet.go`
- `client_order.go`
- `client_realname.go`
- `client_device.go`
这样可保持模块边界清晰,减少单文件复杂度,便于后续迭代。
### 2) Service 复用策略
- 直接复用B1/B3/B4/C1/C2/C3 复用现有 Service 能力(补充客户端上下文约束)。
- 新增逻辑B2 需新增“渠道价 + 加油包前置校验 + 不可售过滤 + 价格排序”逻辑。
- 新建 `client_order` ServiceC4/D1 引入客户端订单编排,复用 `order/recharge` 的底层能力但增加客户端专属流程控制。
### 3) 数据权限绕过
客户端调用 `asset/wallet` 等后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)`,绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
### 4) 归属校验方案
所有涉及资产操作接口统一前置:
- 查询 `PersonalCustomerDevice` 条件:`customer_id = 当前登录客户``virtual_no = 资产虚拟号`
- 未命中即返回 403`无权限操作该资产或资源不存在`
该规则覆盖 B/C/D/E/F 全模块写操作与敏感读操作。
### 5) Generation 过滤
客户端历史查询统一附加条件:`WHERE generation = 资产当前 generation`,适用于订单、充值、套餐历史。
- 客户端:必须过滤
- 后台:不加该过滤(保留全量视图)
### 6) OpenID 安全规范 + 微信支付 SDK 实例选择
支付相关接口C4/D1所需 OpenID 必须由后端按 `customer_id + app_type` 查询 `PersonalCustomerOpenID`
- 客户端请求体禁止携带 `openid`,仅传 `app_type``official_account``miniapp`
- 缺失时返回 `OPENID_NOT_FOUND`
**微信支付 SDK 实例选择逻辑**
支付时需根据 `app_type` 创建不同的 `PaymentService` 实例,因为微信 JSAPI 支付绑定的 AppID 必须与用户 OpenID 所属的应用一致:
```
客户端传入 app_type
├─ "official_account" → 用 WechatConfig.oa_app_id 创建 Payment 实例
│ → 查 PersonalCustomerOpenID WHERE app_id=oa_app_id 获取 openid
└─ "miniapp" → 用 WechatConfig.miniapp_app_id 创建 Payment 实例
→ 查 PersonalCustomerOpenID WHERE app_id=miniapp_app_id 获取 openid
```
**使用的现有 SDK 方法**`pkg/wechat/payment.go`,不需要修改):
| 方法 | 签名 | 用途 |
|------|------|------|
| `CreateJSAPIOrder` | `(ctx, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)` | 公众号/小程序内拉起支付,返回 `prepay_id` + `PayConfig`(可直接传给前端 `wx.requestPayment` |
| `HandlePaymentNotify` | `(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)` | 支付回调验签+解密,回调函数接收 `*PaymentNotifyResult` |
| `QueryOrder` | `(ctx, orderNo string) (*OrderInfo, error)` | 主动查询订单状态 |
| `CloseOrder` | `(ctx, orderNo string) error` | 关闭未支付订单 |
**SDK 实例创建**(使用提案 1 新增的工厂函数):
```go
// 在 client_order Service 中:
config, _ := s.wechatConfigService.GetActiveConfig(ctx) // 从 DB/Redis 缓存
appID := config.OaAppID
if req.AppType == "miniapp" {
appID = config.MiniappAppID
}
paymentApp, _ := wechat.NewPaymentAppFromConfig(config, appID, cache, logger)
paymentService := wechat.NewPaymentService(paymentApp, logger)
// 调用 paymentService.CreateJSAPIOrder(ctx, orderNo, desc, openID, amount)
```
**注意**`CreateH5Order`(外部浏览器 H5 支付)在客户端场景中**不使用**——客户端始终在微信内(公众号 H5 或小程序),一律走 JSAPI 支付。
### 7) 强充两阶段设计
强充场景采用“同步入账 + 异步自动购买”两阶段:
- 第一阶段(同步事务内):
1. 钱入钱包
2. 更新充值记录状态
3. 更新累计充值/首充状态
- 第二阶段(异步 Asynq
1. 从钱包扣款
2. 创建套餐订单
3. 激活套餐
`AssetRechargeRecord` 新增 `auto_purchase_status` 字段追踪异步状态pending/success/failed
### 8) D1 返回结构分流
`POST /api/c/v1/orders/create` 根据是否触发强充返回不同结构:
- `order_type = "package"`:直接返回 `order + pay_config`
- `order_type = "recharge"`:返回 `recharge + pay_config + linked_package_info`
前端据 `order_type` 决定支付结果页与文案。
### 9) 实名闭环说明
运营商实名为外部流程,无平台回调。用户完成实名后需主动触发 B4 刷新资产状态,再重新发起购买流程。
## Risks / Trade-offs
1. **强充异步失败风险**:第二阶段失败会导致“钱已到账、套餐未生效”的中间态。权衡后采用可重试 + `auto_purchase_status=failed` + 用户可手动购买的降级方案。
2. **Gateway 超时风险**B4/F2/F3/F4/F5/E1(gateway) 依赖外部网关,网络抖动可能放大请求延迟。需统一超时、重试与可观测日志。
3. **OpenID 缺失风险**:用户未完成公众号授权时无法拉起支付。需明确错误码 `OPENID_NOT_FOUND` 并引导重新授权。
4. **Generation 不一致风险**:资产转手或切换后,若查询未按 generation 过滤会出现历史串数据。客户端侧强制过滤会增加查询条件复杂度,但可换取数据隔离正确性。
5. **服务复用边界风险**:复用后台 Service 可加速交付,但若遗漏客户端前置条件(归属、权限绕过),会造成逻辑缺口。需在 Handler 层统一封装公共校验。
## Migration Plan
数据库迁移新增字段:
- 表:`tb_asset_recharge_record`
- 字段:`auto_purchase_status`(建议 `varchar(20)`,默认 `pending`
- 用途:记录强充回调后二阶段自动购买状态
迁移步骤:
1. 新增迁移文件up/down
2. 执行迁移并确认版本无 dirty。
3. 更新对应 Model 与常量枚举。
4. 回调逻辑写入状态流转:`pending -> success/failed`

View File

@@ -0,0 +1,175 @@
## Why
认证系统就绪后(提案 1客户端需要完整的业务接口来支撑核心使用场景查看资产信息、购买套餐、钱包充值、查看订单、实名认证跳转、设备操作。本提案覆盖客户端**全部 15 个业务接口**,是 C 端体验的核心支撑。
其中**套餐购买含强充两阶段处理**是最复杂的接口,涉及强充判断 → 微信支付 → 充值回调 → 异步套餐购买的完整链路,需要特别严谨的设计。
**前置依赖**:提案 0数据模型修复、提案 1认证系统
## What Changes
### 模块 B资产信息4 个接口)
- **B1 资产基本信息** `GET /api/c/v1/asset/info?identifier=xxx`:复用 `asset.Service.Resolve()`,个人客户调用不走 shop_id 数据权限过滤
- **B2 可购买套餐列表** `GET /api/c/v1/asset/packages?identifier=xxx`:按渠道区分价格(代理→`allocation.retail_price`,平台→`SuggestedRetailPrice`);过滤条件包含 `Package.status``shelf_status`、加油包前置校验;按价格升序排序
- **B3 历史套餐列表** `GET /api/c/v1/asset/package-history?identifier=xxx`:按资产当前 `generation` 过滤,复用 `dto.AssetPackageResponse`
- **B4 手动刷新** `POST /api/c/v1/asset/refresh`:卡类型调 Gateway 刷新;设备类型有 Redis 冷却时间
### 模块 C钱包与充值5 个接口)
- **C1 钱包详情** `GET /api/c/v1/wallet/detail?identifier=xxx`:不存在则自动创建空钱包
- **C2 钱包流水列表** `GET /api/c/v1/wallet/transactions?identifier=xxx`:通过 wallet_id 天然隔离(不需 generation 过滤),支持 transaction_type / 时间范围筛选
- **C3 充值预检** `GET /api/c/v1/wallet/recharge-check?identifier=xxx`:复用 `recharge.Service.GetRechargeCheck()`,返回是否需要强充、强充金额、触发类型
- **C4 创建充值订单** `POST /api/c/v1/wallet/recharge`客户端仅支持微信支付OpenID 由后端查表获取(安全规范);`operator_type=personal_customer``generation` 写时快照;拉起 JSAPI 支付
- **C5 充值订单列表** `GET /api/c/v1/wallet/recharges?identifier=xxx`:按 `generation` 过滤
### 模块 D套餐购买3 个接口,含核心强充流程)
- **D1 创建套餐购买订单** `POST /api/c/v1/orders/create`**核心接口**。含资产归属校验、套餐校验、实名校验、强充两阶段处理、幂等性保证
- **D2 套餐订单列表** `GET /api/c/v1/orders?identifier=xxx`:按 `generation` 过滤
- **D3 套餐订单详情** `GET /api/c/v1/orders/:id`:归属校验(通过资产虚拟号匹配 PersonalCustomerDevice
### 模块 E实名认证1 个接口)
- **E1 获取实名跳转链接** `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`:两个入口(购买拦截 / 设备卡列表主动选择三种模式none/template/gateway
### 模块 F设备能力5 个接口)
- **F1 设备卡列表** `GET /api/c/v1/device/cards?identifier=xxx`:从 CMP 数据库查,不调 Gateway
- **F2 设备重启** `POST /api/c/v1/device/reboot`
- **F3 恢复出厂** `POST /api/c/v1/device/factory-reset`
- **F4 设置 WiFi** `POST /api/c/v1/device/wifi`:注意 Gateway WiFiReq 的 cardNo 字段实际传入设备 IMEI
- **F5 切卡** `POST /api/c/v1/device/switch-card`
### D1 套餐购买核心流程(含强充两阶段)
```
客户端发起 POST /api/c/v1/orders/create
① 解析标识符 → card/device + asset_type + asset_id
② 资产归属校验
查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=?
未绑定 → 403 "无权操作该资产"
③ 套餐购买校验
调 purchaseValidationService.ValidateCardPurchase/ValidateDevicePurchase
检查: series_id、Package.status、shelf_status、加油包前置条件
④ 实名校验
套餐 enable_realname_activation=true 且卡 real_name_status=0
→ 返回 { code: NEED_REALNAME, need_realname: true }
⑤ OpenID 查询
根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid
找不到 → 返回 { code: OPENID_NOT_FOUND }
⑥ 幂等性检查Redis 业务键 + 分布式锁)
⑦ 强充检查
调 checkForceRechargeRequirement()
├─── 不需要强充 ──────────────────────────────┐
│ │
│ ⑧A 创建 Order │
│ (source="client", generation=当前) │
│ 拉起微信 JSAPI 支付 │
│ → 返回 { order_type: "package", │
│ order, pay_config } │
│ │
└─── 需要强充 ────────────────────────┐ │
│ │ │
▼ │ │
pay_amount = max(force_amount, │ │
package_total_price) │ │
│ │ │
▼ │ │
⑧B 创建 AssetRechargeRecord │ │
(linked_package_ids=package_ids, │ │
generation=当前) │ │
拉起微信 JSAPI 支付 │ │
→ 返回 { order_type: "recharge", │ │
recharge, pay_config, │ │
linked_package_info } │ │
│ │
═══════════════════════════════════════════╧════════╧═══
强充支付成功后的两阶段回调处理:
微信支付成功 → 充值回调
第一阶段(同步,事务内):
├── 1. 钱入钱包(余额增加)
├── 2. 更新充值单状态为已完成
├── 3. 更新累计/首充状态
└── 4. 检查一次性佣金触发
第二阶段异步Asynq 任务):
├── 5. 入队 AutoPurchaseAfterRecharge(recharge_record_id)
├── 6. 异步执行:
│ ├── a. 从钱包扣款payment_method=wallet
│ ├── b. 创建 Ordersource="client", generation=当前)
│ └── c. 激活套餐
└── 7. 失败处理:
├── a. Asynq 自动重试(最多 3 次)
├── b. 全部失败 → 标记 auto_purchase_status="failed"
└── c. 钱已在钱包中,用户可手动操作
```
### 实名跳转流程
```
前端调用 GET /api/c/v1/realname/link?identifier=xxx[&iccid=yyy]
解析标识符 → 确定目标卡:
├── 直接是卡 → 用该卡
├── 是设备 + 传了 iccid → 查该 iccid 对应的卡
└── 是设备 + 没传 iccid → 查 DeviceSimBinding 中 isActive=1 的卡
检查 card.real_name_status == 1?
├── YES → "该卡已完成实名"
└── NO
查 Carrier WHERE id=card.carrier_id → 获取 realname_link_type:
├── 'none' → "该运营商暂不支持在线实名"
├── 'template' → 替换占位符 {iccid}/{msisdn}/{virtual_no} → 返回 URL
└── 'gateway' → 调 gateway.GetRealnameLink(card.ICCID) → 返回 URL
```
## Capabilities
### New Capabilities
- `client-asset-info`客户端资产信息查询B1、可购买套餐列表B2含渠道价格、加油包校验、上下架过滤、历史套餐列表B3含 generation 过滤、手动刷新B4
- `client-wallet-recharge`客户端钱包详情C1、流水列表C2、充值预检C3、创建充值订单C4含 OpenID 安全规范、operator_type、generation 快照、充值订单列表C5
- `client-order-purchase`套餐购买订单创建D1含归属校验、实名校验、强充两阶段、幂等性、订单列表D2、订单详情D3、强充回调异步购买AutoPurchaseAfterRecharge Asynq 任务)
- `client-realname-link`实名跳转链接E1三种模式、两个入口、设备多卡选择
- `client-device-capability`设备卡列表F1、重启F2、恢复出厂F3、WiFi 设置F4、切卡F5
### Modified Capabilities
- `asset-resolve`Resolve 方法增加"无数据权限过滤"的客户端调用入口
- `wallet-recharge`:充值回调增加两阶段处理——同步入账 + 异步自动购买AssetRechargeRecord 新增 `auto_purchase_status` 字段跟踪异步购买状态
- `package-purchase-validation`增加客户端场景的归属校验PersonalCustomerDevice和实名校验拦截
- `iot-order`Order 新增客户端创建路径source="client"),支持 generation 写入
- `force-recharge-check`:强充检查结果输出给客户端,支持前端提示强充金额和套餐价格拆分
## Impact
- **新增文件**`internal/handler/app/client_asset.go``client_wallet.go``client_order.go``client_realname.go``client_device.go`5 个 Handler`internal/service/client_order/service.go`(客户端订单 Service新增 Asynq 任务 `AutoPurchaseAfterRecharge`;新增 DTO 文件;常量和错误码
- **修改文件**`internal/service/order/service.go`(提取强充逻辑供客户端复用);`internal/service/recharge/service.go`(充值回调增加两阶段处理);`internal/service/asset/service.go`(增加无数据权限调用方式);`internal/routes/personal.go`(新增客户端业务路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)
- **新增 API 路由**`/api/c/v1/` 下 18 个端点
- **数据库变更**AssetRechargeRecord 新增 `auto_purchase_status` 字段
- **新增 Asynq 任务类型**`task:auto_purchase_after_recharge`

View File

@@ -0,0 +1,41 @@
# Capability: 客户端资产信息
## ADDED Requirements
### Requirement: B1 资产基本信息查询接口
系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证C 端 Token。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`ICCID、虚拟号、设备号之一。响应体 SHALL 返回 `asset_type``asset_id``identifier``virtual_no``status``real_name_status``carrier``generation``wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_NOT_FOUND/资产不存在`
#### Scenario: 个人客户查询已绑定资产
- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人
- **THEN** 系统返回 200包含资产基础信息与当前 generation
---
### Requirement: B2 可购买套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1``shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id``package_name``package_type``retail_price``cost_price``validity``is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``PACKAGE_NOT_AVAILABLE/当前无可购买套餐`
#### Scenario: 代理渠道价格与过滤生效
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price`
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出
---
### Requirement: B3 历史套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page``page_size`(默认 20最大 100。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 转手后历史隔离
- **WHEN** 资产已发生转手且存在历史套餐记录
- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据
---
### Requirement: B4 手动刷新接口
系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type``card`/`device`)、`accepted``cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备刷新冷却拦截
- **WHEN** 客户在冷却时间内重复调用设备刷新
- **THEN** 系统返回频率限制错误并告知剩余冷却时间

View File

@@ -0,0 +1,51 @@
# Capability: 客户端设备能力
## ADDED Requirements
### Requirement: F1 设备卡列表接口
系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id``iccid``msisdn``carrier_name``network_status``real_name_status``slot_position``is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失`
#### Scenario: 返回设备绑定卡列表
- **WHEN** 客户查询已绑定设备卡列表
- **THEN** 系统返回设备下全部卡及活跃标记
---
### Requirement: F2 设备重启接口
系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true``request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备重启成功受理
- **WHEN** 客户对合法设备发起重启
- **THEN** 系统调用网关成功并返回受理结果
---
### Requirement: F3 设备恢复出厂接口
系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 恢复出厂失败返回网关错误
- **WHEN** 网关返回失败
- **THEN** 系统返回网关调用失败错误
---
### Requirement: F4 设备 WiFi 设置接口
系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``ssid``password``enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: WiFi 请求 cardNo 使用 IMEI
- **WHEN** 客户调用设备 WiFi 设置
- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI
---
### Requirement: F5 设备切卡接口
系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true``target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 切卡成功返回目标卡号
- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功
- **THEN** 系统返回 `accepted=true` 与目标 ICCID

View File

@@ -0,0 +1,46 @@
# Capability: 客户端套餐购买
## ADDED Requirements
### Requirement: D1 创建套餐购买订单接口
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``package_ids[]``app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
- 无强充:创建套餐订单并返回 `order_type="package"``order``pay_config`
- 需强充:创建充值单并返回 `order_type="recharge"``recharge``pay_config``linked_package_info`
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``NEED_REALNAME/该套餐需实名认证后购买``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交``PACKAGE_NOT_AVAILABLE/套餐不可购买`
#### Scenario: 命中强充返回 recharge 结构
- **WHEN** 客户购买套餐触发强充要求
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
---
### Requirement: D2 套餐订单列表接口
系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项至少含 `order_id``order_no``total_amount``payment_status``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 支持支付状态筛选
- **WHEN** 客户带 `payment_status=paid` 查询订单
- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单
---
### Requirement: D3 套餐订单详情接口
系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ORDER_NOT_FOUND/订单不存在`
#### Scenario: 查询他人订单被拦截
- **WHEN** 客户请求不属于本人资产的订单详情
- **THEN** 系统返回 403错误消息为无权限操作该资产或资源不存在
---
### Requirement: AutoPurchaseAfterRecharge 异步任务
系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`

View File

@@ -0,0 +1,23 @@
# Capability: 客户端实名跳转
## ADDED Requirements
### Requirement: E1 获取实名跳转链接接口
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
1. 标识符直达卡:直接使用该卡
2. 标识符为设备且传 `iccid`:定位对应设备下卡
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
`real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持:
- `none`:不支持在线实名,直接报错
- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL
- `gateway`:调用网关获取实名链接
响应体 SHALL 至少包含 `realname_mode``realname_url``card_info{iccid,msisdn,virtual_no}``expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``REALNAME_ALREADY_DONE/该卡已完成实名``REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名``GATEWAY_ERROR/获取实名链接失败`
#### Scenario: 设备未传 iccid 自动选活跃卡
- **WHEN** 客户传入设备标识符且不传 `iccid`
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接

View File

@@ -0,0 +1,51 @@
# Capability: 客户端钱包与充值
## ADDED Requirements
### Requirement: C1 钱包详情接口
系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id``resource_type``resource_id``balance``frozen_balance``updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 首次访问自动建钱包
- **WHEN** 客户查询资产钱包详情且钱包记录不存在
- **THEN** 系统自动创建钱包并返回余额 0
---
### Requirement: C2 钱包流水列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type``start_time``end_time``page``page_size`。响应体 SHALL 包含 `list[]``total``page``page_size`,每条记录至少含 `transaction_id``type``amount``balance_after``created_at``remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: wallet_id 隔离生效
- **WHEN** 客户查询某资产流水
- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据
---
### Requirement: C3 充值预检接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge``force_recharge_amount``trigger_type``min_amount``max_amount``message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 返回强充预检结果
- **WHEN** 资产命中强充规则
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
---
### Requirement: C4 创建充值订单接口
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier``amount`100~10000000 分)、`payment_method=wechat``app_type`。接口 MUST 禁止客户端传入 OpenID并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge``pay_config`,其中 `recharge` 至少含 `recharge_id``recharge_no``amount``status``pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``FORBIDDEN/无权限操作该资产或资源不存在``PAYMENT_NOT_SUPPORTED/仅支持微信支付`
#### Scenario: 后端查 OpenID 并返回支付参数
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
---
### Requirement: C5 充值订单列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,每项至少含 `recharge_id``recharge_no``amount``status``payment_method``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: generation 过滤充值历史
- **WHEN** 资产存在多代充值记录
- **THEN** 系统仅返回当前 generation 对应的充值记录

View File

@@ -0,0 +1,21 @@
# Capability: 强充预检
## MODIFIED Requirements
### Requirement: 强充检查结果对客户端透出
系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge``force_recharge_amount``trigger_type``total_package_amount``actual_payment``wallet_credit``message`。若无强充,`need_force_recharge=false``actual_payment=total_package_amount`
#### Scenario: 客户端购买预检命中强充
- **WHEN** 客户端调用购买预检且命中强充规则
- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额
---
### Requirement: 前端展示套餐价与强充金额拆分
系统 SHALL 在强充场景提供可直接渲染的拆分语义套餐总价、需支付金额、充值入钱包金额并给出中文提示文案。当前端调用客户端下单接口D1若命中强充 MUST 返回 `order_type="recharge"``linked_package_info`,以便前端保持与预检展示一致。
#### Scenario: 套餐价低于强充金额
- **WHEN** 套餐总价 5000 分,强充金额 10000 分
- **THEN** 预检返回 `actual_payment=10000``wallet_credit=5000`、提示文案可用于前端直接展示

View File

@@ -0,0 +1,33 @@
# Capability: 钱包充值
## MODIFIED Requirements
### Requirement: 充值回调采用两阶段处理
系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。
#### Scenario: 强充回调同步入账成功并触发异步任务
- **WHEN** 强充充值支付回调验签成功
- **THEN** 系统在事务内完成钱包入账与充值单状态更新
- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务
---
### Requirement: 充值记录新增 auto_purchase_status 状态追踪
系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending``success``failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`
#### Scenario: 强充充值单创建时默认 pending
- **WHEN** 系统创建与套餐联动的强充充值单
- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending`
---
### Requirement: 异步自动购买失败处理规范
系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。
#### Scenario: 异步任务最终失败
- **WHEN** 自动购买任务连续失败并达到最大重试次数
- **THEN** 系统将 `auto_purchase_status` 标记为 `failed`
- **AND** 钱包余额保持可用,用户可手动下单

View File

@@ -0,0 +1,70 @@
## 1. 常量与错误码
- [x] 1.1 在 `pkg/constants/constants.go` 增加 `auto_purchase_status` 状态常量pending/success/failed
- [x] 1.2 在 `pkg/errors/codes.go` 增加 `NEED_REALNAME``OPENID_NOT_FOUND` 错误码并补充中文消息
- [x] 1.3 在 `pkg/constants/redis.go` 增加客户端购买幂等键与锁键生成函数
## 2. DTO 定义
- [x] 2.1 新增资产模块 DTOB1/B2/B3/B4 请求与响应结构(含 description/validate 标签)
- [x] 2.2 新增钱包充值模块 DTOC1~C5 请求与响应结构(含支付返回 `pay_config`
- [x] 2.3 新增订单模块 DTOD1~D3 请求与响应结构(含 `order_type` 分流结构)
- [x] 2.4 新增实名与设备模块 DTOE1、F1~F5 请求与响应结构
## 3. 模型变更与迁移
- [x] 3.1 在 `internal/model/asset_recharge_record.go` 增加 `auto_purchase_status` 字段与中文注释
- [x] 3.2 创建迁移文件为 `tb_asset_recharge_record` 添加 `auto_purchase_status` 字段up/down
- [x] 3.3 执行迁移并确认版本状态正常(非 dirty
## 4. 资产信息模块B1~B4
- [x] 4.1 新建 `internal/handler/app/client_asset.go` 并实现 B1~B4 路由处理
- [x] 4.2 抽取公共方法 `resolveAssetFromIdentifier`(解析标识符 + 归属校验 + 权限绕过上下文)
- [x] 4.3 实现 B2 渠道价格计算、加油包前置校验、上下架过滤与价格升序
- [x] 4.4 实现 B4 刷新分流:卡走 Gateway、设备走 Redis 冷却 + 批量刷新
## 5. 钱包与充值模块C1~C5
- [x] 5.1 新建 `internal/handler/app/client_wallet.go` 并实现 C1~C5 路由处理
- [x] 5.2 实现 C1 钱包不存在自动创建逻辑与 C2 wallet_id 隔离查询
- [x] 5.3 复用并接入 C3 强充预检返回结构
- [x] 5.4 实现 C4 创建充值订单:根据 `app_type` 查 PersonalCustomerOpenID 获取 openid → 根据 `app_type` 选择 AppID`official_account``oa_app_id``miniapp``miniapp_app_id`)→ 调用提案 1 新增的 `wechat.NewPaymentAppFromConfig(config, appID)` 创建支付实例 → 调用现有 `PaymentService.CreateJSAPIOrder(orderNo, desc, openID, amount)` 拉起支付 → 设置 `operator_type=personal_customer`、写入 `generation` 快照
- [x] 5.5 实现 C5 充值记录 generation 过滤查询
## 6. 套餐购买模块D1~D3
- [x] 6.1 新建 `internal/handler/app/client_order.go` 并实现 D1~D3 路由处理
- [x] 6.2 新建 `internal/service/client_order/service.go` 编排 D1 全流程(归属/套餐/实名/OpenID/幂等/强充分流)。支付调用链:根据 `app_type` 选择 AppID → `wechat.NewPaymentAppFromConfig(config, appID)``PaymentService.CreateJSAPIOrder()` → 返回 `pay_config` 给前端。**客户端一律走 JSAPI 支付(微信内环境),不使用 H5 支付**
- [x] 6.3 实现强充两阶段:同步入账 + 异步自动购买Asynq 入队)。注意第二阶段自动购买创建的订单使用 `payment_method=wallet`(钱包扣款),不涉及微信支付
- [x] 6.4 新增 `AutoPurchaseAfterRecharge` 任务处理器(钱包扣款→创建订单→激活套餐,失败重试 3 次)
- [x] 6.5 实现 D1 `order_type` 双结构返回package/recharge
- [x] 6.6 实现 D2/D3 generation 与归属校验约束
## 7. 实名跳转E1
- [x] 7.1 新建 `internal/handler/app/client_realname.go` 并实现 E1 接口
- [x] 7.2 实现目标卡定位三路径(直接卡/设备+iccid/设备活跃卡)
- [x] 7.3 实现实名模式三分支none/template/gateway与模板占位符替换
## 8. 设备能力F1~F5
- [x] 8.1 新建 `internal/handler/app/client_device.go` 并实现 F1~F5 路由处理
- [x] 8.2 实现通用前置校验(必须设备类型且 IMEI 非空)
- [x] 8.3 对接 Gateway`RebootDevice``ResetDevice``SetWiFi``SwitchCard`
- [x] 8.4 实现 F4 特殊映射:`WiFiReq.cardNo = 设备IMEI`
## 9. 路由注册与文档
- [x] 9.1 在 `internal/bootstrap/types.go` 增加客户端业务 Handler 字段
- [x] 9.2 在 `internal/bootstrap/handlers.go` 完成客户端业务 Handler 实例化
- [x] 9.3 在 `internal/routes/personal.go` 注册 `/api/c/v1/` 18 个端点(使用 `Register()`
- [x] 9.4 在 `cmd/api/docs.go` 注册新增 Handler 供文档生成器使用
- [x] 9.5 在 `cmd/gendocs/main.go` 注册新增 Handler 并生成 OpenAPI 文档
## 10. 验证
- [x] 10.1 运行 `go build ./...` 确认构建通过
- [x] 10.2 运行 LSP diagnostics确保改动文件无错误
- [x] 10.3 使用数据库验证流程确认 `auto_purchase_status` 字段已生效
- [x] 10.4 补充 `docs/client-core-business-api/功能总结.md` 并更新相关索引文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

@@ -0,0 +1,149 @@
# 设计文档客户端换货系统client-exchange-system
## 背景与上下文
现有换卡能力基于 `CardReplacementRecord`,仅覆盖“老卡→新卡”的窄场景,无法支撑本次目标中的完整换货闭环(后台发起、客户端填收货、后台发货、确认完成、可选全量迁移、旧资产转新再销售)。
当前主要问题:
1. **模型能力不足**`internal/model/card_replacement.go:11-71`
- 只支持卡换卡,不支持设备换设备。
- 缺少客户端收货地址、后台物流信息、迁移结果字段。
- 状态机不匹配本次流程(待填写→待发货→已发货待确认→已完成/已取消)。
2. **历史代码字段不一致风险**`internal/store/postgres/iot_card_store.go:644-655`
- 现有查询使用 `old_iot_card_id` 维度过滤换卡记录,但旧模型字段命名是 `old_card_id`,存在语义/列名不一致隐患。
- `is_replaced` 逻辑依赖旧表,不适配新换货单模型。
3. **旧模型未纳入统一迁移体系**
- `CardReplacementRecord` 没有持续参与当前主线 AutoMigrate 维护,演进风险高。
4. **资产迁移链路涉及多模型联动,旧方案无法表达**
- 钱包与流水:`internal/model/asset_wallet.go:9-35``resource_type + resource_id`
- 套餐使用:`internal/model/package.go:57-87`
- 标签:`internal/model/tag.go:25-41`
- 客户设备绑定:`internal/model/personal_customer_device.go:9-23`
- 设备卡绑定:`internal/model/device_sim_binding.go:9-24`
- 分佣记录:`internal/model/commission.go:9-30`
- 流量明细:`internal/model/data_usage.go:7-23`
- 卡累计字段:`internal/model/iot_card.go:41-44``FirstCommissionPaid``AccumulatedRecharge``AccumulatedRechargeBySeriesJSON``FirstRechargeTriggeredBySeriesJSON`
5. **模块接入需遵循统一 Bootstrap 装配模式**
- 参考 `internal/bootstrap/handlers.go:12-62``internal/bootstrap/types.go:13-60`
## 目标与非目标
### Goals
1. 提供完整换货生命周期能力:
- 后台 7 个接口H1~H7
- 客户端 2 个接口G1~G2
2. 在 H5 确认完成时支持可选“全量迁移”11 张表规则)。
3. 支持旧资产“转新”再销售generation+1、状态重置、历史隔离
4. 替换旧换卡模型引用,统一到 ExchangeOrder。
### Non-Goals
1. 不对接第三方物流轨迹查询(仅记录物流公司/单号)。
2. 不实现主动消息推送(客户端通过 G1 轮询换货通知)。
## 关键设计决策
### 决策 1ExchangeOrder 模型设计
引入新模型 `ExchangeOrder`,作为换货生命周期唯一事实来源,字段覆盖:
- 基础字段:`gorm.Model + BaseModel`
- 单号:`exchange_no`
- 旧资产快照:`old_asset_type``old_asset_id``old_asset_identifier`
- 新资产快照:`new_asset_type``new_asset_id``new_asset_identifier`
- 收货信息:`recipient_name``recipient_phone``recipient_address`
- 物流信息:`express_company``express_no`
- 迁移结果:`migrate_data``migration_completed``migration_balance`
- 业务信息:`exchange_reason``remark``status`
- 多租户:`shop_id`
换货单号生成规则:`EXC` + 日期 + 随机数(示例:`EXC20260319XXXXXX`)。
### 决策 2状态机由 Service 层强校验
`status` 采用 int 常量:
- 1 待填写信息
- 2 待发货
- 3 已发货待确认
- 4 已完成
- 5 已取消
状态流转在 Service 层校验,不使用数据库触发器。理由:
1. 业务规则集中在 Go 代码,便于复用和审计。
2. 避免跨环境数据库触发器差异。
3. 更易与错误码体系、权限体系协同。
### 决策 3发货时执行同类型资产校验
在 H4 发货阶段强制校验:
1. `new_asset_type == old_asset_type`(卡换卡 / 设备换设备)。
2. 新资产必须 `asset_status=1`(在库)。
该校验放在“发货”而非“创建”,因为创建时允许先立单、后备货。
### 决策 4全量迁移使用单一大事务11 张表)
H5 在 `migrate_data=true` 时,使用**一个数据库事务**完成 11 张表相关操作。理由:
1. 迁移一致性优先,必须保证“要么全成功,要么全失败”。
2. 换货属于低频运营操作,非高并发核心交易路径。
3. 单资产迁移涉及行数有限,可接受事务时间。
补充规则:设备换设备时,不迁移 `DeviceSimBinding`(新设备视为自带新卡体系)。
### 决策 5转新采用 generation 隔离历史
H7 转新时:
1. `generation = generation + 1`
2. 不删除旧代际历史数据(订单、充值、分佣、流量等)
3. 创建新空钱包(新 `wallet_id` 天然隔离流水)
4. 清除累计充值/首充触发状态
5. 清除客户绑定关系
通过“新代际 + 新钱包”实现可回收再销售,同时不破坏历史可追溯。
### 决策 6旧模型降级为 legacy不回灌迁移
`CardReplacementRecord` 对应表改名为 `tb_card_replacement_record_legacy`,但**不迁移历史数据到新表**。理由:
1. 旧数据量小,保留查询价值即可。
2. 历史数据结构与新模型语义不完全一致,强行回灌成本高且收益低。
`iot_card_store.go``is_replaced` 过滤逻辑改为查询 `ExchangeOrder`,不再依赖旧表。
### 决策 7依托现有多租户 Callback 自动过滤
`ExchangeOrder` 增加 `shop_id` 字段,直接接入现有 GORM 数据权限 Callback避免重复实现权限 where 条件。
## 风险与权衡
1. **[风险] 全量迁移事务锁表时间增长**
- 权衡:换货低频,且单次仅操作单资产关联记录,影响可接受。
2. **[风险] 转新后旧客户仍持有旧虚拟号认知**
- 权衡:`PersonalCustomerDevice` 绑定会清除,旧客户再次登录会被要求重新绑定,避免继续访问新代际资产。
3. **[风险] 设备换设备不迁移 DeviceSimBinding 造成“看起来少迁移”**
- 权衡:这是显式设计决策;新设备按“新硬件+新卡”交付,旧设备卡绑定保留历史关系。
4. **[风险] 迁移期 CMP 与 Gateway 状态观测不一致**
- 权衡:本次迁移仅操作 CMP 数据库,不调用 Gateway运营商侧状态由新资产实际使用逐步收敛。
## 迁移计划
1. 新建 `tb_exchange_order` 表。
2.`tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`
3. 代码层替换:
- Store/Service/Handler 查询改用 ExchangeOrder
- `is_replaced` 等旧逻辑改为新表判定
4. 在 bootstrap、routes、docs 生成器中注册新 Handler`cmd/api/docs.go``cmd/gendocs/main.go`)。

View File

@@ -0,0 +1,162 @@
## Why
现有 `CardReplacementRecord` 模型仅支持简单换卡,无法满足完整换货需求:缺少收货地址、快递信息、设备换货、全量数据迁移等功能。客户端换货场景中,后台发起换货 → 客户端收到通知填写收货信息 → 后台发货+确认完成(含全量迁移)→ 旧资产可"转新"重新销售,是一个跨后台/客户端的完整业务闭环。
**前置依赖**:提案 0`asset_status`/`generation` 字段已就位)、提案 1客户端认证
## What Changes
### 新增模型
- **ExchangeOrder换货单**:完整的换货生命周期模型,包含旧/新资产信息、收货地址、物流信息、迁移状态。状态机:`1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成`1 或 2 时可取消(→5)
### 删除旧模型
- **CardReplacementRecord**:表改名为 `tb_card_replacement_record_legacy`,代码引用替换为 ExchangeOrder
### 后台换货管理(模块 H7 个接口)
- **H1 发起换货** `POST /api/admin/exchanges`:验证资产无进行中换货单,创建 status=1
- **H2 换货列表** `GET /api/admin/exchanges`:支持状态筛选、资产标识符搜索、时间范围
- **H3 换货详情** `GET /api/admin/exchanges/:id`
- **H4 发货** `POST /api/admin/exchanges/:id/ship`:填写物流信息+新资产标识符,验证 status=2、同类型资产、新资产 asset_status=1在库
- **H5 确认完成** `POST /api/admin/exchanges/:id/complete`:验证 status=3如 migrate_data=true 则执行全量迁移11 张表事务内操作),旧资产 asset_status→3
- **H6 取消换货** `POST /api/admin/exchanges/:id/cancel`:验证 status IN (1,2),已发货不可取消
- **H7 旧资产转新** `POST /api/admin/exchanges/:id/renew`:旧资产 asset_status 从 3→1generation+1清除客户绑定和累计状态创建新空钱包
### 客户端换货(模块 G2 个接口)
- **G1 查询换货通知** `GET /api/c/v1/exchange/pending?identifier=xxx`:查是否有进行中的换货单
- **G2 填写收货信息** `POST /api/c/v1/exchange/:id/shipping-info`:验证 status=1更新收货信息status→2
### 换货状态机
```
后台发起换货
┌─────────────────────┐
│ 1-待填写信息 │ ←── ExchangeOrder 创建
│ (等待客户端填写) │
└──────────┬──────────┘
客户端填写收货信息 [G2]
┌─────────────────────┐
│ 2-待发货 │
│ (等待后台填写物流) │
└──────────┬──────────┘
后台发货 [H4] (填物流+新资产)
┌─────────────────────┐
│ 3-已发货待确认 │
│ (等待后台确认完成) │
└──────────┬──────────┘
后台确认完成 [H5]
(可选: 全量迁移)
┌─────────────────────┐
│ 4-已完成 │
└─────────────────────┘
取消: status=1 或 2 时可取消 → 5-已取消
已发货(status=3)后不可取消
```
### 全量迁移流程H5 确认完成时触发)
```
确认完成 (migrate_data=true)
事务开始
├── 1. 钱包余额转移
│ 旧 AssetWallet.Balance → 新 AssetWallet
│ 生成迁移流水 AssetWalletTransaction
├── 2. 生效中套餐关联新资产
│ PackageUsage WHERE iot_card_id/device_id=旧 AND status IN (生效中)
│ → UPDATE iot_card_id/device_id = 新
├── 3. 累计充值/首充状态迁移
│ IotCard/Device 的 AccumulatedRecharge/FirstCommissionPaid
│ 等字段复制到新资产
├── 4. 标签复制
│ ResourceTag WHERE resource_type=? AND resource_id=旧
│ → 为新资产创建相同标签
├── 5. 个人客户绑定更新
│ PersonalCustomerDevice WHERE virtual_no=旧虚拟号
│ → UPDATE virtual_no = 新虚拟号
├── 6. 旧资产标记
│ 旧资产 asset_status → 3已换货
└── 7. 记录迁移信息
ExchangeOrder: migration_completed=true,
migration_balance=转移金额
事务提交
ExchangeOrder status → 4已完成
注意:
- 设备换设备时不迁移 DeviceSimBinding卡绑定关系
- 新设备自带新的 SIM 卡,旧设备的卡绑定保持不变
- 保留不修改的表: tb_order, tb_commission, tb_data_usage_record,
tb_asset_recharge_record历史记录保留通过 generation 隔离)
```
### 转新流程H7
```
旧资产 (asset_status=3 已换货)
POST /api/admin/exchanges/:id/renew
├── 1. asset_status: 3 → 1在库
├── 2. generation: +1进入新世代
├── 3. 清除: 累计充值状态、首充触发状态
├── 4. 清除: PersonalCustomerDevice 绑定
├── 5. 创建新空钱包(新 wallet_id
└── 6. 不删除历史数据(通过 generation 隔离)
旧资产可重新销售给新客户
新客户查询时按当前 generation 过滤
看不到旧周期数据
```
## Capabilities
### New Capabilities
- `exchange-order-model`ExchangeOrder 模型定义、状态机、状态常量、换货单号生成规则
- `exchange-admin-management`:后台换货管理 CRUDH1~H3、发货H4含同类型资产校验+新资产在库校验、确认完成H5含全量迁移事务、取消H6、转新H7含 generation 自增+状态重置)
- `exchange-data-migration`全量迁移逻辑11 张数据表的事务内操作规则,设备不迁移卡绑定的特殊规则
- `exchange-client-notification`客户端换货通知查询G1、收货信息填写G2
### Modified Capabilities
- `iot-card`IotCard 新增换货相关行为——`asset_status=3` 标记、转新时 generation 自增+状态重置
- `device`Device 同上
- `personal-customer`PersonalCustomerDevice 绑定关系在换货迁移时更新虚拟号
- `card-replacement`**REMOVED** — CardReplacementRecord 模型废弃,表改名为 legacy代码引用替换为 ExchangeOrder
## Impact
- **新增文件**`internal/model/exchange_order.go`(模型);`internal/handler/admin/exchange.go`(后台 Handler`internal/handler/app/client_exchange.go`(客户端 Handler`internal/service/exchange/service.go`Service含迁移逻辑`internal/store/postgres/exchange_order_store.go`StoreDTO 文件;迁移文件;常量和错误码
- **修改文件**`internal/model/card_replacement.go`(删除或标记废弃);`internal/store/postgres/iot_card_store.go`(移除 `is_replaced` 过滤改为查新表);`internal/model/system.go`AutoMigrate 移除旧模型+注册新模型);`internal/routes/`(新增后台+客户端路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)
- **新增 API 路由**:后台 `/api/admin/exchanges/` 下 7 个端点 + 客户端 `/api/c/v1/exchange/` 下 2 个端点
- **数据库变更**:新建 `tb_exchange_order` 表;旧表 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`
- **全量迁移涉及 11 张表**`tb_asset_wallet``tb_asset_wallet_transaction``tb_asset_recharge_record``tb_package_usage``tb_package_usage_daily_record``tb_order``tb_commission``tb_data_usage_record``tb_resource_tag``tb_personal_customer_device``tb_iot_card`/`tb_device`

View File

@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: 废弃旧换卡模型能力
系统 MUST 废弃 `CardReplacementRecord` 作为主业务能力,原因是其仅覆盖卡换卡且缺少收货信息、物流信息、设备换货与全量迁移能力,无法满足当前换货闭环需求。
#### Scenario: 新换货流程不再写入旧模型
- **WHEN** 执行任意新换货流程H1~H7、G1~G2
- **THEN** 系统 MUST 仅读写 `ExchangeOrder`,不再创建 `CardReplacementRecord` 新记录
---
### Requirement: 旧表迁移为 legacy 保留查询
系统 SHALL 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`,仅用于历史查询保留。
系统 MUST NOT 将 legacy 数据回灌到 `tb_exchange_order`
#### Scenario: legacy 数据保留但不参与新流程
- **WHEN** 运营查询历史老换卡记录
- **THEN** 系统可从 legacy 表读取历史数据,但新换货流程 SHALL 不依赖该表
---
### Requirement: 旧代码引用替换
系统 MUST 将旧换卡引用替换为 `ExchangeOrder`,包括 `iot_card_store.go``is_replaced` 过滤逻辑。
#### Scenario: is_replaced 基于新换货单判定
- **WHEN** 查询 IoT 卡并使用 `is_replaced=true` 过滤
- **THEN** 系统 MUST 基于 `ExchangeOrder` 状态判定是否已发生换货,而非 legacy 表

View File

@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: 设备换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为“已换货”,用于标记已被换出的旧设备资产。
#### Scenario: 换货完成后旧设备标记
- **WHEN** H5 确认完成且旧资产为设备
- **THEN** 系统 MUST 将旧设备 `asset_status` 更新为 `3`
---
### Requirement: 设备转新重置规则
系统 SHALL 在 H7 转新时对设备执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态
- 清除个人客户绑定关系
- 创建新空钱包并与新代际设备关联
#### Scenario: 转新后设备可重新销售
- **WHEN** 对已换货设备执行转新
- **THEN** 系统 MUST 使该设备进入新代际并恢复在库可售

View File

@@ -0,0 +1,121 @@
## ADDED Requirements
### Requirement: H1 发起换货单
系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。
请求体 MUST 包含:`old_asset_type``old_identifier``exchange_reason`,可选 `remark`
系统 MUST 校验:
- 旧资产存在且当前用户有权限
- 同一资产不存在进行中的换货单(`status IN (1,2,3)`
成功响应 SHALL 返回新建换货单信息(含 `id``exchange_no``status=1`)。
错误响应 MUST 至少包含:参数错误、资产不存在或无权限、存在进行中换货单。
#### Scenario: 资产已有进行中换货单
- **WHEN** 后台为同一资产重复发起换货
- **THEN** 系统 MUST 拒绝创建并返回“存在进行中的换货单”
---
### Requirement: H2 换货单列表
系统 SHALL 提供 `GET /api/admin/exchanges``Auth=true`),支持分页与条件查询。
查询条件 SHOULD 支持:`status``identifier`(资产标识搜索)、`created_at_start``created_at_end`、分页参数。
响应 SHALL 返回列表与分页元数据。
#### Scenario: 按状态查询待发货单
- **WHEN** 运营查询 `status=2`
- **THEN** 系统返回所有待发货换货单并按创建时间倒序
---
### Requirement: H3 换货单详情
系统 SHALL 提供 `GET /api/admin/exchanges/:id``Auth=true`)查询换货单详情。
响应 MUST 返回旧/新资产信息、收货信息、物流信息、迁移状态信息。
错误响应 MUST 至少包含:换货单不存在或无权限。
#### Scenario: 查询不存在换货单
- **WHEN** 查询不存在的换货单 ID
- **THEN** 系统 MUST 返回“资源不存在或无权限”
---
### Requirement: H4 发货
系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship``Auth=true`)。
请求体 MUST 包含:`express_company``express_no``new_identifier``migrate_data`
系统 MUST 校验:
- 当前状态必须为 `2`
- 新旧资产类型必须一致(卡换卡/设备换设备)
- 新资产必须 `asset_status=1`(在库)
成功后 SHALL 更新新资产信息、物流信息并将状态改为 `3`
错误响应 MUST 至少包含:非法状态、资产类型不匹配、新资产非在库、资产不存在或无权限。
#### Scenario: 新资产类型不一致
- **WHEN** 旧资产为 iot_card 且新资产为 device
- **THEN** 系统 MUST 拒绝发货并返回“换货资产类型必须一致”
---
### Requirement: H5 确认完成
系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete``Auth=true`)。
系统 MUST 校验当前状态为 `3`。当 `migrate_data=true` 时,系统 MUST 执行全量迁移事务(见 `exchange-data-migration` 能力)。
成功后 SHALL
- `migration_completed=true`(若执行迁移)
- 换货单状态更新为 `4`
错误响应 MUST 至少包含:非法状态、迁移失败、换货单不存在或无权限。
#### Scenario: 需要迁移并完成
- **WHEN** 状态为 `3``migrate_data=true`
- **THEN** 系统 MUST 在事务成功后将状态变为 `4` 并记录迁移结果
---
### Requirement: H6 取消换货
系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel``Auth=true`)。
系统 MUST 仅允许在 `status IN (1,2)` 时取消,成功后状态更新为 `5`
系统 MUST 禁止已发货单取消(`status=3`)。
#### Scenario: 已发货单取消失败
- **WHEN** 换货单状态为 `3` 发起取消
- **THEN** 系统 MUST 返回状态非法错误
---
### Requirement: H7 旧资产转新
系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew``Auth=true`)。
系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行:
- `generation + 1`
- `asset_status -> 1`
- 清除累计充值/首充相关状态
- 清除个人客户绑定
- 创建新空钱包
系统 MUST 保留历史数据,不执行历史删除。
错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。
#### Scenario: 旧资产未处于已换货状态
- **WHEN** 旧资产 `asset_status != 3` 发起转新
- **THEN** 系统 MUST 拒绝并返回“资产当前状态不允许转新”

View File

@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: G1 查询进行中换货通知
系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。
系统 MUST 根据资产标识查询当前客户可见的进行中换货单,仅返回 `status IN (1,2,3)` 的记录。
响应 SHALL 至少包含:换货单 ID、单号、状态、换货原因、创建时间。
错误响应 MUST 至少包含:参数错误、资产不存在或无权限。
#### Scenario: 命中进行中换货单
- **WHEN** 客户按资产标识查询且存在状态为 2 的换货单
- **THEN** 系统返回该换货单并标识当前状态为待发货
---
### Requirement: G2 填写收货信息
系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。
请求体 MUST 包含:`recipient_name``recipient_phone``recipient_address`
系统 MUST 校验:
- 换货单存在且当前客户有权限
- 当前状态必须为 `1`
成功后 SHALL 写入收货信息并将状态更新为 `2`
错误响应 MUST 至少包含:参数错误、状态非法、换货单不存在或无权限。
#### Scenario: 非待填写状态禁止更新收货信息
- **WHEN** 换货单当前状态为 `2``3`
- **THEN** 系统 MUST 拒绝填写并返回状态非法错误

View File

@@ -0,0 +1,60 @@
## ADDED Requirements
### Requirement: 全量迁移事务边界
系统 MUST 在 H5 确认完成且 `migrate_data=true` 时,使用**单一数据库事务**执行全量迁移。
该事务 SHALL 覆盖资产钱包、套餐、标签、客户绑定及资产状态更新等所有步骤;任一步骤失败 MUST 回滚。
#### Scenario: 迁移中途失败回滚
- **WHEN** 迁移第 N 步发生数据库错误
- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成
---
### Requirement: 11 张表迁移规则
系统 SHALL 按以下规则处理 11 张表:
1. `tb_asset_wallet`:将旧资产钱包余额转移到新资产钱包。
2. `tb_asset_wallet_transaction`:生成一条迁移流水记录(明确来源钱包、目标钱包、金额、业务类型)。
3. `tb_asset_recharge_record`:历史充值记录保留,不做更新。
4. `tb_package_usage`:将生效套餐关联到新资产(更新 `iot_card_id``device_id`)。
5. `tb_package_usage_daily_record`:随 `tb_package_usage` 关系迁移(保持套餐日明细连续性)。
6. `tb_order`:历史订单保留,不做更新。
7. `tb_commission`:历史分佣记录保留,不做更新。
8. `tb_data_usage_record`:历史流量记录保留,不做更新。
9. `tb_resource_tag`:复制旧资产标签到新资产。
10. `tb_personal_customer_device`:将绑定记录中的 `virtual_no` 更新为新资产虚拟号。
11. `tb_iot_card`/`tb_device`:迁移累计充值与首充状态到新资产,并将旧资产 `asset_status -> 3`
#### Scenario: 钱包余额转移并记录流水
- **WHEN** 旧资产钱包余额为 5000 分
- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水
---
### Requirement: 设备换设备特殊规则
设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`
系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。
#### Scenario: 设备换设备不复制绑定卡
- **WHEN** 执行设备换设备全量迁移
- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备
---
### Requirement: 转新规则
系统 SHALL 在 H7 转新时执行代际隔离策略:
- 资产 `generation + 1`
- 创建新空钱包(新 `wallet_id`
- 清除累计充值状态与首充触发状态
- 清除 `PersonalCustomerDevice` 绑定
- 不删除历史业务数据
#### Scenario: 转新后历史数据保留
- **WHEN** 资产转新完成
- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯

View File

@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: ExchangeOrder 换货单模型定义
系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。
模型字段 MUST 至少包含:
- 基础:`id``created_at``updated_at``deleted_at``creator``updater`
- 单号:`exchange_no`
- 旧资产:`old_asset_type``old_asset_id``old_asset_identifier`
- 新资产:`new_asset_type``new_asset_id``new_asset_identifier`
- 收货:`recipient_name``recipient_phone``recipient_address`
- 物流:`express_company``express_no`
- 迁移:`migrate_data``migration_completed``migration_balance`
- 业务:`exchange_reason``remark``status`
- 多租户:`shop_id`
`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`
#### Scenario: 创建换货单模型实例
- **WHEN** 系统创建新的换货单记录
- **THEN** 记录 MUST 同时包含旧资产快照、收货信息占位、迁移状态字段和多租户字段
---
### Requirement: 换货状态常量定义
系统 MUST 使用 int 常量定义换货状态:
- `1` 待填写信息
- `2` 待发货
- `3` 已发货待确认
- `4` 已完成
- `5` 已取消
#### Scenario: 状态常量一致性
- **WHEN** Service、Store、Handler 读取或更新换货状态
- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字
---
### Requirement: 换货状态机流转规则
系统 SHALL 执行以下状态机:
- 创建换货单后:`1`
- 客户填写收货信息后:`1 -> 2`
- 后台发货后:`2 -> 3`
- 后台确认完成后:`3 -> 4`
- 取消:仅允许 `1/2 -> 5`
系统 MUST 禁止非法流转(如 `3 -> 5``4 -> 2`)。
#### Scenario: 已发货不可取消
- **WHEN** 换货单状态为 `3` 且请求取消
- **THEN** 系统 MUST 拒绝并返回状态流转非法错误
---
### Requirement: 换货单号生成规则
系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`
生成规则 SHALL 满足:
- 前缀固定为 `EXC`
- 包含日期/时间信息用于人工排查
- 包含随机片段降低并发冲突概率
#### Scenario: 生成换货单号
- **WHEN** 后台发起换货并创建新单
- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no`

View File

@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: IoT 卡换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为“已换货”,用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。
#### Scenario: 换货完成后旧卡标记为已换货
- **WHEN** H5 确认完成且旧资产为 IoT 卡
- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3`
---
### Requirement: IoT 卡转新重置规则
系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge``FirstCommissionPaid`、系列首充/累计字段)
- 清除个人客户绑定关系
#### Scenario: 转新后进入新代际
- **WHEN** 对旧卡执行转新
- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售

View File

@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: 换货迁移时更新个人客户资产绑定
系统 SHALL 在 H5 全量迁移成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系:
- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产 `virtual_no`
- 更新后客户对资产访问连续,不需重新登录即可看到新资产
#### Scenario: 迁移后客户绑定跟随新资产
- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true`
- **THEN** 系统 MUST 将绑定记录的 `virtual_no` 更新为新资产虚拟号
---
### Requirement: 转新时清除个人客户绑定
系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。
#### Scenario: 转新后旧客户需重新绑定
- **WHEN** 资产转新完成
- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程

View File

@@ -0,0 +1,37 @@
- [x] 1.1 定义 ExchangeOrder 模型(含 BaseModel、旧/新资产字段、收货字段、物流字段、迁移字段、shop_id
- [x] 1.2 新增换货状态常量1待填写、2待发货、3已发货待确认、4已完成、5已取消
- [x] 1.3 实现换货单号生成函数EXC + 日期时间 + 随机数)
- [x] 1.4 新增后台/客户端换货相关 DTO请求参数、响应结构、错误字段
- [x] 2.1 创建数据库迁移:新增 tb_exchange_order 表
- [x] 2.2 创建数据库迁移:将 tb_card_replacement_record 改名为 tb_card_replacement_record_legacy
- [x] 3.1 实现 ExchangeOrderStore创建换货单、按ID查询、按条件分页查询
- [x] 3.2 实现 ExchangeOrderStore状态更新含前置状态校验
- [x] 3.3 实现 ExchangeOrderStore按旧资产查询进行中换货单
- [x] 4.1 实现换货 ServiceH1 创建换货单与重复进行中校验
- [x] 4.2 实现换货 Service状态流转校验1->2->3->4、1/2->5
- [x] 4.3 实现换货 ServiceH4 发货同类型资产校验与新资产在库校验
- [x] 4.4 实现换货 ServiceH5 确认完成与可选全量迁移入口
- [x] 4.5 实现换货 Service全量迁移事务11张表规则
- [x] 4.6 实现换货 ServiceH7 转新逻辑generation+1、状态重置、清除绑定、新钱包
- [x] 5.1 新增后台 Exchange HandlerH1~H7
- [x] 5.2 在 admin 路由注册 H1~H7使用 Register() + RouteSpec 完整元数据)
- [x] 6.1 新增客户端 Exchange HandlerG1~G2
- [x] 6.2 在客户端路由注册 G1~G2使用 Register() + RouteSpec 完整元数据)
- [x] 7.1 清理旧模型引用:移除/停用 card_replacement.go 在业务流程中的使用
- [x] 7.2 修改 iot_card_store.go 的 is_replaced 过滤逻辑,改为查询 ExchangeOrder
- [x] 8.1 更新 bootstrap/types.go新增后台与客户端换货 Handler 字段
- [x] 8.2 更新 bootstrap/handlers.go实例化换货相关 Handler
- [x] 8.3 更新 cmd/api/docs.go注册换货 Handler 到文档生成器
- [x] 8.4 更新 cmd/gendocs/main.go注册换货 Handler 到文档生成器
- [x] 9.1 执行 go build 验证编译通过
- [x] 9.2 执行 lsp_diagnostics 检查改动文件诊断信息
- [x] 9.3 使用数据库验证流程核对 tb_exchange_order 与 legacy 表结构
- [x] 9.4 在 docs/client-exchange-system/ 补充功能总结文档