# 客户端（H5/小程序）接口需求完整说明

> **文档目的**: 本文档是客户端接口开发的**唯一权威参考**。新的 AI session 或开发者应基于此文档进行实现，无需重新探索需求。

---

## 〇、原始需求（用户原话摘要）

### 背景
系统之前由于疏忽在提案中做了几个 H5 接口（`/api/h5`），这些接口使用的是 B 端认证体系，不适用于个人客户场景。现在需要**删除这些旧接口**，重新设计一套面向个人客户（C 端）的完整客户端接口体系。

### 客户端形态
- 微信公众号 H5
- 微信小程序
- 两者可能使用不同的 AppID，不一定绑定同一个微信开放平台

### 登录流程
用户有两种路径进入客户端：
1. **扫码进入**：通过微信扫一扫扫描特定二维码，带参数跳转进前端（扫码是前端行为，后端无感）
2. **手动输入**：直接打开客户端，输入 SN/IMEI/虚拟号/ICCID 等资产标识符

登录是一种"假登录"，**基于资产**而非基于用户身份。任何人拿到资产标识符都可以登录，即使该资产已被别人绑定过。

**登录流程顺序**：先输入资产标识符 → 再走微信授权 → 检查是否需要绑定资产 → 检查是否需要绑定手机号（是否强制绑定手机号在代码中可配置）

### 功能需求清单

1. **登录注册**：资产标识符输入 → 微信授权（公众号/小程序）→ 资产绑定 → 手机号绑定（可配置是否强制）
2. **资产信息展示**：基本信息、套餐信息、流量信息（支持卡资产和设备资产两种类型）
3. **可购买套餐列表**：按价格排序。代理渠道展示代理设定的零售价，平台渠道展示建议零售价。平台禁用的套餐全渠道不展示，代理下架仅影响该代理
4. **钱包详情 + 流水列表**
5. **充值接口**：用户输入金额 → 微信支付充值
6. **充值订单列表**
7. **套餐购买**：拉起微信支付。购买前必须实名。有强充逻辑（不应让前端决定创建充值还是套餐订单，后端统一处理）。涉及一次性佣金的首充/累计充值/梯度模式
8. **套餐订单列表 + 详情**
9. **历史套餐列表**
10. **实名跳转**：两个入口——购买套餐被拦截后引导、设备卡列表主动选择。运营商实名链接两种方式：模板 URL 和 Gateway 接口
11. **手动刷新卡状态/实名状态**
12. **设备卡列表**：含卡状态、实名状态、运营商名称
13. **设备能力**：重启/恢复出厂/设置 WiFi/切卡
14. **换货系统**：后台发起换货 → 客户端收到通知填写收货信息 → 后台发货+全量迁移 → 手动确认完成。旧资产可"转新"重新销售
15. **换绑手机号**

### 关键设计决策

- 所有接口挂载在 `/api/c/v1` 下，使用 PersonalCustomer JWT 认证
- JWT 只含 `customer_id`，资产信息由客户端每次请求传递
- **所有涉及资产的接口统一使用标识符（identifier）入参**，而非内部 ID（方案 A）
- 换货不是简单换卡，是全量迁移（钱包、套餐、累计充值状态、标签等），旧资产数据保留但标记为"已换货"
- 后台代理自购不应触发一次性佣金（只有个人客户充值才触发）
- 代理需要能设置自己的零售价（现有 ShopPackageAllocation 缺少 retail_price）

---

## 一、现有系统需要修改/修复的问题

### BUG-1：代理零售价缺失

**现状**：`ShopPackageAllocation` 只有 `cost_price`（上级给的成本价），没有代理面向终端客户的零售价。`GetPurchasePrice()` 始终返回 `Package.SuggestedRetailPrice`。

**修复**：`tb_shop_package_allocation` 新增 `retail_price` 字段。

```
retail_price (bigint, NOT NULL, DEFAULT 0)
说明: 代理面向终端客户的实际零售价（分）
默认值: 分配时自动设为 Package.SuggestedRetailPrice
约束: retail_price >= cost_price AND retail_price <= suggested_retail_price * 2
```

**成本价锁定规则**：当套餐存在下级分配记录（`ShopPackageAllocation`）时，上级**禁止修改该套餐的 `cost_price`**。必须先回收所有下级分配后才能调整成本价。这避免了 cost_price 调高后下级 retail_price 违反约束导致亏损售卖的问题。

**影响文件**：
- `internal/model/shop_package_allocation.go` — 加字段
- `internal/service/purchase_validation/service.go` → `GetPurchasePrice()` 改为：代理渠道查 `allocation.retail_price`，平台渠道用 `Package.SuggestedRetailPrice`
- `internal/service/shop_package_batch_allocation/service.go` — 创建分配时设置 `retail_price` 默认值；**修改 cost_price 前检查是否存在下级分配，存在则拒绝修改**
- 代理套餐管理接口 — 支持修改 `retail_price`
- 需要一个数据库迁移：新增字段

**补充说明**: `validatePackages()` 第 148 行的 `totalPrice += pkg.SuggestedRetailPrice` 也需要修复。代理渠道应查 `allocation.retail_price` 计算订单总价，而非始终使用建议零售价。修复范围不仅限于 `GetPurchasePrice()`，还包括 `validatePackages()` 内部的价格累加逻辑。

### BUG-2：后台代理自购触发一次性佣金

**现状**：`CreateAdminOrder` 中代理自购（`isPurchaseOnBehalf=false`）会调用 `enqueueCommissionCalculation`，而佣金计算中 `!order.IsPurchaseOnBehalf` 为 true 时会触发一次性佣金。

**修复**：后台所有订单都不应触发一次性佣金。一次性佣金只能由个人客户的**充值操作**触发。

**方案**：给 Order 模型新增 `source` 字段标识订单来源（`admin`/`client`）。修改 `commission_calculation/service.go` 的一次性佣金触发条件为**完整的双重判断**：`!order.IsPurchaseOnBehalf && order.Source == "client"`——只有客户端来源且非代购的订单才触发一次性佣金。

**完整判断矩阵**：
| 场景 | IsPurchaseOnBehalf | Source | 触发一次性佣金？ |
|------|-------------------|--------|----------------|
| 后台代理自购 | false | admin | ❌ source != client |
| 后台代理代购 | true | admin | ❌ IsPurchaseOnBehalf |
| 后台平台代购 | true | admin | ❌ 两条都拦截 |
| 客户端个人客户购买 | false | client | ✅ 唯一触发路径 |

**影响文件**：
- `internal/model/order.go` — 新增 `Source` 字段
- `internal/service/commission_calculation/service.go` — 将 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`
- `internal/service/order/service.go` — `CreateAdminOrder` 设置 `Source: "admin"`，客户端订单创建设置 `Source: "client"`

### BUG-4：充值回调事务一致性问题

**现状**：`recharge/service.go:308-321` 的 `HandlePaymentCallback` 中，`UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 使用 `s.db.WithContext(ctx)` 而非事务内的 `tx`。导致可能出现「充值单已标记为已支付，但钱包余额未增加」的半提交。

