新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO: - 客户端资产信息查询、套餐列表、套餐历史、资产刷新 - 客户端钱包详情、流水、充值校验、充值订单、充值记录 - 客户端订单创建、列表、详情 - 客户端实名认证链接获取 - 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡 - 客户端订单服务(含微信/支付宝支付流程) - 强充自动代购异步任务处理 - 数据库迁移 000084:充值记录增加自动代购状态字段
50 KiB
客户端(H5/小程序)接口需求完整说明
文档目的: 本文档是客户端接口开发的唯一权威参考。新的 AI session 或开发者应基于此文档进行实现,无需重新探索需求。
〇、原始需求(用户原话摘要)
背景
系统之前由于疏忽在提案中做了几个 H5 接口(/api/h5),这些接口使用的是 B 端认证体系,不适用于个人客户场景。现在需要删除这些旧接口,重新设计一套面向个人客户(C 端)的完整客户端接口体系。
客户端形态
- 微信公众号 H5
- 微信小程序
- 两者可能使用不同的 AppID,不一定绑定同一个微信开放平台
登录流程
用户有两种路径进入客户端:
- 扫码进入:通过微信扫一扫扫描特定二维码,带参数跳转进前端(扫码是前端行为,后端无感)
- 手动输入:直接打开客户端,输入 SN/IMEI/虚拟号/ICCID 等资产标识符
登录是一种"假登录",基于资产而非基于用户身份。任何人拿到资产标识符都可以登录,即使该资产已被别人绑定过。
登录流程顺序:先输入资产标识符 → 再走微信授权 → 检查是否需要绑定资产 → 检查是否需要绑定手机号(是否强制绑定手机号在代码中可配置)
功能需求清单
- 登录注册:资产标识符输入 → 微信授权(公众号/小程序)→ 资产绑定 → 手机号绑定(可配置是否强制)
- 资产信息展示:基本信息、套餐信息、流量信息(支持卡资产和设备资产两种类型)
- 可购买套餐列表:按价格排序。代理渠道展示代理设定的零售价,平台渠道展示建议零售价。平台禁用的套餐全渠道不展示,代理下架仅影响该代理
- 钱包详情 + 流水列表
- 充值接口:用户输入金额 → 微信支付充值
- 充值订单列表
- 套餐购买:拉起微信支付。购买前必须实名。有强充逻辑(不应让前端决定创建充值还是套餐订单,后端统一处理)。涉及一次性佣金的首充/累计充值/梯度模式
- 套餐订单列表 + 详情
- 历史套餐列表
- 实名跳转:两个入口——购买套餐被拦截后引导、设备卡列表主动选择。运营商实名链接两种方式:模板 URL 和 Gateway 接口
- 手动刷新卡状态/实名状态
- 设备卡列表:含卡状态、实名状态、运营商名称
- 设备能力:重启/恢复出厂/设置 WiFi/切卡
- 换货系统:后台发起换货 → 客户端收到通知填写收货信息 → 后台发货+全量迁移 → 手动确认完成。旧资产可"转新"重新销售
- 换绑手机号
关键设计决策
- 所有接口挂载在
/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.SuggestedRetailPriceinternal/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 事务内统一使用 txinternal/store/postgres/asset_recharge_store.go— UpdateStatusWithOptimisticLock / UpdatePaymentInfo 支持传入 tx
BUG-3:换卡模型不满足换货需求
现状:CardReplacementRecord 仅支持卡换卡,缺少收货地址、快递信息、设备换货、全量迁移等功能。
修复:新建 ExchangeOrder 模型替代,删除旧模型。
当前引用旧模型的文件(仅 3 处,替换成本低):
internal/model/card_replacement.go— 删除internal/model/system.go— 移除 AutoMigrateinternal/store/postgres/iot_card_store.go—is_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 行- 先查 Device(virtual_no/imei/sn),再查 IotCard(virtual_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 - 中间件:
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 复制到关联记录上(写时快照):
// 创建订单时
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)
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 |