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旧接口和个人客户旧登录方法
This commit is contained in:
2026-03-19 10:56:50 +08:00
parent 817d0d6e04
commit ec86dbf463
70 changed files with 1438 additions and 1188 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

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

View File

@@ -0,0 +1,67 @@
## Why
系统存在 4 个影响资金安全和业务正确性的 BUG且即将启动客户端C 端)接口体系开发。本提案作为客户端接口系列提案的**前置基础**,解决三类问题:
1. **资金/业务 BUG 修复**代理零售价缺失导致价格计算错误BUG-1、后台订单误触发一次性佣金BUG-2、充值回调事务半提交风险BUG-4
2. **基础字段准备**:为客户端接口和换货系统新增必要的模型字段(`asset_status``generation``source``operator_type``realname_link_type`
3. **旧接口清理**:删除基于 B 端认证体系的旧 H5 接口和旧个人客户登录接口,为新的 `/api/c/v1` 体系腾出空间
## What Changes
### BUG 修复
- **BUG-1 代理零售价修复**`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice``validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price**扩展现有 `BatchUpdatePricing` 接口支持 `retail_price` 调整**(新增 `pricing_target` 字段区分调 cost_price 还是 retail_price默认 `cost_price` 保持向后兼容);**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价)
- **BUG-2 一次性佣金触发修复**`Order` 新增 `source` 字段(`admin`/`client`);佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,确保只有客户端个人客户购买才触发
- **BUG-4 充值回调事务修复**`HandlePaymentCallback``UpdateStatusWithOptimisticLock``UpdatePaymentInfo``s.db.WithContext(ctx)` 改为事务内 `tx`,确保充值单状态变更和钱包入账原子完成
### 新增模型字段
- **`IotCard`/`Device` 新增 `asset_status`**业务生命周期状态1-在库 2-已销售 3-已换货 4-已停用),与运营商 `network_status` 独立
- **`IotCard`/`Device` 新增 `generation`**:资产世代编号,换货转新时 +1客户端按当前 generation 过滤历史数据
- **`Order`/`PackageUsage`/`AssetRechargeRecord` 新增 `generation`**:创建时快照资产当前 generation客户端查询按此字段过滤
- **`AssetRechargeRecord` 新增 `operator_type`**:区分操作人类型(`admin_user`/`personal_customer`),配合 `user_id` 区分不同 ID 体系
- **`AssetRechargeRecord` 新增强充关联字段**`linked_package_ids``linked_order_type``linked_carrier_type``linked_carrier_id`,支持强充两阶段处理
- **`Carrier` 新增实名链接配置**`realname_link_type`none/template/gateway`realname_link_template`(支持 `{iccid}`/`{msisdn}`/`{virtual_no}` 占位符)。**同步更新 Carrier admin DTO**`CarrierCreateRequest`/`CarrierUpdateRequest`)包含这两个字段,使后台管理员可通过 API 配置运营商实名链接方式
- **`PersonalCustomer` 索引变更**`wx_open_id` 从唯一索引改为普通索引(支持后续多 OpenID 方案)
### 旧接口删除
- **删除全部旧 H5 接口**`internal/handler/h5/` 下所有文件auth、order、recharge、package_usage、enterprise_device`internal/routes/h5*.go` 路由注册
- **删除旧个人客户登录接口**`internal/handler/app/personal_customer.go` 中的 Login、SendCode、WechatOAuthLogin、BindWechat、Profile 方法
- **同步清理**bootstrap 中 H5 Handler 注册、docs.go/gendocs 中引用
## Capabilities
### New Capabilities
- `asset-lifecycle-status`资产业务生命周期状态管理。IotCard/Device 新增 `asset_status` 字段(在库→已销售→已换货→已停用),定义状态流转规则,与运营商 `network_status` 完全独立
- `asset-generation`资产世代机制。IotCard/Device 的 `generation` 字段关联表Order/PackageUsage/AssetRechargeRecord的 generation 写时快照规则,客户端按世代过滤、后台不过滤的查询规则
- `carrier-realname-config`运营商实名链接配置。Carrier 模型新增 `realname_link_type`/`realname_link_template` 字段,支持 none/template/gateway 三种模式URL 模板占位符替换。**Carrier admin DTO 同步更新**,后台可通过现有运营商管理接口配置实名链接
- `agent-retail-price`代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`cost_price 分配锁定规则。**扩展 `BatchUpdatePricing` 接口**支持 `pricing_target=retail_price` 批量调整零售价;**代理套餐列表展示 retail_price****利润计算修正**为 `RetailPrice - CostPrice`
- `asset-manual-deactivation`:资产手动停用。新增后台接口 `PATCH /api/admin/iot-cards/:id/deactivate``PATCH /api/admin/devices/:id/deactivate`,将 `asset_status` 设为 4已停用`asset_status=1`(在库)或 `asset_status=2`(已销售)时可操作
- `h5-legacy-cleanup`:旧 H5 接口和旧登录接口的完整删除,包括 handler、route、bootstrap 注册、文档生成器引用的清理
### Modified Capabilities
- `package-purchase-validation``GetPurchasePrice()` 价格来源改为按渠道区分代理→retail_price平台→SuggestedRetailPrice`validatePackages()` 价格累加逻辑同步修正
- `package-list`:代理查询套餐列表时,`PackageResponse` 新增 `retail_price` 字段;`ProfitMargin` 计算从 `SuggestedRetailPrice - CostPrice` 改为 `RetailPrice - CostPrice`
- `batch-pricing``BatchUpdatePricing` 接口扩展支持 `pricing_target` 参数(`cost_price`/`retail_price`),默认 `cost_price` 保持向后兼容retail_price 调整时校验 `>= cost_price`
- `one-time-commission-trigger`:触发条件增加 `order.Source == "client"` 判断,确保仅客户端个人客户购买才触发
- `wallet-recharge``HandlePaymentCallback` 事务一致性修复Store 方法支持传入事务 `tx`
- `iot-order`Order 模型新增 `source`(订单来源)和 `generation`(世代)字段;`CreateAdminOrder()` 创建订单时从资产快照当前 `generation` 写入订单(而非依赖默认值 1
- `iot-card`IotCard 模型新增 `asset_status``generation` 字段
- `device`Device 模型新增 `asset_status``generation` 字段
- `personal-customer``wx_open_id` 索引从唯一改为普通索引
- `asset-recharge-adaptation`AssetRechargeRecord 新增 `operator_type``generation`、强充关联字段
## Impact
- **模型文件**`shop_package_allocation.go``carrier.go``order.go``iot_card.go``device.go``package_usage.go``asset_recharge_record.go``personal_customer.go`
- **Service 文件**`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`(扩展支持 retail_price + cost_price 锁定)、`shop_series_grant/service.go`cost_price 锁定)、`order/service.go`source 设置 + generation 快照)、`package/service.go`(利润计算修正 + PackageResponse 新增 retail_price
- **Handler/DTO 文件**`shop_package_batch_pricing.go` Handler扩展`shop_package_batch_pricing_dto.go`(新增 `pricing_target` 字段)、`package_dto.go``PackageResponse` 新增 `retail_price`)、`carrier_dto.go`(新增实名链接字段)
- **Store 文件**`asset_recharge_store.go`(支持事务传入)
- **删除文件**`internal/handler/h5/` 全部5 个文件)、`internal/routes/h5*.go`3 个文件)、`internal/handler/app/personal_customer.go` 中旧方法
- **数据库迁移**7 张表共 15+ 个字段变更1 个索引变更
- **文档生成器**`cmd/api/docs.go``cmd/gendocs/main.go` 移除 H5 Handler 引用
- **Bootstrap**:移除 H5 Handler 注册
- **性能**:所有变更为字段新增/修复,无查询性能影响;新增字段均带 DEFAULT 值,迁移可在线执行

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: 分配零售价字段定义
系统 MUST 在 `ShopPackageAllocation` 新增 `retail_price bigint NOT NULL DEFAULT 0` 字段。
#### Scenario: 新字段存在且非空
- **WHEN** 执行分配记录建表或迁移
- **THEN** `retail_price` MUST 为非空整型字段,默认值为 `0`
---
### Requirement: 分配创建默认零售价规则
系统 MUST 在创建分配记录时将 `retail_price` 自动设置为对应 `Package.SuggestedRetailPrice`
#### Scenario: 创建分配自动带出建议零售价
- **WHEN** 平台给代理创建套餐分配记录
- **THEN** 新记录的 `retail_price` MUST 等于该套餐的 `suggested_retail_price`
---
### Requirement: 零售价约束规则
系统 MUST 强制校验:`retail_price >= cost_price``retail_price <= suggested_retail_price * 2`
#### Scenario: 零售价低于成本价
- **WHEN** 代理设置 `retail_price < cost_price`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
#### Scenario: 零售价超过建议价两倍
- **WHEN** 代理设置 `retail_price > suggested_retail_price * 2`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
---
### Requirement: 成本价分配锁定规则
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`
#### Scenario: 存在下级分配时修改成本价
- **WHEN** 上级分配记录已被继续分配到下级店铺
- **THEN** 系统 MUST 拒绝对该记录的 `cost_price` 修改
---
### Requirement: 代理零售价可调与存量迁移
系统 MUST 允许代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
#### Scenario: 代理调整自己的零售价
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束
- **THEN** 系统 MUST 允许更新
#### Scenario: 存量数据回填零售价
- **WHEN** 执行本次数据迁移
- **THEN** 系统 MUST 将历史 `ShopPackageAllocation.retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`

View File

@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: 资产表新增代际字段
系统 MUST 在资产主表新增 `generation int NOT NULL DEFAULT 1` 字段,覆盖 `IotCard``Device`
#### Scenario: 新资产默认代际为 1
- **WHEN** 创建新的 IoT 卡或设备
- **THEN** 系统 MUST 将 `generation` 初始化为 `1`
---
### Requirement: 关联业务表新增代际字段
系统 MUST 在以下关联业务表新增 `generation int NOT NULL DEFAULT 1` 字段:`Order``PackageUsage``AssetRechargeRecord`
#### Scenario: 新关联记录默认代际为 1
- **WHEN** 创建订单、套餐使用记录或资产充值记录
- **THEN** 系统 MUST 将记录的 `generation` 默认为 `1`
---
### Requirement: 写时快照代际规则
系统 MUST 在创建关联记录时执行代际写时快照从当前资产IoT 卡/设备)的 `generation` 复制到新建的 `Order``PackageUsage``AssetRechargeRecord` 记录。
#### Scenario: 创建订单时复制资产代际
- **WHEN** 某资产当前 `generation=3`,并基于该资产创建订单
- **THEN** 该订单记录的 `generation` MUST 写入为 `3`
---
### Requirement: 查询过滤规则
系统 MUST 支持客户端按 `generation` 过滤历史数据;后台管理侧 MUST 不默认按 `generation` 过滤。
本提案阶段 MUST 仅新增字段定义,具体过滤逻辑在后续提案实现。
#### Scenario: 客户端按代际查看历史
- **WHEN** 客户端请求携带指定 `generation`
- **THEN** 系统 MUST 仅返回该代际的数据(在后续提案中实现)
#### Scenario: 后台查询不按代际裁剪
- **WHEN** 管理端查询订单或充值记录且未显式指定 `generation`
- **THEN** 系统 MUST 返回全部代际数据
---
### Requirement: 钱包流水不引入代际字段
系统 MUST NOT 在钱包流水相关表新增 `generation` 字段,因为钱包流水已通过 `wallet_id` 天然隔离。
#### Scenario: 钱包流水按钱包隔离
- **WHEN** 查询某资产钱包流水
- **THEN** 系统 MUST 仅依赖 `wallet_id` 完成数据隔离,不新增 `generation` 参与过滤

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: 资产生命周期状态字段定义
系统 MUST 在 `IotCard``Device` 数据模型中新增 `asset_status int NOT NULL DEFAULT 1` 字段,用于表达资产生命周期状态。
状态值域 MUST 固定为:`1-在库``2-已销售``3-已换货``4-已停用`
#### Scenario: 新建资产默认在库
- **WHEN** 系统创建新的 IoT 卡或设备记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 非法状态值被拒绝
- **WHEN** 写入 `asset_status``0``5` 或其他非约定值
- **THEN** 系统 MUST 拒绝该写入并提示状态值不合法
---
### Requirement: 资产生命周期状态常量定义
系统 MUST 在 `pkg/constants/` 中定义资产生命周期状态常量,并统一由业务层引用,禁止在业务代码中硬编码状态值。
#### Scenario: 业务代码引用常量
- **WHEN** Service 层执行资产状态判断或赋值
- **THEN** 代码 MUST 使用 `pkg/constants/` 中定义的资产状态常量而不是硬编码数字
---
### Requirement: 资产状态与网络状态独立
系统 MUST 保证 `asset_status` 与运营商侧 `network_status` 完全独立,二者不互相推导、不互相覆盖。
本提案阶段 MUST 仅新增字段与常量定义,状态流转逻辑(导入→在库、首次绑定/分配→已销售、换货完成→已换货、转新→在库且代际+1、手动停用→已停用在后续提案实现。
#### Scenario: 网络状态变化不影响资产状态
- **WHEN** Gateway 同步将 `network_status` 从开机改为停机
- **THEN** 系统 MUST 保持 `asset_status` 不变
#### Scenario: 资产状态变化不强制修改网络状态
- **WHEN** 管理端将资产手动停用(`asset_status=4`
- **THEN** 系统 MUST 不自动改写 `network_status`

View File

@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: 资产充值表结构变更
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `operator_type` | varchar(20) | ✅ | 操作人类型,枚举 `admin_user` / `personal_customer`,默认 `admin_user` |
| `generation` | int | ✅ | 资产代际,默认 `1` |
| `linked_package_ids` | jsonb | ❌ | 关联套餐 ID 列表,默认 `'[]'` |
| `linked_order_type` | varchar(20) | ❌ | 关联订单类型 |
| `linked_carrier_type` | varchar(20) | ❌ | 关联载体类型(如 iot_card/device |
| `linked_carrier_id` | bigint | ❌ | 关联载体 ID |
#### Scenario: 新建充值记录默认字段值
- **WHEN** 系统创建新的资产充值记录且未显式传入新增字段
- **THEN** `operator_type` MUST 默认为 `admin_user`
- **THEN** `generation` MUST 默认为 `1`
- **THEN** `linked_package_ids` MUST 默认为空数组 `[]`
#### Scenario: 写入关联上下文信息
- **WHEN** 充值记录由订单或套餐联动产生
- **THEN** 系统 MUST 可写入 `linked_order_type``linked_carrier_type``linked_carrier_id` 作为关联上下文

View File

@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: 运营商实名链接配置字段定义
系统 MUST 在 Carrier 模型新增以下字段:
- `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`
- `realname_link_template varchar(500) DEFAULT ''`
#### Scenario: 默认配置为不支持在线实名
- **WHEN** 创建新的运营商记录且未显式设置实名链接配置
- **THEN** 系统 MUST 将 `realname_link_type` 设为 `none``realname_link_template` 设为空字符串
---
### Requirement: 实名链接三种模式
系统 MUST 支持并仅支持以下实名链接模式:
- `none`:不支持在线实名
- `template`:使用模板 URL 生成实名链接
- `gateway`:通过 Gateway 接口动态获取实名链接
#### Scenario: none 模式
- **WHEN** `realname_link_type=none`
- **THEN** 系统 MUST 视为不支持在线实名跳转
#### Scenario: template 模式
- **WHEN** `realname_link_type=template`
- **THEN** 系统 MUST 使用 `realname_link_template` 作为实名链接模板
#### Scenario: gateway 模式
- **WHEN** `realname_link_type=gateway`
- **THEN** 系统 MUST 通过 Gateway 能力获取实名链接
---
### Requirement: 模板占位符规则
`realname_link_type=template` 时,系统 MUST 支持模板中的占位符 `{iccid}``{msisdn}``{virtual_no}`
本提案阶段 MUST 仅新增字段,不实现实名跳转接口逻辑。
#### Scenario: 模板占位符可被解析
- **WHEN** 模板 URL 包含 `{iccid}``{msisdn}``{virtual_no}`
- **THEN** 系统 MUST 在后续实名跳转实现中按占位符语义进行参数替换

View File

@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: 设备实体定义
系统 SHALL 在 `Device` 模型新增以下字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建设备默认资产状态
- **WHEN** 创建新的设备记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建设备默认代际
- **WHEN** 创建新的设备记录
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: 旧 H5 接口文件删除清单
系统 MUST 完整删除以下旧 H5 文件:
- `internal/handler/h5/auth.go`
- `internal/handler/h5/order.go`
- `internal/handler/h5/recharge.go`
- `internal/handler/h5/package_usage.go`
- `internal/handler/h5/enterprise_device.go`
- `internal/routes/h5.go`
- `internal/routes/h5_enterprise_device.go`
- `internal/routes/h5_package_usage.go`
#### Scenario: 旧 H5 文件不存在
- **WHEN** 执行本提案改造完成后检查仓库
- **THEN** 上述文件 MUST 全部不存在
---
### Requirement: 旧 H5 与旧登录引用清理清单
系统 MUST 清理以下代码引用:
- bootstrap`handlers.go``H5Auth``EnterpriseDeviceH5``H5PackageUsage``H5Order``H5Recharge`
- bootstrap`types.go` 对应字段
- bootstrap`middlewares.go``createH5AuthMiddleware`
- 路由:`routes.go``/api/h5` 挂载
- 路由:`order.go``registerH5OrderRoutes`
- 路由:`recharge.go``registerH5RechargeRoutes`
- 文档:`pkg/openapi/handlers.go` 中 H5 Handler 构造
- 限流:`cmd/api/main.go``/api/h5` 限流配置
- 旧登录方法:`internal/handler/app/personal_customer.go``Login``SendCode``WechatOAuthLogin``BindWechat`
- 旧登录路由:`internal/routes/personal.go` 中指向已删除方法的路由
#### Scenario: 编译期无已删除符号引用
- **WHEN** 清理完成后执行编译
- **THEN** 系统 MUST 不再出现对上述已删除 Handler、路由或方法的引用
---
### Requirement: 清理后编译通过
系统 MUST 在完成文件删除与引用清理后保持工程可编译。
#### Scenario: 全量编译验证通过
- **WHEN** 执行构建命令
- **THEN** 工程 MUST 编译通过且无 H5 旧接口残留导致的编译错误

View File

@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: IoT 卡实体定义
系统 SHALL 在 `IotCard` 模型新增以下字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建 IoT 卡默认资产状态
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建 IoT 卡默认代际
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: 订单实体定义
系统 SHALL 定义订单Order实体并新增来源与代际字段
- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建订单默认后台来源
- **WHEN** 系统创建订单且未显式指定来源
- **THEN** `source` MUST 默认为 `admin`
#### Scenario: 客户端下单写入客户端来源
- **WHEN** 客户端入口创建订单
- **THEN** `source` MUST 写入为 `client`
#### Scenario: 新建订单默认代际为 1
- **WHEN** 系统创建订单且未显式指定代际
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: 一次性佣金触发条件
系统 SHALL 在满足一次性佣金阈值规则的前提下,仅对客户端订单触发一次性佣金。
完整触发判断 MUST 为:`!order.IsPurchaseOnBehalf && order.Source == "client"`
#### Scenario: 客户端自购订单触发
- **WHEN** 订单满足阈值条件,且 `order.IsPurchaseOnBehalf=false``order.Source="client"`
- **THEN** 系统 SHALL 触发一次性佣金计算
#### Scenario: 代购订单不触发
- **WHEN** 订单满足阈值条件,但 `order.IsPurchaseOnBehalf=true`
- **THEN** 系统 SHALL 不触发一次性佣金
#### Scenario: 后台订单不触发
- **WHEN** 订单满足阈值条件,且 `order.Source="admin"`
- **THEN** 系统 SHALL 不触发一次性佣金

View File

@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: 获取购买价格
系统 MUST 根据购买渠道返回正确的购买价格。
#### Scenario: 代理渠道使用分配零售价
- **WHEN** 客户通过代理渠道购买套餐
- **THEN** 系统 MUST 使用 `allocation.retail_price` 作为支付金额
#### Scenario: 平台渠道使用套餐建议零售价
- **WHEN** 客户通过平台自营渠道购买套餐
- **THEN** 系统 MUST 使用 `Package.SuggestedRetailPrice` 作为支付金额
---
### Requirement: validatePackages 价格累加与展示校验
系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的价格来源进行累加计算,并在代理渠道增加价格展示可见性校验。
#### Scenario: 代理渠道累加使用 retail_price
- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的 `allocation.retail_price`
#### Scenario: 平台渠道累加使用 SuggestedRetailPrice
- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道过滤异常零售价
- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price`
- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验