**修复**：将状态更新和支付信息更新改为使用事务内的 `tx`，确保充值单状态变更和钱包入账在同一事务内原子完成。

**影响文件**：
- `internal/service/recharge/service.go` — HandlePaymentCallback 事务内统一使用 tx
- `internal/store/postgres/asset_recharge_store.go` — UpdateStatusWithOptimisticLock / UpdatePaymentInfo 支持传入 tx

### BUG-3：换卡模型不满足换货需求

**现状**：`CardReplacementRecord` 仅支持卡换卡，缺少收货地址、快递信息、设备换货、全量迁移等功能。

**修复**：新建 `ExchangeOrder` 模型替代，删除旧模型。

**当前引用旧模型的文件**（仅 3 处，替换成本低）：
- `internal/model/card_replacement.go` — 删除
- `internal/model/system.go` — 移除 AutoMigrate
- `internal/store/postgres/iot_card_store.go` — `is_replaced` 过滤改为查新表

---

## 二、数据库变更

### 2.1 新增模型

#### 系统配置 — 使用配置文件（非数据库表）

客户端行为开关（如 `require_phone_binding`）通过 Viper 配置文件管理，不单独建表。

**配置项**（在 `config.yaml` 中）：

```yaml
client:
  require_phone_binding: true  # 客户端是否强制绑定手机号
```

**设计原因**：当前仅一个配置项，使用配置文件足够。后续若需要多个动态配置项（如需运行时热更新），再考虑引入数据库 KV 表。

#### tb_personal_customer_openid — 个人客户 OpenID 关联表

```go
type PersonalCustomerOpenID struct {
    gorm.Model
    CustomerID uint   `gorm:"column:customer_id;not null;index;comment:关联个人客户ID"`
    AppType    string `gorm:"column:app_type;type:varchar(20);not null;comment:应用类型 official_account-公众号 miniapp-小程序"`
    AppID      string `gorm:"column:app_id;type:varchar(100);not null;comment:微信AppID"`
    OpenID     string `gorm:"column:open_id;type:varchar(100);not null;comment:该AppID下的OpenID"`
    UnionID    string `gorm:"column:union_id;type:varchar(100);comment:UnionID（有开放平台时才有）"`
    // 唯一索引: UNIQUE(app_id, open_id) WHERE deleted_at IS NULL
}
func (PersonalCustomerOpenID) TableName() string { return "tb_personal_customer_openid" }
```

**设计原因**：公众号和小程序可能使用不同 AppID 且不一定绑定同一微信开放平台。同一用户在不同 AppID 下有不同 OpenID。原 `PersonalCustomer.wx_open_id` 保留兼容，逻辑主体迁移到新表。

#### tb_exchange_order — 换货单模型

