## 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 ### 决策 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 无。所有设计决策基于需求说明文档中的明确定义。