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

50 KiB
Raw Blame History

客户端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.goGetPurchasePrice() 改为:代理渠道查 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.goCreateAdminOrder 设置 Source: "admin",客户端订单创建设置 Source: "client"

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

现状recharge/service.go:308-321HandlePaymentCallback 中,UpdateStatusWithOptimisticLockUpdatePaymentInfo 使用 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.gois_replaced 过滤改为查新表

二、数据库变更

2.1 新增模型

系统配置 — 使用配置文件(非数据库表)

客户端行为开关(如 require_phone_binding)通过 Viper 配置文件管理,不单独建表。

配置项(在 config.yaml 中):

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

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

tb_personal_customer_openid — 个人客户 OpenID 关联表

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 — 换货单模型

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/ 中):

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

3.2 认证方式

  • 路由域:/api/c/v1
  • 中间件:PersonalAuthMiddlewareJWT
  • 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_typeofficial_accountminiapp)标识当前使用的应用类型
  • 后端根据 customer_id + app_typePersonalCustomerOpenID 表查询对应的 OpenID
  • 防止客户端伪造 OpenID 拉起他人支付

3.6 资产世代generation机制

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

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

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

在创建记录时,从资产的当前 generation 复制到关联记录上(写时快照):

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

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

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

客户端查询时按 generation 过滤:

-- 示例: 查询当前世代的订单
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

IotCardDevice 新增 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