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 的错误描述
11 KiB
Context
当前状态
系统即将启动客户端(C 端)接口体系开发(/api/c/v1),但存在以下阻塞项:
- 价格计算错误:
ShopPackageAllocation缺少retail_price字段,GetPurchasePrice()和validatePackages()始终使用Package.SuggestedRetailPrice,代理无法设定自己的零售价 - 佣金误触发:后台所有订单(包括代理自购)都可能触发一次性佣金,缺少订单来源区分
- 充值事务不一致:
HandlePaymentCallback中状态更新和支付信息更新使用s.db而非事务内tx,存在半提交风险 - 基础字段缺失:客户端接口和换货系统依赖
asset_status、generation、operator_type等字段,目前模型中均不存在 - 旧接口残留:
/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.SuggestedRetailPricevalidatePackages()第 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
- 生成迁移文件:单个迁移文件包含所有 ALTER TABLE 语句(7 张表 15+ 字段)
- 存量数据修复:迁移中包含 UPDATE 语句,将
ShopPackageAllocation.retail_price批量设为对应套餐的SuggestedRetailPrice - 部署顺序:先执行迁移 → 再部署新代码(新增字段有默认值,旧代码不受影响)
- 回滚策略:可安全回滚代码(字段有默认值),如需回滚迁移则 DROP COLUMN(不可逆,需确认)
- 旧接口清理:代码部署即生效,无需额外操作
Open Questions
无。所有设计决策基于需求说明文档中的明确定义。