```go
type ExchangeOrder struct {
    gorm.Model
    BaseModel `gorm:"embedded"`

    ExchangeNo string `gorm:"column:exchange_no;type:varchar(50);uniqueIndex:...,where:deleted_at IS NULL;not null;comment:换货单号"`

    // 旧资产
    OldAssetType  string `gorm:"column:old_asset_type;type:varchar(20);not null;comment:旧资产类型 iot_card/device"`
    OldAssetID    uint   `gorm:"column:old_asset_id;not null;index;comment:旧资产ID"`
    OldIdentifier string `gorm:"column:old_identifier;type:varchar(100);not null;comment:旧标识符"`

    // 新资产（发货时填写）
    NewAssetType  string `gorm:"column:new_asset_type;type:varchar(20);comment:新资产类型"`
    NewAssetID    *uint  `gorm:"column:new_asset_id;index;comment:新资产ID"`
    NewIdentifier string `gorm:"column:new_identifier;type:varchar(100);comment:新标识符"`

    // 收货信息（客户端填写）
    RecipientName    string `gorm:"column:recipient_name;type:varchar(50);comment:收货人姓名"`
    RecipientPhone   string `gorm:"column:recipient_phone;type:varchar(20);comment:收货人手机号"`
    RecipientAddress string `gorm:"column:recipient_address;type:text;comment:收货地址"`

    // 物流（后台发货时填写）
    ExpressCompany string `gorm:"column:express_company;type:varchar(100);comment:快递公司"`
    ExpressNo      string `gorm:"column:express_no;type:varchar(100);comment:快递单号"`

    // 状态: 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消
    Status         int        `gorm:"column:status;type:int;not null;default:1;comment:状态"`
    ExchangeReason string     `gorm:"column:exchange_reason;type:varchar(200);comment:换货原因"`
    Remark         *string    `gorm:"column:remark;type:text;comment:备注"`

    // 迁移
    MigrateData        bool  `gorm:"column:migrate_data;type:boolean;default:false;comment:是否迁移数据"`
    MigrationCompleted bool  `gorm:"column:migration_completed;type:boolean;default:false;comment:是否完成迁移"`
    MigrationBalance   int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移余额（分）"`

    // 时间
    ShippedAt   *time.Time `gorm:"column:shipped_at;comment:发货时间"`
    CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间"`
    InitiatorID uint       `gorm:"column:initiator_id;not null;comment:发起人ID"`
    ShopID      *uint      `gorm:"column:shop_id;index;comment:多租户过滤"`
}
func (ExchangeOrder) TableName() string { return "tb_exchange_order" }
```

**状态机**：`1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成`，status=1 或 2 时可取消(→5)，已发货(status=3)后不可取消

### 2.2 修改现有模型

| 模型 | 字段变更 | 说明 |
|------|---------|------|
| `ShopPackageAllocation` | +`retail_price bigint NOT NULL DEFAULT 0` | 代理零售价 |
| `Carrier` | +`realname_link_type varchar(20) NOT NULL DEFAULT 'none'` | 实名链接类型: none/template/gateway |
| `Carrier` | +`realname_link_template varchar(500) DEFAULT ''` | 模板 URL，支持 `{iccid}` `{msisdn}` `{virtual_no}` 占位符 |
| `AssetRechargeRecord` | +`linked_package_ids jsonb DEFAULT '[]'` | 强充关联套餐ID列表 |
| `AssetRechargeRecord` | +`linked_order_type varchar(20)` | 关联订单类型 |
| `AssetRechargeRecord` | +`linked_carrier_type varchar(20)` | 关联载体类型 |
| `AssetRechargeRecord` | +`linked_carrier_id bigint` | 关联载体ID |
| `AssetRechargeRecord` | +`operator_type varchar(20) NOT NULL DEFAULT 'admin_user'` | 操作人类型: admin_user-后台用户 personal_customer-个人客户。与 user_id 配合区分不同 ID 体系 |
| `AssetRechargeRecord` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号，创建时从资产当前 generation 复制 |
| `Order` | +`source varchar(20) NOT NULL DEFAULT 'admin'` | 订单来源: admin-后台 client-客户端。一次性佣金仅 client 来源且非代购时触发 |
| `Order` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号，创建时从资产当前 generation 复制 |
| `PackageUsage` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号，创建时从资产当前 generation 复制 |
| `IotCard` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号，转新时 +1，客户端按当前 generation 过滤历史数据 |
| `IotCard` | +`asset_status int NOT NULL DEFAULT 1` | 业务生命周期状态: 1-在库 2-已销售 3-已换货 4-已停用。与 network_status（运营商网络状态）独立 |
| `Device` | +`generation int NOT NULL DEFAULT 1` | 同上 |
| `Device` | +`asset_status int NOT NULL DEFAULT 1` | 同上 |
| `PersonalCustomer` | 删除 `wx_open_id` 的唯一索引，改为普通索引 | 支持多 OpenID 方案后，唯一约束迁移到 PersonalCustomerOpenID 表 |

### 2.3 删除模型

| 模型 | 处理 |
|------|------|
| `CardReplacementRecord` | 表改名 `tb_card_replacement_record_legacy`，代码引用全部替换为 `ExchangeOrder` |

---

## 三、统一设计原则

### 3.1 标识符统一入参

**所有涉及资产的客户端接口**使用 `identifier` 参数而非 `asset_id`。

**已有可复用方法**：
- `asset.Service.Resolve(ctx, identifier)` — 位于 `internal/service/asset/service.go` 第 71 行
- 先查 Device（virtual_no/imei/sn），再查 IotCard（virtual_no/iccid/msisdn）
- 返回 `*dto.AssetResolveResponse`（定义在 `internal/model/dto/asset_dto.go`）

**客户端 Handler 需提取的公共方法**（放在 `internal/handler/app/` 或 `pkg/` 中）：

```go
// resolveAssetFromIdentifier 统一资产解析
// 注意: 此方法不能添加数据权限过滤（个人客户不受 shop_id 限制）
func resolveAssetFromIdentifier(ctx context.Context, assetService *asset.Service, identifier string) (assetType string, assetID uint, err error)
```

### 3.2 认证方式

- 路由域：`/api/c/v1`
- 中间件：`PersonalAuthMiddleware`（JWT）
- JWT Payload：`{ customer_id, exp }`
- 现有实现：`internal/middleware/personal_auth.go`，存储 `c.Locals("customer_id")`

### 3.3 响应格式

统一 `pkg/response/`：`{ code, msg, data, timestamp }`

### 3.4 个人客户认证体系

- JWT Payload: `{ customer_id, exp }`
- **有状态管理**: Redis 存储 token 状态，支持服务端主动失效（如封禁用户、强制下线）
- Token 过期时间: 可配置（建议 7 天）
- 退出登录时清除 Redis 中的 token 记录
- 认证中间件: `PersonalAuthMiddleware` 需增加 Redis 有效性检查
- 是否强制绑定手机号: 通过 Viper 配置文件 `client.require_phone_binding` 读取（非数据库表）

### 3.5 openid 安全规范

**所有需要微信 OpenID 的接口（支付、充值等），OpenID 一律由后端查询获取，禁止客户端传入。**

- 客户端传 `app_type`（`official_account` 或 `miniapp`）标识当前使用的应用类型
- 后端根据 `customer_id` + `app_type` 从 `PersonalCustomerOpenID` 表查询对应的 OpenID
- 防止客户端伪造 OpenID 拉起他人支付

### 3.6 资产世代（generation）机制

用于换货转新场景的数据隔离：

- `IotCard` 和 `Device` 模型均有 `generation` 字段（默认 1）
- 换货完成后旧资产执行"转新"操作时，`generation` 自增 1
- **客户端查询规则**: 订单、充值记录、套餐历史等仅展示**当前 generation** 的数据
- **后台查询规则**: 不受 generation 过滤，可查看全部历史数据

#### 实现方案：关联表冗余 generation 字段

在创建记录时，从资产的当前 generation 复制到关联记录上（写时快照）：

```go
// 创建订单时
order.Generation = card.Generation

// 创建充值记录时
rechargeRecord.Generation = card.Generation

// 创建套餐使用记录时
packageUsage.Generation = card.Generation
```

客户端查询时按 generation 过滤：
```sql
-- 示例: 查询当前世代的订单
SELECT * FROM tb_order
WHERE iot_card_id = ? AND generation = ?
ORDER BY created_at DESC
```

#### 需要 generation 字段的关联表

| 表 | 过滤方式 | 说明 |
|----|---------|------|
| `tb_order` | `generation` 字段过滤 | 创建订单时快照资产 generation |
| `tb_asset_recharge_record` | `generation` 字段过滤 | 创建充值记录时快照资产 generation |
| `tb_package_usage` | `generation` 字段过滤 | 创建套餐使用记录时快照资产 generation |
| `tb_asset_wallet_transaction` | **通过 wallet_id 隔离** | 转新时创建新钱包，流水天然属于新钱包，无需 generation 字段 |

#### 需要 generation 过滤的客户端查询

- C5 充值订单列表 → `WHERE resource_id=? AND generation=?`
- D2 套餐订单列表 → `WHERE iot_card_id=? AND generation=?`
- B3 历史套餐列表 → `WHERE iot_card_id=? AND generation=?`
- C2 钱包流水列表 → **通过 wallet_id 隔离**（转新后新钱包有新 wallet_id）

#### 不需要 generation 过滤的查询

- B1 资产基本信息（实时状态）
- C1 钱包详情（转新时余额已迁移到新钱包）
- B2 可购买套餐列表（实时可购买）

### 3.7 资产业务状态（asset_status）

`IotCard` 和 `Device` 新增 `asset_status` 字段，表示 CMP 内部业务生命周期，与运营商 `network_status`（网络状态: 开机/停机）完全独立。

```
asset_status int NOT NULL DEFAULT 1
  1 = 在库（可销售、可分配）
  2 = 已销售（正常使用中，已绑定客户或代理）
  3 = 已换货（换货完成，数据保留，不可操作）
  4 = 已停用（手动停用）
```

**状态流转**:
- 导入 → 1（在库）
- 首次绑定客户/分配代理 → 2（已销售）
- 换货完成 → 3（已换货）
- 转新 → 1（在库），同时 generation +1
- 手动停用 → 4（已停用）

**与 network_status 的关系**：
- `network_status` 由 Gateway 同步，反映运营商侧真实网络状态
- `asset_status` 由 CMP 业务操作控制，反映内部生命周期
- 两者互不干扰。例如：转新后 `asset_status=1`（在库），但 `network_status` 仍由运营商决定

---

## 四、接口详细规格

### 4.1 模块 A：认证与登录

#### A1: 验证资产标识符

```
POST /api/c/v1/auth/verify-asset
认证: 不需要
限频: 同一 IP 每分钟最多 30 次