View File

@@ -0,0 +1,13 @@
## MODIFIED Requirements
### Requirement: 微信标识索引策略
系统 MUST 将 `tb_personal_customer.wx_open_id` 的索引从唯一索引调整为普通索引:删除 `uniqueIndex`,改为 `index`
#### Scenario: 多条记录允许相同 wx_open_id
- **WHEN** 数据库中写入两条具有相同 `wx_open_id` 的个人客户记录
- **THEN** 数据库层 MUST 不再因唯一约束报错
#### Scenario: 查询性能仍受索引保障
- **WHEN** 按 `wx_open_id` 执行查询
- **THEN** 系统 MUST 继续命中普通索引以保障查询性能

View File

@@ -0,0 +1,26 @@
## MODIFIED Requirements
### Requirement: 充值支付回调处理
系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额。
关键一致性修复:`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行。
#### Scenario: 回调处理中状态更新与支付信息更新同事务
- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback`
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdateStatusWithOptimisticLock`
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdatePaymentInfo`
#### Scenario: 事务失败整体回滚
- **WHEN** 回调处理中任一步骤失败
- **THEN** 系统 MUST 回滚该事务,保证订单状态与支付信息不出现部分成功
---
### Requirement: Store 方法签名支持事务参数
系统 MUST 调整充值相关 Store 方法签名,支持显式传入 `*gorm.DB tx` 参数,以保证事务边界可控。
#### Scenario: Service 传入事务句柄
- **WHEN** Service 在事务上下文调用 Store 更新充值记录
- **THEN** Store 方法 MUST 接收并使用传入的 `tx` 执行数据库操作

