- 新增资产状态、订单来源、操作人类型、实名链接类型常量 - 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旧接口和个人客户旧登录方法
165 lines
10 KiB
Markdown
165 lines
10 KiB
Markdown
## 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
|
||
|
||
### 决策 1:retail_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` 字段。但每个代理需要不同的零售价,单字段不够,放分配表更合理。
|
||
|
||
### 决策 2:Order.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` 中的事务用法)。
|
||
|
||
### 决策 4:generation 字段设计
|
||
|
||
**选择**:`generation int NOT NULL DEFAULT 1`,在 IotCard、Device、Order、PackageUsage、AssetRechargeRecord 五个表新增。创建关联记录时从资产当前 generation 复制(写时快照)。
|
||
|
||
**理由**:写时快照方案简单可靠,无需 JOIN 查询。客户端按 generation 过滤只需加一个 WHERE 条件。后台不受影响(不加 generation 过滤)。钱包流水通过 wallet_id 天然隔离,无需 generation 字段。
|
||
|
||
**本次范围**:仅新增字段和 DEFAULT 值。快照逻辑和查询过滤在后续提案中实现。
|
||
|
||
### 决策 5:asset_status 字段设计
|
||
|
||
**选择**:`asset_status int NOT NULL DEFAULT 1`,在 IotCard 和 Device 新增。值域:1-在库 2-已销售 3-已换货 4-已停用。与 `network_status` 完全独立。
|
||
|
||
**理由**:`network_status` 反映运营商侧网络状态(Gateway 同步),`asset_status` 反映 CMP 内部业务生命周期。两者关注点不同,互不干扰。默认值 1(在库)符合导入后的初始状态。
|
||
|
||
**本次范围**:仅新增字段和常量定义。状态流转逻辑在后续提案中实现。
|
||
|
||
### 决策 6:AssetRechargeRecord 扩展字段
|
||
|
||
**选择**:新增以下字段:
|
||
- `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 体系歧义。强充关联字段支持两阶段处理(充值回调后异步创建套餐订单)。
|
||
|
||
### 决策 7:Carrier 实名链接配置
|
||
|
||
**选择**:`tb_carrier` 新增 `realname_link_type varchar(20) NOT NULL DEFAULT 'none'` 和 `realname_link_template varchar(500) DEFAULT ''`。
|
||
|
||
**理由**:实名链接有三种模式:不支持(none)、模板 URL(template,支持 `{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` 中移除指向已删除方法的路由注册
|
||
|
||
**理由**:一次性清理比分批清理更安全,避免残留引用导致编译错误或运行时异常。
|
||
|
||
### 决策 9:PersonalCustomer.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 端认证(AdminAuth),C 端用户无法调用,实际上已不可用。
|
||
|
||
**[风险] 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
|
||
|
||
无。所有设计决策基于需求说明文档中的明确定义。
|