请求体:
{
  "identifier": "string"  // 必填。SN/IMEI/虚拟号/ICCID/MSISDN
}

响应体:
{
  "asset_token": "eyJhbGciOi...",  // 短时效令牌（5分钟过期），替代明文 asset_id
  "asset_type": "card",             // card 或 device
  "carrier_name": "中国联通",       // 卡时有效
  "device_name": "GPS追踪器"       // 设备时有效
}

实现逻辑:
1. 调用 asset.Service.Resolve(ctx, identifier)
2. 注意: 不检查归属/权限，任何人输入任何标识符都可以查到
3. 资产不存在返回 404
4. 只返回最小必要信息（asset_type, 名称），不暴露内部 asset_id 和详细信息
5. 生成短时效 asset_token（JWT，payload: { asset_type, asset_id, exp: 5min }），供后续 A2/A3 登录使用
6. IP 级别限频防止暴力枚举

安全说明: 不直接返回 asset_id（数据库内部 ID），避免无认证接口暴露内部标识。
A2/A3 登录接口使用 asset_token 验证，而非客户端直传 asset_type + asset_id。

参考: internal/service/asset/service.go:71 的 Resolve 方法
```

#### A2: 微信公众号登录

```
POST /api/c/v1/auth/wechat-login
认证: 不需要

请求体:
{
  "code": "string",          // 必填。微信 OAuth 授权码（scope=snsapi_userinfo 以获取用户信息）
  "asset_token": "string"    // 必填。A1 返回的短时效令牌
}

响应体:
{
  "token": "jwt_token_string",
  "expires_in": 86400,
  "customer": {
    "id": 1,
    "nickname": "微信昵称",
    "avatar_url": "https://...",
    "phone": "138****0001",   // 有绑定则返回脱敏手机号，无则为空
    "status": 1
  },
  "need_bind_phone": true,   // 是否需要绑定手机号
  "is_new_user": false        // 是否新创建的用户
}

实现逻辑:
1. 解析并验证 asset_token（校验签名、过期时间），提取 asset_type + asset_id
2. 从当前生效的 WechatConfig（tb_wechat_config WHERE is_active=true）获取公众号 AppID/AppSecret
3. 用 code 调用微信 OAuth 接口获取 openid + (可选)unionid + userinfo
   注意: 需使用 scope=snsapi_userinfo 的授权码才能获取 userinfo
4. 查找个人客户:
   a. 查 tb_personal_customer_openid WHERE app_id=公众号AppID AND open_id=openid → 找到则获取客户
   b. 没找到，有 unionid → 查 WHERE union_id=unionid → 找到则合并（新增 openid 记录）
   c. 都没找到 → 创建新 PersonalCustomer + openid 记录
5. 创建 PersonalCustomerDevice (customer_id, virtual_no, bind_at=now)
   注意: 允许多人绑定同一资产（不覆盖旧绑定），一个资产可被多个客户绑定。
   订单跟着资产走，同时记录 customer_id 便于后续改版区分归属。
6. 检查手机号绑定: 读 Viper 配置 client.require_phone_binding，查 PersonalCustomerPhone
7. 签发 JWT Token (payload: customer_id)，同时写入 Redis（有状态 Token）

参考:
- 现有微信 OAuth: internal/service/personal_customer/service.go WechatOAuthLogin
- 现有 WechatOAuthRequest/Response: internal/model/dto/wechat_dto.go
- PersonalAuthMiddleware: internal/middleware/personal_auth.go
```

#### A3: 微信小程序登录

```
POST /api/c/v1/auth/miniapp-login
认证: 不需要

请求体:
{
  "code": "string",          // 必填。小程序 login 获取的 code
  "asset_token": "string",   // 必填。A1 返回的短时效令牌
  "nickname": "string",      // 可选。前端授权后传入的用户昵称
  "avatar_url": "string"     // 可选。前端授权后传入的用户头像
}

响应体: 同 A2

实现逻辑:
1. 解析并验证 asset_token，提取 asset_type + asset_id
2. 从 WechatConfig 获取小程序 MiniappAppID/MiniappAppSecret
3. 调用微信 jscode2session 接口获取 openid + (可选)unionid
   注意: 小程序不能直接获取 userinfo，昵称/头像由前端授权后通过请求体传入
4. 后续逻辑同 A2 步骤 4-7（如传入了 nickname/avatar_url 则更新客户资料）
```

#### A4: 发送验证码

```
POST /api/c/v1/auth/send-code
认证: 不需要

请求体:
{
  "phone": "13800138000"  // 必填，11位手机号
}

响应体:
{ "message": "验证码已发送" }

实现逻辑:
1. 验证手机号格式（11位数字）
2. Redis 限频: 同一手机号 60 秒内不可重发
3. 生成 6 位随机验证码，Redis 存储 5 分钟 (KEY: sms:code:{phone})
4. 调用短信发送服务
5. IP 级别限频: 同一 IP 每小时最多发送 20 次验证码（防止批量刷不同手机号）
6. 每个手机号每日最多发送 10 次

参考: 现有实现 internal/handler/app/personal_customer.go:35 SendCode
```

#### A5: 绑定手机号

```
POST /api/c/v1/auth/bind-phone
认证: 需要 JWT

请求体:
{
  "phone": "13800138000",
  "code": "123456"
}

响应体:
{ "message": "绑定成功" }

实现逻辑:
1. 从 JWT 获取 customer_id
2. 验证码校验（Redis 中查找比对）
3. 检查该客户是否已绑定主手机号 → 已绑定则报错"已绑定手机号，如需更换请使用换绑功能"
4. 检查该手机号是否已被其他客户绑定为主手机号 → 已被绑定则报错"该手机号已被其他用户绑定"
5. 创建 PersonalCustomerPhone (customer_id, phone, is_primary=true, verified_at=now)
```

#### A6: 换绑手机号

```
POST /api/c/v1/auth/change-phone
认证: 需要 JWT

请求体:
{
  "old_phone": "13800138000",
  "old_code": "123456",        // 旧手机验证码
  "new_phone": "13900139000",
  "new_code": "654321"         // 新手机验证码
}

响应体:
{ "message": "换绑成功" }

实现逻辑:
1. 验证旧手机号: 查 PersonalCustomerPhone WHERE customer_id=? AND phone=old_phone AND is_primary=true
2. 验证旧手机验证码
3. 验证 new_phone != old_phone
4. 验证新手机验证码
5. 检查新手机号未被其他客户绑定
6. 旧记录 is_primary → false，新增记录 phone=new_phone, is_primary=true
```

#### A7: 退出登录

```
POST /api/c/v1/auth/logout
认证: 需要 JWT

