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

10 KiB
Raw Blame History

Context

当前状态

系统即将启动客户端C 端)接口体系开发(/api/c/v1),但存在以下阻塞项:

  1. 价格计算错误ShopPackageAllocation 缺少 retail_price 字段,GetPurchasePrice()validatePackages() 始终使用 Package.SuggestedRetailPrice,代理无法设定自己的零售价
  2. 佣金误触发:后台所有订单(包括代理自购)都可能触发一次性佣金,缺少订单来源区分
  3. 充值事务不一致HandlePaymentCallback 中状态更新和支付信息更新使用 s.db 而非事务内 tx,存在半提交风险
  4. 基础字段缺失:客户端接口和换货系统依赖 asset_statusgenerationoperator_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_statusgeneration 字段
  • 为 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.gotriggerOneTimeCommissionForCardInTxtriggerOneTimeCommissionForDeviceInTx 中的 IsPurchaseOnBehalf 判断,增加 order.Source == "client" 条件
  • order/service.goCreateAdminOrder 设置 Source: "admin"(默认值已满足,可不显式设置)

决策 3充值回调事务修复

选择AssetRechargeStoreUpdateStatusWithOptimisticLockUpdatePaymentInfo 方法新增 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.goh5_enterprise_device.goh5_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

无。所有设计决策基于需求说明文档中的明确定义。