## 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

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