响应体:
{ "message": "已退出登录" }

实现逻辑:
1. 从 JWT 获取 token
2. 删除 Redis 中该 token 的记录（使 token 立即失效）
3. 返回成功（即使 token 在 Redis 中不存在也返回成功，保持幂等）
```

### 4.2 模块 B：资产信息

#### B1: 资产基本信息

```
GET /api/c/v1/asset/info?identifier=xxx
认证: 需要 JWT

查询参数:
  identifier: string  // 必填。资产标识符

响应体: 复用 dto.AssetResolveResponse（见 internal/model/dto/asset_dto.go）
包含: 虚拟号、状态、套餐信息、流量信息、实名状态、绑定关系等全部字段

实现逻辑:
1. resolveAssetFromIdentifier → asset_type + asset_id
2. 调用 asset.Service.Resolve(ctx, identifier)
3. 注意: 个人客户调用不走 shop_id 数据权限过滤

参考: internal/handler/admin/asset.go Resolve 方法, internal/service/asset/service.go
```

#### B2: 可购买套餐列表

```
GET /api/c/v1/asset/packages?identifier=xxx
认证: 需要 JWT

查询参数:
  identifier: string  // 必填

响应体:
{
  "list": [
    {
      "package_id": 1,
      "package_code": "PKG001",
      "package_name": "月套餐30G",
      "package_type": "formal",        // formal-正式套餐 addon-加油包
      "duration_months": 1,
      "real_data_mb": 30720,
      "virtual_data_mb": 24576,        // 虚流量（如果启用）
      "enable_virtual_data": true,
      "price": 3000,                   // 售价（分）— 代理渠道=allocation.retail_price，平台=SuggestedRetailPrice
      "calendar_type": "natural_month",
      "data_reset_cycle": "monthly",
      "enable_realname_activation": true  // 是否需要实名才能买
    }
  ]
}

实现逻辑:
1. 解析标识符 → 获取 card/device
2. 检查 series_id 不为空
3. 确定卖家渠道: card.shop_id > 0 → 代理渠道，否则平台渠道
4. 查询该 series 下所有套餐
5. 过滤:
   - Package.status == 1（启用）← 全局开关
   - 代理渠道: 查 ShopPackageAllocation.shelf_status == 1
   - 平台渠道: 查 Package.shelf_status == 1
6. 价格:
   - 代理渠道: ShopPackageAllocation.retail_price
   - 平台渠道: Package.SuggestedRetailPrice
   - 代理渠道额外校验: retail_price >= cost_price，不满足则该套餐不展示（防止亏损售卖）
7. 加油包前置校验: package_type=addon 的套餐，仅在该资产有至少一个生效中的正式套餐（formal）时才展示
8. 按价格升序排序

参考:
- 上下架逻辑: internal/service/purchase_validation/service.go validatePackages (第109行)
- 代理分配: internal/model/shop_package_allocation.go
- 上下架提案: openspec/changes/archive/2026-03-02-agent-allocation-shelf-status/proposal.md
```

#### B3: 历史套餐列表

```
GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20
认证: 需要 JWT

响应体: 复用 []dto.AssetPackageResponse（见 internal/model/dto/asset_dto.go:79）
包含所有状态的套餐使用记录
自动按资产当前 generation 过滤（仅展示当前世代套餐历史）

参考: internal/service/asset/service.go GetPackages 方法
```

#### B4: 手动刷新

```
POST /api/c/v1/asset/refresh
认证: 需要 JWT

请求体:
{ "identifier": "xxx" }

响应体:
{ "message": "刷新成功" }

实现逻辑:
1. 解析标识符
2. asset_type=card: 调用 iotCardService.RefreshCardDataFromGateway(iccid) — 同步 Gateway 写回 DB
3. asset_type=device: 设备有 Redis 限频（冷却时间），调用设备下所有卡的刷新

参考: internal/service/asset/service.go Refresh 方法, internal/handler/admin/asset.go Refresh
```

### 4.3 模块 C：钱包与充值

#### C1: 钱包详情

```
GET /api/c/v1/wallet/detail?identifier=xxx
认证: 需要 JWT

响应体: 复用 dto.AssetWalletResponse（见 internal/model/dto/asset_wallet_dto.go:6）

实现逻辑:
1. 解析标识符 → resource_type + resource_id
2. 查 AssetWallet WHERE resource_type=? AND resource_id=?
3. 不存在则自动创建空钱包

参考: internal/handler/admin/asset_wallet.go GetWallet
```

#### C2: 钱包流水列表

```
GET /api/c/v1/wallet/transactions?identifier=xxx&page=1&page_size=20&transaction_type=recharge
认证: 需要 JWT

查询参数:
  identifier: string       // 必填
  page: int               // 默认1
  page_size: int           // 默认20，最大100
  transaction_type: string // 可选: recharge/deduct/refund
  start_time: RFC3339      // 可选
  end_time: RFC3339        // 可选

响应体: 复用 dto.AssetWalletTransactionListResponse（见 asset_wallet_dto.go:46）

参考: internal/handler/admin/asset_wallet.go ListTransactions
```

#### C3: 充值预检

```
GET /api/c/v1/wallet/recharge-check?identifier=xxx
认证: 需要 JWT

响应体: 复用 dto.RechargeCheckResponse（见 internal/model/dto/recharge.go:88）
{
  "need_force_recharge": true,
  "force_recharge_amount": 20000,    // 分
  "trigger_type": "first_recharge",
  "min_amount": 100,                  // 最低1元
  "max_amount": 10000000,            // 最高10万
  "current_accumulated": 5000,        // 当前累计
  "threshold": 20000,                // 触发阈值
  "message": "首次购买需充值200元",
  "first_commission_paid": false
}

参考: internal/handler/h5/recharge.go RechargeCheck (将删除，需重建)
      internal/service/recharge/service.go GetRechargeCheck
```

#### C4: 创建充值订单

```
POST /api/c/v1/wallet/recharge
认证: 需要 JWT

请求体:
{
  "identifier": "xxx",           // 资产标识符
  "amount": 20000,               // 充值金额（分）
  "payment_method": "wechat",    // 固定 wechat（客户端只支持微信支付）
  "app_type": "official_account"  // 应用类型: official_account-公众号 miniapp-小程序
}

响应体:
{
  "recharge": {  // dto.RechargeResponse
    "id": 1,
    "recharge_no": "CRCH20260318...",
    "amount": 20000,
    "status": 1,
    ...
  },
  "pay_config": { ... }  // 微信 JSAPI 支付配置（可直接拉起支付）
}

实现逻辑:
1. 解析标识符 → resource_type + resource_id
2. 验证金额范围 (100 ~ 10000000 分)
3. 查找/创建 AssetWallet
4. 创建 AssetRechargeRecord:
   - status=1 待支付
   - payment_config_id=当前生效配置
   - user_id=customer_id, operator_type="personal_customer"（区分后台用户 operator_type="admin_user"）
   - generation=资产当前 generation（写时快照）
