Files
junhong_cmp_fiber/docs/client-api-requirements/需求说明.md
huang 9bd55a1695 feat: 实现客户端核心业务接口(client-core-business-api)
新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO:
- 客户端资产信息查询、套餐列表、套餐历史、资产刷新
- 客户端钱包详情、流水、充值校验、充值订单、充值记录
- 客户端订单创建、列表、详情
- 客户端实名认证链接获取
- 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡
- 客户端订单服务(含微信/支付宝支付流程)
- 强充自动代购异步任务处理
- 数据库迁移 000084:充值记录增加自动代购状态字段
2026-03-19 13:28:04 +08:00

1215 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客户端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 行
- 先查 Devicevirtual_no/imei/sn再查 IotCardvirtual_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_tokenJWTpayload: { 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. 从当前生效的 WechatConfigtb_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. 不需要强充 → 创建 Ordergeneration=资产当前generation, source="client" + 拉起微信支付
b. 需要强充:
pay_amount = max(force_amount, package_total_price)
创建 AssetRechargeRecordlinked_package_ids=package_ids, generation=资产当前generation
拉起微信支付
强充触发条件(详见 order/service.go:2216 checkForceRechargeRequirement:
- 一次性佣金未启用 → 不强充
- 首充模式 + 该系列已触发 → 不强充
- 首充模式 + 未触发 → 强充额 = threshold
- 累计模式 + 该系列已触发 → 不强充
- 累计模式 + 平台启用强充 → 强充额 = config.ForceAmount (fixed) 或 threshold-已累计 (dynamic)
- 累计模式 + 平台未启用 + 代理启用 → 用代理的 ForceRechargeAmount
强充回调两阶段处理(重要设计变更):
充值支付成功后,采用「先入账,再异步购买」的两阶段方案:
**第一阶段(同步,在支付回调事务内)**:
1. 钱入钱包(余额增加)
2. 更新充值单状态为已完成
3. 更新累计/首充状态
4. 检查一次性佣金触发(固定模式或梯度模式)
→ 保证用户资金安全:即使后续步骤失败,钱已在钱包中
**第二阶段(异步,通过 Asynq 任务)**:
5. 入队异步任务: AutoPurchaseAfterRechargerecharge_record_id
6. 异步任务执行:
a. 从钱包扣款payment_method=wallet扣除套餐总价
b. 创建套餐购买订单Ordersource="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.goDTO
```
### 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 字段自增 1generation = 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 |