View File

@@ -0,0 +1,107 @@
## 1. 常量定义
- [x] 1.1 在 `pkg/constants/` 新增 `asset_status.go`,定义资产业务状态常量:`AssetStatusInStock = 1`(在库)、`AssetStatusSold = 2`(已销售)、`AssetStatusExchanged = 3`(已换货)、`AssetStatusDeactivated = 4`(已停用),每个常量必须有中文注释
- [x] 1.2 在 `pkg/constants/` 新增 `order_source.go`,定义订单来源常量:`OrderSourceAdmin = "admin"`(后台)、`OrderSourceClient = "client"`(客户端),每个常量必须有中文注释
- [x] 1.3 在 `pkg/constants/` 新增 `operator_type.go`,定义操作人类型常量:`OperatorTypeAdminUser = "admin_user"`(后台用户)、`OperatorTypePersonalCustomer = "personal_customer"`(个人客户),每个常量必须有中文注释
- [x] 1.4 在 `pkg/constants/` 新增 `realname_link.go`,定义实名链接类型常量:`RealnameLinkTypeNone = "none"``RealnameLinkTypeTemplate = "template"``RealnameLinkTypeGateway = "gateway"`,每个常量必须有中文注释
## 2. 模型字段新增
- [x] 2.1 在 `internal/model/iot_card.go``IotCard` 结构体中新增 `AssetStatus int` 字段gorm: `column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用`)和 `Generation int` 字段gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`
- [x] 2.2 在 `internal/model/device.go``Device` 结构体中新增 `AssetStatus int``Generation int` 字段gorm tag 同 2.1
- [x] 2.3 在 `internal/model/order.go``Order` 结构体中新增 `Source string` 字段gorm: `column:source;type:varchar(20);not null;default:'admin';comment:订单来源 admin-后台 client-客户端`)和 `Generation int` 字段gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`
- [x] 2.4 在 `internal/model/package.go``PackageUsage` 结构体中新增 `Generation int` 字段gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`
- [x] 2.5 在 `internal/model/asset_wallet.go``AssetRechargeRecord` 结构体中新增以下字段:`OperatorType string``column:operator_type;type:varchar(20);not null;default:'admin_user';comment:操作人类型`)、`Generation int``column:generation;type:int;not null;default:1;comment:资产世代编号`)、`LinkedPackageIDs datatypes.JSON``column:linked_package_ids;type:jsonb;default:'[]';comment:强充关联套餐ID列表`)、`LinkedOrderType string``column:linked_order_type;type:varchar(20);comment:关联订单类型`)、`LinkedCarrierType string``column:linked_carrier_type;type:varchar(20);comment:关联载体类型`)、`LinkedCarrierID *uint``column:linked_carrier_id;type:bigint;comment:关联载体ID`
- [x] 2.6 在 `internal/model/carrier.go``Carrier` 结构体中新增 `RealnameLinkType string``column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口`)和 `RealnameLinkTemplate string``column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL`
- [x] 2.7 在 `internal/model/shop_package_allocation.go``ShopPackageAllocation` 结构体中新增 `RetailPrice int64` 字段gorm: `column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)`
- [x] 2.8 在 `internal/model/personal_customer.go` 中将 `WxOpenID` 字段的 gorm tag 从 `uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL` 改为 `index:idx_personal_customer_wx_open_id`(唯一索引改为普通索引)
## 3. 数据库迁移
- [x] 3.1 创建迁移文件(使用 golang-migrate 工具),包含以下 ALTER TABLE 语句:
- `tb_iot_card` ADD `asset_status int NOT NULL DEFAULT 1`、ADD `generation int NOT NULL DEFAULT 1`
- `tb_device` ADD `asset_status int NOT NULL DEFAULT 1`、ADD `generation int NOT NULL DEFAULT 1`
- `tb_order` ADD `source varchar(20) NOT NULL DEFAULT 'admin'`、ADD `generation int NOT NULL DEFAULT 1`
- `tb_package_usage` ADD `generation int NOT NULL DEFAULT 1`
- `tb_asset_recharge_record` ADD `operator_type varchar(20) NOT NULL DEFAULT 'admin_user'`、ADD `generation int NOT NULL DEFAULT 1`、ADD `linked_package_ids jsonb DEFAULT '[]'`、ADD `linked_order_type varchar(20)`、ADD `linked_carrier_type varchar(20)`、ADD `linked_carrier_id bigint`
- `tb_carrier` ADD `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`、ADD `realname_link_template varchar(500) DEFAULT ''`
- `tb_shop_package_allocation` ADD `retail_price bigint NOT NULL DEFAULT 0`
- [x] 3.2 在同一迁移文件中添加存量数据修复 SQL`UPDATE tb_shop_package_allocation spa SET retail_price = (SELECT suggested_retail_price FROM tb_package p WHERE p.id = spa.package_id) WHERE retail_price = 0`
- [x] 3.3 在同一迁移文件中添加索引变更DROP `idx_personal_customer_wx_open_id` 唯一索引CREATE 普通索引 `idx_personal_customer_wx_open_id` ON `tb_personal_customer(wx_open_id)`
- [x] 3.4 编写 down 迁移文件(回滚用),包含对应的 DROP COLUMN 和索引恢复语句
- [x] 3.5 执行迁移,使用 PostgreSQL MCP 工具验证所有字段已添加、存量 `retail_price` 已更新、索引已变更
## 4. BUG-1 修复:代理零售价
- [x] 4.1 修改 `internal/service/purchase_validation/service.go``GetPurchasePrice()` 方法(约第 172 行):增加渠道判断,代理渠道(`card.ShopID > 0`)时查询 `ShopPackageAllocation` 获取 `RetailPrice` 并返回,平台渠道继续返回 `Package.SuggestedRetailPrice`
- [x] 4.2 修改 `internal/service/purchase_validation/service.go``validatePackages()` 方法(约第 148 行):将 `totalPrice += pkg.SuggestedRetailPrice` 改为按渠道取价逻辑(复用 4.1 的渠道判断),代理渠道额外校验 `allocation.RetailPrice >= allocation.CostPrice`,不满足则该套餐视为不可购买
- [x] 4.3 修改 `internal/service/shop_package_batch_allocation/service.go`:创建分配记录时设置 `RetailPrice``Package.SuggestedRetailPrice`(约第 84-105 行创建 allocation 的位置)
- [x] 4.4 修改 `internal/service/shop_series_grant/service.go`:创建/更新 `ShopPackageAllocation` 时同步设置 `RetailPrice`(约第 302-352 行和第 614-685 行)
- [x] 4.5 在 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法中新增 cost_price 锁定检查:更新每条 allocation 的 cost_price 前,查询是否存在下级分配记录(`ShopPackageAllocation WHERE allocator_shop_id = allocation.ShopID AND package_id = allocation.PackageID`),存在则跳过该条并记录到响应的 `skipped` 列表,附带原因"存在下级分配记录,请先回收后再修改成本价"
- [x] 4.6 在 `internal/service/shop_series_grant/service.go` 中同步添加 cost_price 锁定检查:更新现有 allocation 的 CostPrice 时(约第 634 行),查询是否存在下级分配记录,存在则拒绝修改并返回错误
## 5. 代理零售价后台管理
- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go``BatchUpdateCostPriceRequest`:新增 `PricingTarget string` 字段(`json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"`),不传时默认为 `cost_price` 保持向后兼容
- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法:根据 `req.PricingTarget` 分流——`cost_price` 走现有逻辑(含 4.5 的锁定检查),`retail_price` 走新逻辑:计算新零售价、校验 `newRetailPrice >= allocation.CostPrice`(不满足则跳过并报错"零售价不能低于成本价")、更新 `allocation.RetailPrice`、记录价格历史(`ShopPackageAllocationPriceHistory` 增加 `old_retail_price`/`new_retail_price` 字段或复用 `OldCostPrice`/`NewCostPrice` 字段并新增 `price_type` 标识)
- [x] 5.3 修改 `internal/model/dto/package_dto.go``PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
- [x] 5.4 修改 `internal/service/package/service.go``toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice`
- [x] 5.5 修改 `internal/service/package/service.go``toResponseWithAllocation()` 方法(约第 595-603 行):同 5.4,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算
## 6. Carrier 管理 DTO 更新
- [x] 6.1 修改 `internal/model/dto/carrier_dto.go`(或 Carrier 相关 DTO 文件):在 `CarrierCreateRequest``CarrierUpdateRequest` 中新增 `RealnameLinkType *string` 字段(`json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`)和 `RealnameLinkTemplate *string` 字段(`json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
- [x] 6.2 修改 Carrier Service 的 Create/Update 方法:将 DTO 中的 `RealnameLinkType``RealnameLinkTemplate` 写入 Carrier 模型Update 时 `realname_link_type``template` 时校验 `realname_link_template` 非空
- [x] 6.3 修改 Carrier 的响应 DTO`CarrierResponse`):新增 `RealnameLinkType``RealnameLinkTemplate` 字段,后台列表/详情可展示
## 7. 后台订单 generation 快照
- [x] 7.1 修改 `internal/service/order/service.go``CreateAdminOrder()` 方法:创建 Order 时从资产IotCard/Device获取当前 `Generation` 值并设入 `order.Generation`,不再依赖数据库默认值 1
## 8. 资产手动停用
- [x] 8.1 在 `internal/handler/admin/asset.go`(或新建 `internal/handler/admin/asset_lifecycle.go`)新增 `DeactivateAsset` Handler 方法:接收资产类型和 ID调用 Service 将 `asset_status` 设为 `constants.AssetStatusDeactivated`4
- [x] 8.2 在 `internal/service/asset/service.go`(或相关 Service新增 `Deactivate()` 方法:校验当前 `asset_status` 为 1在库或 2已销售才允许停用已换货3或已停用4的拒绝操作使用条件更新 `WHERE asset_status IN (1, 2)` 确保幂等
- [x] 8.3 在 `internal/routes/` 注册停用路由:`PATCH /api/admin/iot-cards/:id/deactivate``PATCH /api/admin/devices/:id/deactivate`(或统一为 `PATCH /api/admin/assets/:type/:id/deactivate`
- [x] 8.4 更新 `cmd/api/docs.go``cmd/gendocs/main.go`:如新增了 Handler 则同步注册到文档生成器
## 9. BUG-2 修复:一次性佣金触发条件
- [x] 9.1 修改 `internal/service/commission_calculation/service.go` 中的 `triggerOneTimeCommissionForCardInTx` 方法:将 `if order.IsPurchaseOnBehalf` 的跳过逻辑改为 `if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient`,即代购订单或非客户端来源订单均跳过一次性佣金
- [x] 9.2 修改 `internal/service/commission_calculation/service.go` 中的 `triggerOneTimeCommissionForDeviceInTx` 方法:同 9.1 逻辑
## 10. BUG-4 修复:充值回调事务一致性
- [x] 10.1 修改 `internal/store/postgres/asset_recharge_store.go``UpdateStatusWithOptimisticLock` 方法:新增 `tx *gorm.DB` 参数(方法签名变更),内部使用传入的 `tx` 替代 `s.db.WithContext(ctx)`;同时保留原方法签名的兼容版本或修改所有调用点
- [x] 10.2 修改 `internal/store/postgres/asset_recharge_store.go``UpdatePaymentInfo` 方法:同 10.1,新增 `tx *gorm.DB` 参数
- [x] 10.3 修改 `internal/service/recharge/service.go``HandlePaymentCallback` 方法(约第 308-321 行):在事务闭包内调用 Store 方法时传入事务 `tx`,确保 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` 和钱包入账在同一事务内
- [x] 10.4 检查并更新 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` 的其他调用点(如有),确保传入正确的 db 或 tx 参数
## 11. 旧 H5 接口清理
- [x] 11.1 删除 `internal/handler/h5/` 目录下全部 5 个文件:`auth.go``order.go``recharge.go``package_usage.go``enterprise_device.go`
- [x] 11.2 删除 `internal/routes/h5.go``internal/routes/h5_enterprise_device.go``internal/routes/h5_package_usage.go`
- [x] 11.3 修改 `internal/routes/routes.go`:移除 `/api/h5` 路由组挂载(约第 31-33 行)
- [x] 11.4 修改 `internal/routes/order.go`:移除 `registerH5OrderRoutes` 函数(约第 56-105 行)
- [x] 11.5 修改 `internal/routes/recharge.go`:移除 `registerH5RechargeRoutes` 函数(约第 11-44 行)
- [x] 11.6 修改 `internal/bootstrap/handlers.go`:移除 H5 Handler 构造H5Auth、EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge约第 24、31、44、49、50 行)
- [x] 11.7 修改 `internal/bootstrap/types.go`:移除 Handlers 结构体中的 H5 Handler 字段(约第 22、29、42、47、48 行)
- [x] 11.8 修改 `internal/bootstrap/middlewares.go`:移除 `createH5AuthMiddleware` 函数和 H5 跳过路径配置(约第 72-95 行)
- [x] 11.9 修改 `pkg/openapi/handlers.go`:移除文档生成中的 H5 Handler 构造EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge
- [x] 11.10 修改 `cmd/api/main.go`:移除 `/api/h5` 限流挂载(约第 250-257 行)
## 12. 旧个人客户登录接口清理
- [x] 12.1 修改 `internal/handler/app/personal_customer.go`:删除 Login约第 79 行、SendCode约第 35 行、WechatOAuthLogin约第 114 行、BindWechat约第 134 行)方法。保留 UpdateProfile 和 GetProfile
- [x] 12.2 修改 `internal/routes/personal.go`移除指向已删除方法的路由注册Login、SendCode、WechatOAuthLogin、BindWechat 的路由)
- [x] 12.3 检查 `internal/bootstrap/handlers.go``internal/bootstrap/types.go`:如果 PersonalCustomer Handler 有初始化引用已删除方法的依赖,同步清理
## 13. 验证
- [x] 13.1 执行 `go build ./...` 确认编译通过,无任何编译错误
- [x] 13.2 对所有修改的文件执行 `lsp_diagnostics` 确认无错误和警告
- [x] 13.3 使用 PostgreSQL MCP 工具验证数据库:确认 7 张表的新字段存在、默认值正确、存量 `retail_price` 已填充、`wx_open_id` 索引已变更
- [x] 13.4 验证删除的 H5 路由不再注册:检查代码中无 `/api/h5` 相关路由残留
- [x] 13.5 验证 `BatchUpdatePricing` 接口扩展:确认 `pricing_target=retail_price` 参数可正常使用,不传时默认走 `cost_price` 逻辑(向后兼容)
- [x] 13.6 验证代理套餐列表:确认 `PackageResponse` 包含 `retail_price` 字段,`profit_margin` 计算基于 `retail_price - cost_price`
- [x] 13.7 撰写功能总结文档 `docs/client-api-data-model-fixes/功能总结.md`,记录所有变更内容