5. 根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid
   - 如果找不到对应 app_type 的 OpenID 记录 → 返回错误 { code: OPENID_NOT_FOUND, message: "请在对应平台重新授权登录" }
6. 调用微信支付创建 JSAPI 订单，返回 pay_config

注意: AssetRechargeRecord.user_id + operator_type 配合使用区分操作人身份。
个人客户充值: user_id=customer_id, operator_type="personal_customer"
后台用户充值: user_id=admin_user_id, operator_type="admin_user"

前端提示: 如果是强充场景（充值金额 > 套餐价格），前端应明确提示用户"充值 XX 元，其中 XX 元用于购买套餐，剩余 XX 元将存入钱包余额"

参考:
- 现有 H5 充值: internal/handler/h5/recharge.go Create (将删除)
- 充值服务: internal/service/recharge/service.go
- DTO: internal/model/dto/recharge.go
```

#### C5: 充值订单列表

```
GET /api/c/v1/wallet/recharges?identifier=xxx&page=1&page_size=20
认证: 需要 JWT

响应体: 复用 dto.RechargeListResponse（见 recharge.go:66）

实现逻辑:
1. 解析标识符 → resource_type + resource_id，获取资产当前 generation
2. 查 AssetRechargeRecord WHERE resource_type=? AND resource_id=? AND generation=? ORDER BY created_at DESC
```

### 4.4 模块 D：套餐购买

#### D1: 创建套餐购买订单（核心接口，含强充两阶段处理）

```
POST /api/c/v1/orders/create
认证: 需要 JWT

请求体:
{
  "identifier": "xxx",            // 资产标识符
  "package_ids": [1, 2],          // 套餐ID列表
  "app_type": "official_account"  // 应用类型: official_account-公众号 miniapp-小程序（JSAPI 支付需要）
}

响应体（两种情况）:
// 情况 A: 不需要强充，直接购买
{
  "order_type": "package",
  "order": { ...dto.OrderResponse },
  "pay_amount": 3000,
  "pay_config": { ... }           // 微信 JSAPI 支付配置
}

// 情况 B: 需要强充，创建充值订单
{
  "order_type": "recharge",
  "recharge": { ...dto.RechargeResponse },
  "pay_amount": 20000,            // 强充金额
  "linked_package_info": {        // 关联的套餐信息（前端展示用）
    "package_names": ["月套餐30G"],
    "package_price": 3000
  },
  "pay_config": { ... }
}

实现逻辑（关键流程）:
1. 解析标识符 → card/device + asset_type + asset_id
2. 资产归属校验: 查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=资产虚拟号
   未绑定 → 返回 403 "无权操作该资产"
3. 调用 purchaseValidationService.ValidateCardPurchase / ValidateDevicePurchase
   - 检查 series_id、Package.status、shelf_status
   - 加油包校验: package_type=addon 时，检查该资产是否有生效中的正式套餐
4. 检查实名: 如果套餐 enable_realname_activation=true 且卡 real_name_status=0
   → 返回错误 { code: NEED_REALNAME, need_realname: true }
5. 根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid
   找不到 → 返回错误 { code: OPENID_NOT_FOUND, message: "请在对应平台重新授权登录" }
6. 幂等性检查（Redis 业务键 + 分布式锁）
7. 检查强充 checkForceRechargeRequirement（见 order/service.go:2216）
8. 分流:
   a. 不需要强充 → 创建 Order（generation=资产当前generation, source="client"） + 拉起微信支付
   b. 需要强充:
      pay_amount = max(force_amount, package_total_price)
      创建 AssetRechargeRecord（linked_package_ids=package_ids, generation=资产当前generation）
      拉起微信支付

强充触发条件（详见 order/service.go:2216 checkForceRechargeRequirement）:
- 一次性佣金未启用 → 不强充
- 首充模式 + 该系列已触发 → 不强充
- 首充模式 + 未触发 → 强充额 = threshold
- 累计模式 + 该系列已触发 → 不强充
- 累计模式 + 平台启用强充 → 强充额 = config.ForceAmount (fixed) 或 threshold-已累计 (dynamic)
- 累计模式 + 平台未启用 + 代理启用 → 用代理的 ForceRechargeAmount

强充回调两阶段处理（重要设计变更）:
充值支付成功后，采用「先入账，再异步购买」的两阶段方案：

**第一阶段（同步，在支付回调事务内）**:
1. 钱入钱包（余额增加）
2. 更新充值单状态为已完成
3. 更新累计/首充状态
4. 检查一次性佣金触发（固定模式或梯度模式）
→ 保证用户资金安全：即使后续步骤失败，钱已在钱包中

**第二阶段（异步，通过 Asynq 任务）**:
5. 入队异步任务: AutoPurchaseAfterRecharge（recharge_record_id）
6. 异步任务执行：
   a. 从钱包扣款（payment_method=wallet，扣除套餐总价）
   b. 创建套餐购买订单（Order，source="client", generation=资产当前generation）
   c. 激活套餐
7. 如果异步任务失败（如 Gateway 超时）：
   a. 自动重试（Asynq 默认重试机制，最多 3 次）
   b. 重试全部失败后：标记 AssetRechargeRecord.auto_purchase_status = "failed"
   c. 客户端查询充值详情时提示"自动购买失败，请手动购买套餐"
   d. 钱已在钱包中，用户可手动操作

设计原因: 原方案将充值入账和套餐购买放在同一事务中，如果套餐激活失败（Gateway 超时等），
会导致整个事务回滚，充值也不入账。微信回调重试次数有限（约 15 次），持续失败会导致
用户资金丢失。两阶段方案保证资金安全，异步购买失败后用户可手动操作。

注意: 充值回调存在已知事务一致性 BUG（状态更新在事务外执行），需在本次开发中一并修复。
详见 recharge/service.go:308-321，需将 UpdateStatusWithOptimisticLock 和 UpdatePaymentInfo
改为使用事务内的 tx 而非 s.db。

幂等性保证: 充值回调可能被第三方重复通知。通过 AssetRechargeRecord 的状态条件更新
（WHERE status = 待支付）确保重复回调不会重复执行。

一次性佣金梯度模式（已实现于 commission_calculation/service.go:451）:
- config.CommissionType == "tiered" && len(config.Tiers) > 0
- 按 sales_count/sales_amount 维度匹配阈值
- 支持 self/self_and_sub 统计范围
- 按链式分配规则分佣

参考:
- 现有 H5 创建订单: internal/service/order/service.go:632 CreateH5Order
- 强充检查: internal/service/order/service.go:2216 checkForceRechargeRequirement
- 梯度佣金: internal/service/commission_calculation/service.go:451
- 购买验证: internal/service/purchase_validation/service.go
```

#### D2: 套餐订单列表

```
GET /api/c/v1/orders?identifier=xxx&page=1&page_size=20&payment_status=2
认证: 需要 JWT

