Files
junhong_cmp_fiber/openspec/changes/client-api-data-model-fixes/design.md
huang ec86dbf463 feat: 客户端接口数据模型基础准备
- 新增资产状态、订单来源、操作人类型、实名链接类型常量
- 8个模型新增字段(asset_status/generation/source/retail_price等)
- 数据库迁移000082:7张表15+字段,含存量retail_price回填
- BUG-1修复:代理零售价渠道隔离,cost_price分配锁定
- BUG-2修复:一次性佣金仅客户端订单触发
- BUG-4修复:充值回调Store操作纳入事务
- 新增资产手动停用接口(PATCH /iot-cards/:id/deactivate、/devices/:id/deactivate)
- Carrier管理新增实名链接配置
- 后台订单generation写时快照
- BatchUpdatePricing支持retail_price调价目标
- 清理全部H5旧接口和个人客户旧登录方法
2026-03-19 10:56:50 +08:00

165 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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、路由注册、文档生成器必须同步清理否则编译失败
- 本提案不涉及任何新 API 接口,纯粹是模型/字段/BUG 修复
## 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
- 不修改后台管理界面或 Admin API
- 不新增 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
无。所有设计决策基于需求说明文档中的明确定义。