查询参数: 参考 dto.OrderListRequest (order_dto.go:22)
  增加 identifier 筛选（转为 iot_card_id/device_id 查询）
  自动按资产当前 generation 过滤（仅展示当前世代订单）

响应体: dto.OrderListResponse (order_dto.go:84)
```

#### D3: 套餐订单详情

```
GET /api/c/v1/orders/:id
认证: 需要 JWT

响应体: dto.OrderResponse (order_dto.go:47)

实现逻辑:
1. 从 JWT 获取 customer_id
2. 查询 Order WHERE id = :id
3. 归属校验: 通过 order 的 iot_card_id/device_id 查找资产，验证当前客户是否绑定了该资产
   （查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=资产虚拟号）
4. 未绑定则返回 403
```

### 4.5 模块 E：实名

#### E1: 获取实名跳转链接

```
GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx
认证: 需要 JWT

查询参数:
  identifier: string  // 必填。资产标识符
  iccid: string       // 可选。设备资产时指定具体哪张卡去实名

响应体:
{
  "url": "https://realname.carrier.com/xxx",
  "carrier_name": "中国联通"
}

两个入口:
A. 购买套餐被拦截(NEED_REALNAME) → 前端弹窗引导 → 调此接口
B. 设备卡列表主动选择某张未实名的卡 → 传 iccid 参数

实现逻辑:
1. 解析标识符 → 确定目标卡:
   - 直接是卡 → 用该卡
   - 是设备 + 传了 iccid → 查该 iccid 对应的卡
   - 是设备 + 没传 iccid → 查 DeviceSimBinding 中 isActive=1 的卡
2. 检查 card.real_name_status == 1 → "该卡已完成实名"
3. 查 Carrier WHERE id=card.carrier_id → 获取 realname_link_type:
   - 'none' → "该运营商暂不支持在线实名"
   - 'template' → 替换占位符 {iccid}/{msisdn}/{virtual_no} → 返回 URL
   - 'gateway' → 调 gateway.GetRealnameLink(card.ICCID) → 返回 URL

参考: internal/routes/iot_card.go:111 GetRealnameLink 路由
      internal/gateway/flow_card.go:44 GetRealnameLink
```

### 4.6 模块 F：设备能力

**通用前置**: 解析标识符 → 必须是设备类型 → 设备必须有 IMEI

#### F1: 设备卡列表

```
GET /api/c/v1/device/cards?identifier=xxx
认证: 需要 JWT

响应体:
{
  "device_id": 1,
  "device_name": "GPS追踪器",
  "max_sim_slots": 4,
  "cards": [
    {
      "card_id": 101,
      "iccid": "89860...",
      "msisdn": "1064...",
      "carrier_name": "中国联通",
      "network_status": 1,         // 0-停机 1-开机
      "real_name_status": 0,       // 0-未实名 1-已实名
      "slot_position": 1,
      "is_active": true
    }
  ]
}

实现逻辑:
1. 解析标识符 → 设备
2. 查 DeviceSimBinding WHERE device_id=? AND bind_status=1 → 获取所有卡ID
3. 批量查 IotCard → 获取 iccid, msisdn, network_status, real_name_status
4. 关联 Carrier → 获取 carrier_name
5. 全部从 CMP 数据库查，不调 Gateway

参考: internal/model/device_sim_binding.go
```

#### F2-F5: 设备能力接口

```
POST /api/c/v1/device/reboot        → gateway.RebootDevice(imei)
POST /api/c/v1/device/factory-reset  → gateway.ResetDevice(imei)
POST /api/c/v1/device/wifi           → gateway.SetWiFi(imei, ssid, password, enabled)
// 注: Gateway WiFiReq 的 cardNo 字段实际传入设备 IMEI，由后端从设备信息获取
POST /api/c/v1/device/switch-card    → gateway.SwitchCard(imei, target_iccid)

请求体统一:
{
  "identifier": "xxx",          // 资产标识符（设备）
  // WiFi 额外参数:
  "ssid": "MyWiFi",
  "password": "12345678",
  "enabled": true,
  // 切卡额外参数:
  "target_iccid": "89860..."
}

参考: internal/gateway/device.go（所有 Gateway 方法），internal/gateway/models.go（DTO）
```

### 4.7 模块 G：换货（客户端）

#### G1: 查询换货通知

```
GET /api/c/v1/exchange/pending?identifier=xxx
认证: 需要 JWT

响应体:
{
  "has_pending": true,
  "exchange": {
    "id": 1,
    "exchange_no": "EXC20260318...",
    "old_identifier": "V001",
    "status": 1,                   // 1-待填写信息 3-已完成等
    "status_text": "待填写收货信息",
    "exchange_reason": "设备故障",
    "express_company": "",          // 发货后才有
    "express_no": "",
    "created_at": "..."
  }
}

实现逻辑:
1. 解析标识符 → asset_type + asset_id
2. 查 ExchangeOrder WHERE old_asset_type=? AND old_asset_id=? AND status IN (1,2,3) ORDER BY created_at DESC LIMIT 1
```

#### G2: 填写收货信息

```
POST /api/c/v1/exchange/:id/shipping-info
认证: 需要 JWT

请求体:
{
  "recipient_name": "张三",
  "recipient_phone": "13800138000",
  "recipient_address": "北京市朝阳区XXX"
}

实现逻辑:
1. 查 ExchangeOrder，验证 status == 1
2. 更新收货信息，status → 2（待发货）
```

### 4.8 模块 H：后台换货管理

#### H1: 发起换货

```
POST /api/admin/exchanges
请求体: { old_asset_type, old_asset_id (或 old_identifier), exchange_reason, remark }
验证: 该资产无进行中的换货单
创建 ExchangeOrder status=1
```

#### H2: 换货列表

```
GET /api/admin/exchanges?status=1&page=1&page_size=20
支持状态筛选、资产标识符搜索、时间范围
```

#### H3: 换货详情

```
GET /api/admin/exchanges/:id
```

#### H4: 发货

```
POST /api/admin/exchanges/:id/ship
请求体: { express_company, express_no, new_identifier, migrate_data: true/false }
验证: status == 2
验证: new_asset_type == old_asset_type（只能同类型资产更换，卡换卡/设备换设备）
解析 new_identifier → 新资产（新资产需已导入系统，asset_status=1 在库状态）
记录物流信息, shipped_at=now
status → 3（已发货待确认）
```

#### H5: 确认完成

```
POST /api/admin/exchanges/:id/complete
验证: status == 3（已发货待确认状态）

如果 migrate_data == true，执行全量迁移（事务内）:
1. 钱包余额转移: 旧 AssetWallet → 新 AssetWallet
2. 生效中套餐关联到新资产 (PackageUsage.iot_card_id/device_id)
3. 累计充值/首充触发状态迁移到新资产
4. 标签复制 (ResourceTag)
5. 设备卡绑定迁移 (DeviceSimBinding)
6. 个人客户设备绑定更新 (PersonalCustomerDevice)
7. 旧资产 asset_status → 3（已换货）

全量迁移涉及的 11 张数据表:
| 表 | 操作 |
|----|------|
| tb_asset_wallet | 余额转移 |
| tb_asset_wallet_transaction | 新增迁移流水 |
| tb_asset_recharge_record | 保留原记录 |
| tb_package_usage | 生效套餐关联新资产 |
| tb_package_usage_daily_record | 跟随 PackageUsage |
| tb_order | 保留原记录不修改 |
| tb_commission | 保留原记录不修改 |
| tb_data_usage_record | 保留原记录不修改 |
| tb_resource_tag | 复制到新资产 |
| tb_personal_customer_device | 更新 virtual_no |
| tb_iot_card/tb_device | 累计充值/首充状态迁移 |

**注意**: 设备换设备时不迁移 `DeviceSimBinding`（卡绑定关系）。新设备自带新的 SIM 卡，旧设备的卡绑定关系保持不变（随旧资产保留）。

status → 4（已完成）
```

#### H6: 取消换货

```
POST /api/admin/exchanges/:id/cancel
验证: status IN (1, 2)（已发货后不可取消）
status → 5（已取消）
```

#### H7: 旧资产转新

```
POST /api/admin/exchanges/:id/renew
说明: 换货完成后，旧资产被标记为"已换货"。转新后可重新销售给新客户。

实现:
1. 验证旧资产 asset_status == 3（已换货）
2. 重置 asset_status = 1（在库），不修改 network_status（运营商网络状态由 Gateway 决定）
3. 旧资产 generation 字段自增 1（generation = generation + 1）
4. 清除: 累计充值状态、首充触发状态、个人客户绑定（PersonalCustomerDevice）
5. 创建新的空钱包（新 generation 对应新 wallet，通过 wallet_id 天然隔离流水）
6. 不删除历史数据（订单、充值、流水均保留，通过 generation 字段隔离）
7. 新客户使用该资产时，客户端查询自动按当前 generation 过滤，看不到旧周期数据
8. 后台管理可查看全部历史（不受 generation 过滤）
```

---

## 五、删除的旧接口

| 文件 | 路由 | 处理 |
|------|------|------|
| `internal/handler/h5/auth.go` | `/api/h5/login` 等 | 删除 |
| `internal/handler/h5/order.go` | `/api/h5/orders/*` | 删除 |
| `internal/handler/h5/recharge.go` | `/api/h5/wallets/*` | 删除 |
| `internal/handler/h5/package_usage.go` | `/api/h5/package-usage/*` | 删除 |
| `internal/handler/h5/enterprise_device.go` | `/api/h5/enterprise-devices/*` | 删除 |
| `internal/routes/h5.go` | H5 路由注册 | 删除 |
| `internal/routes/h5_enterprise_device.go` | | 删除 |
| `internal/routes/h5_package_usage.go` | | 删除 |
| `internal/handler/app/personal_customer.go` | `/api/c/v1/login` `/api/c/v1/login/send-code` 等 | 删除旧手机号+验证码登录（Login, SendCode）、旧微信绑定（WechatOAuthLogin, BindWechat）、旧 Profile 接口。新体系在模块 A 重新设计 |

同步清理: bootstrap 中 H5 Handler 注册、docs.go/gendocs 中引用、handler/app/personal_customer.go 中旧登录/绑定接口。

---

## 六、完整业务流程图

（流程图同之前版本，已包含在第四节各接口的实现逻辑中。关键流程图参见：
- 登录流程: 见 A2 实现逻辑
- 套餐购买含强充: 见 D1 实现逻辑
- 换货全量迁移: 见 H5 实现逻辑
- 实名跳转: 见 E1 实现逻辑
- 设备能力: 见 F1-F5）
- 实名闭环说明: 运营商实名为外部流程，完成后不会回调本系统。用户需通过 B4 手动刷新确认实名状态后重新提交购买。部分运营商实名为异步生效（几分钟到几小时），前端应提示用户"实名可能需要一定时间生效，请稍后刷新重试"。

---

## 七、提案拆分建议

| 提案 | 内容 | 涉及主要文件 |
|------|------|-------------|
| **提案 0: 数据模型修复 + 基础字段** | BUG-1 代理零售价修复（含 validatePackages 价格计算 + cost_price 分配锁定）、BUG-2 一次性佣金修复（Order.source + 双重判断）、BUG-4 充值回调事务修复、Carrier 实名链接配置（realname_link_type/template）、IotCard/Device 新增 asset_status 字段、Order/PackageUsage/AssetRechargeRecord 新增 generation 字段、AssetRechargeRecord 新增 operator_type 字段、删除旧 H5 接口和旧登录接口 | model/shop_package_allocation.go, model/carrier.go, model/order.go, model/iot_card.go, model/device.go, service/purchase_validation/, service/commission_calculation/, service/recharge/, handler/h5/, routes/h5*.go |
| **提案 1: 认证系统** | PersonalCustomerOpenID 模型、PersonalCustomer.wx_open_id 索引变更、asset_token 机制（A1）、公众号/小程序登录（A2/A3 含 asset_token 验证）、手机号绑定/换绑（A5/A6 含重复绑定校验）、退出登录（有状态 JWT + Redis）、统一标识符入参公共方法、openid 安全规范（后端查询）、require_phone_binding 配置文件 | model/personal_customer_openid.go, handler/app/, service/personal_customer/, middleware/personal_auth.go |
| **提案 2: 客户端核心业务接口** | 资产信息（B1）、可购买套餐（B2 含加油包校验 + 零售价倒挂下架）、钱包/充值（C4 含 operator_type + OpenID 校验 + generation 快照）、套餐购买（D1 含两阶段强充 + 归属校验 + generation）、订单列表/详情（D2/D3 含 generation 过滤 + 归属校验）、实名跳转、设备能力（WiFi cardNo=IMEI）、手动刷新 | handler/app/, service/order/, service/recharge/, service/asset/, gateway/ |
| **提案 3: 换货系统** | ExchangeOrder 模型（状态机: 1→2→3→4，含 3-已发货待确认）、后台换货管理（CRUD+发货→status=3+确认验证status=3+取消+转新用 asset_status）、客户端换货通知/填写收货、全量迁移逻辑（设备不迁移卡绑定）、同类型资产校验、generation 自增（转新时）、旧 CardReplacementRecord 替换 | model/exchange_order.go, handler/admin/exchange.go, handler/app/exchange.go, service/exchange/, model/iot_card.go, model/device.go |
