fix: 修正零售价架构错误 + 清理旧微信配置 + 归档提案 + 前端接口文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s

1. 修正 retail_price 架构:
   - 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
     (上级只能改下级成本价,不能改零售价)
   - 新增 PATCH /api/admin/packages/:id/retail-price 接口
     (代理自己改自己的零售价,校验 retail_price >= cost_price)

2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
   - 删除 config.yaml 中 wechat.official_account 配置节
   - 删除 NewOfficialAccountApp() 旧工厂函数
   - 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
   - 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释

3. 归档四个已完成提案到 openspec/changes/archive/

4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)

5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
This commit is contained in:
2026-03-19 17:39:43 +08:00
parent 9bd55a1695
commit b9733c4913
98 changed files with 3665 additions and 571 deletions

View File

@@ -15,7 +15,7 @@
- 所有字段新增使用 `NOT NULL DEFAULT` 确保存量数据兼容
- 数据库迁移可在线执行,不需停机
- 旧接口删除后 bootstrap、路由注册、文档生成器必须同步清理否则编译失败
- 本提案不涉及任何新 API 接口,纯粹是模型/字段/BUG 修复
- 本提案新增 1 个后台接口:`PATCH /api/admin/packages/:id/retail-price`(代理修改自己的零售价)
## Goals / Non-Goals
@@ -36,8 +36,8 @@
- 不实现任何客户端 API 接口(属于提案 1~3
- 不实现 ExchangeOrder 换货模型(属于提案 3
- 不实现 PersonalCustomerOpenID 模型(属于提案 1
- 不修改后台管理界面或 Admin API
- 不新增 API 路由
- 不修改后台管理界面
- `PATCH /api/admin/packages/:id/retail-price` 外,不新增其他 API 路由
- 不实现 asset_status 的状态流转逻辑(仅新增字段,流转逻辑在后续提案中实现)
## Decisions

View File

@@ -10,7 +10,7 @@
### BUG 修复
- **BUG-1 代理零售价修复**`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice``validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price**扩展现有 `BatchUpdatePricing` 接口支持 `retail_price` 调整**(新增 `pricing_target` 字段区分调 cost_price 还是 retail_price默认 `cost_price` 保持向后兼容)**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价)
- **BUG-1 代理零售价修复**`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice``validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price`BatchUpdatePricing` 接口支持成本价批量调整;新增独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己的零售价**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价)
- **BUG-2 一次性佣金触发修复**`Order` 新增 `source` 字段(`admin`/`client`);佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,确保只有客户端个人客户购买才触发
- **BUG-4 充值回调事务修复**`HandlePaymentCallback``UpdateStatusWithOptimisticLock``UpdatePaymentInfo``s.db.WithContext(ctx)` 改为事务内 `tx`,确保充值单状态变更和钱包入账原子完成
@@ -37,7 +37,7 @@
- `asset-lifecycle-status`资产业务生命周期状态管理。IotCard/Device 新增 `asset_status` 字段(在库→已销售→已换货→已停用),定义状态流转规则,与运营商 `network_status` 完全独立
- `asset-generation`资产世代机制。IotCard/Device 的 `generation` 字段关联表Order/PackageUsage/AssetRechargeRecord的 generation 写时快照规则,客户端按世代过滤、后台不过滤的查询规则
- `carrier-realname-config`运营商实名链接配置。Carrier 模型新增 `realname_link_type`/`realname_link_template` 字段,支持 none/template/gateway 三种模式URL 模板占位符替换。**Carrier admin DTO 同步更新**,后台可通过现有运营商管理接口配置实名链接
- `agent-retail-price`代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`cost_price 分配锁定规则。**扩展 `BatchUpdatePricing` 接口**支持 `pricing_target=retail_price` 批量调整零售价;**代理套餐列表展示 retail_price****利润计算修正**为 `RetailPrice - CostPrice`
- `agent-retail-price`代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`cost_price 分配锁定规则。新增独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己的零售价;**代理套餐列表展示 retail_price****利润计算修正**为 `RetailPrice - CostPrice`
- `asset-manual-deactivation`:资产手动停用。新增后台接口 `PATCH /api/admin/iot-cards/:id/deactivate``PATCH /api/admin/devices/:id/deactivate`,将 `asset_status` 设为 4已停用`asset_status=1`(在库)或 `asset_status=2`(已销售)时可操作
- `h5-legacy-cleanup`:旧 H5 接口和旧登录接口的完整删除,包括 handler、route、bootstrap 注册、文档生成器引用的清理
@@ -45,7 +45,7 @@
- `package-purchase-validation``GetPurchasePrice()` 价格来源改为按渠道区分代理→retail_price平台→SuggestedRetailPrice`validatePackages()` 价格累加逻辑同步修正
- `package-list`:代理查询套餐列表时,`PackageResponse` 新增 `retail_price` 字段;`ProfitMargin` 计算从 `SuggestedRetailPrice - CostPrice` 改为 `RetailPrice - CostPrice`
- `batch-pricing``BatchUpdatePricing` 接口扩展支持 `pricing_target` 参数(`cost_price`/`retail_price`),默认 `cost_price` 保持向后兼容retail_price 调整时校验 `>= cost_price`
- `batch-pricing``BatchUpdatePricing` 接口支持 `cost_price` 批量调整;保留 `cost_price` 锁定校验(存在下级分配时不可修改)
- `one-time-commission-trigger`:触发条件增加 `order.Source == "client"` 判断,确保仅客户端个人客户购买才触发
- `wallet-recharge``HandlePaymentCallback` 事务一致性修复Store 方法支持传入事务 `tx`
- `iot-order`Order 模型新增 `source`(订单来源)和 `generation`(世代)字段;`CreateAdminOrder()` 创建订单时从资产快照当前 `generation` 写入订单(而非依赖默认值 1
@@ -57,8 +57,8 @@
## Impact
- **模型文件**`shop_package_allocation.go``carrier.go``order.go``iot_card.go``device.go``package_usage.go``asset_recharge_record.go``personal_customer.go`
- **Service 文件**`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`扩展支持 retail_price + cost_price 锁定)、`shop_series_grant/service.go`cost_price 锁定)、`order/service.go`source 设置 + generation 快照)、`package/service.go`(利润计算修正 + PackageResponse 新增 retail_price
- **Handler/DTO 文件**`shop_package_batch_pricing.go` Handler扩展)、`shop_package_batch_pricing_dto.go`新增 `pricing_target` 字段)、`package_dto.go``PackageResponse` 新增 `retail_price`)、`carrier_dto.go`(新增实名链接字段)
- **Service 文件**`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`仅成本价批量调价 + cost_price 锁定)、`shop_series_grant/service.go`cost_price 锁定)、`order/service.go`source 设置 + generation 快照)、`package/service.go`新增代理改零售价接口逻辑 + 利润计算修正 + PackageResponse 新增 retail_price
- **Handler/DTO 文件**`shop_package_batch_pricing.go` Handler仅保留成本价批量调价)、`shop_package_batch_pricing_dto.go`移除 `pricing_target` 字段)、`package.go` Handler新增 `PATCH /packages/:id/retail-price`)、`package_dto.go``PackageResponse` 新增 `retail_price` + 新增更新零售价请求 DTO)、`carrier_dto.go`(新增实名链接字段)
- **Store 文件**`asset_recharge_store.go`(支持事务传入)
- **删除文件**`internal/handler/h5/` 全部5 个文件)、`internal/routes/h5*.go`3 个文件)、`internal/handler/app/personal_customer.go` 中旧方法
- **数据库迁移**7 张表共 15+ 个字段变更1 个索引变更

View File

@@ -22,18 +22,12 @@
### Requirement: 零售价约束规则
系统 MUST 强制校验:`retail_price >= cost_price``retail_price <= suggested_retail_price * 2`
系统 MUST 强制校验:`retail_price >= cost_price`
#### Scenario: 零售价低于成本价
- **WHEN** 代理设置 `retail_price < cost_price`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
#### Scenario: 零售价超过建议价两倍
- **WHEN** 代理设置 `retail_price > suggested_retail_price * 2`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
---
### Requirement: 成本价分配锁定规则
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`
@@ -46,7 +40,7 @@
### Requirement: 代理零售价可调与存量迁移
系统 MUST 允许代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price`代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
#### Scenario: 代理调整自己的零售价
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束

View File

@@ -1,6 +1,6 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 资产充值表结构变更
### Requirement: 资产充值记录扩展字段(操作人与代际)
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段:

View File

@@ -1,4 +1,4 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 设备实体定义

View File

@@ -1,8 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: IoT 卡实体定义
### Requirement: IoT 卡资产生命周期字段
系统 SHALL 在 `IotCard` 模型新增以下字段:
系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`

View File

@@ -1,8 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 订单实体定义
### Requirement: 订单来源与代际字段
系统 SHALL 定义订单Order实体新增来源与代际字段:
系统 SHALL 订单Order实体新增来源与代际字段
- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client`
- `generation int NOT NULL DEFAULT 1`

View File

@@ -1,8 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 获取购买价格
### Requirement: 代理渠道购买价格规则
系统 MUST 根据购买渠道返回正确的购买价格。
系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道使用分配零售价
- **WHEN** 客户通过代理渠道购买套餐

View File

@@ -1,4 +1,4 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 微信标识索引策略

View File

@@ -1,10 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 充值支付回调处理
### Requirement: 充值回调事务一致性
系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额
关键一致性修复:`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行。
`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行,保证充值状态与支付信息的原子性
#### Scenario: 回调处理中状态更新与支付信息更新同事务
- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback`

View File

@@ -42,11 +42,15 @@
## 5. 代理零售价后台管理
- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go``BatchUpdateCostPriceRequest`新增 `PricingTarget string` 字段(`json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"`),不传时默认为 `cost_price` 保持向后兼容
- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法:根据 `req.PricingTarget` 分流——`cost_price` 走现有逻辑(含 4.5 的锁定检查)`retail_price` 走新逻辑:计算新零售价、校验 `newRetailPrice >= allocation.CostPrice`(不满足则跳过并报错"零售价不能低于成本价")、更新 `allocation.RetailPrice`、记录价格历史(`ShopPackageAllocationPriceHistory` 增加 `old_retail_price`/`new_retail_price` 字段或复用 `OldCostPrice`/`NewCostPrice` 字段并新增 `price_type` 标识)
- [x] 5.3 修改 `internal/model/dto/package_dto.go` `PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
- [x] 5.4 修改 `internal/service/package/service.go``toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice`
- [x] 5.5 修改 `internal/service/package/service.go` `toResponseWithAllocation()` 方法(约第 595-603 行):同 5.4,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算
- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go``BatchUpdateCostPriceRequest`删除 `PricingTarget` 字段,批量调价接口仅保留成本价路径
- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法:删除 `retail_price` 分支与默认分流逻辑,仅保留 `cost_price` 调整(包含 4.5 的锁定检查)
- [x] 5.3 `internal/model/dto/package_dto.go` 新增 `UpdateRetailPriceRequest``UpdateRetailPriceParams`,用于代理修改自己零售价
- [x] 5.4 `internal/store/postgres/shop_package_allocation_store.go` 新增 `UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error`
- [x] 5.5 `internal/service/package/service.go` 新增 `UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error`:仅代理可调用、校验 `retail_price >= cost_price`
- [x] 5.6 在 `internal/handler/admin/package.go` 新增 `UpdateRetailPrice`,并在 `internal/routes/package.go` 注册 `PATCH /api/admin/packages/:id/retail-price`
- [x] 5.7 修改 `internal/model/dto/package_dto.go``PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
- [x] 5.8 修改 `internal/service/package/service.go``toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice`
- [x] 5.9 修改 `internal/service/package/service.go``toResponseWithAllocation()` 方法(约第 595-603 行):同 5.8,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算
## 6. Carrier 管理 DTO 更新
@@ -102,6 +106,6 @@
- [x] 13.2 对所有修改的文件执行 `lsp_diagnostics` 确认无错误和警告
- [x] 13.3 使用 PostgreSQL MCP 工具验证数据库:确认 7 张表的新字段存在、默认值正确、存量 `retail_price` 已填充、`wx_open_id` 索引已变更
- [x] 13.4 验证删除的 H5 路由不再注册:检查代码中无 `/api/h5` 相关路由残留
- [x] 13.5 验证 `BatchUpdatePricing` 接口扩展:确认 `pricing_target=retail_price` 参数可正常使用,不传时默认走 `cost_price` 逻辑(向后兼容)
- [x] 13.5 验证 `BatchUpdatePricing` 接口仅支持成本价调整;并验证 `PATCH /api/admin/packages/:id/retail-price` 可供代理修改自己的零售价
- [x] 13.6 验证代理套餐列表:确认 `PackageResponse` 包含 `retail_price` 字段,`profit_margin` 计算基于 `retail_price - cost_price`
- [x] 13.7 撰写功能总结文档 `docs/client-api-data-model-fixes/功能总结.md`,记录所有变更内容

View File

@@ -1,6 +1,6 @@
# personal-customer Specification
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 个人客户登录主流程改为微信授权

View File

@@ -1,6 +1,6 @@
# wechat-official-account Specification
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 微信配置源从 YAML 改为数据库动态读取

View File

@@ -0,0 +1,177 @@
# 设计文档client-core-business-api
## Context
认证系统(提案 1就绪后客户端需要一套完整业务接口以覆盖资产查询、钱包充值、套餐购买、实名跳转与设备操作。当前后台接口的 Service 层大部分能力可复用,但客户端场景存在以下关键差异:
1. 个人客户访问资源不应受 shop_id 数据权限过滤影响,需要在调用链中显式绕过。
2. 资产操作必须先做归属校验(绑定关系),避免跨客户操作。
3. 历史数据查询需要按资产当前 generation 过滤,避免展示转手前历史。
4. 支付 OpenID 必须后端查表获取,禁止客户端传入,降低伪造风险。
现有代码参考(复用/改造基线):
- `asset.Service.Resolve()``internal/service/asset/service.go:71`
- `asset.Service.Refresh()``internal/service/asset/service.go:295`
- `asset.Service.GetPackages()``internal/service/asset/service.go:347`
- `recharge.Service.GetRechargeCheck()``internal/service/recharge/service.go:168`
- `recharge.Service.Create()``internal/service/recharge/service.go:83`
- `order.Service.CreateH5Order()``internal/service/order/service.go:632`
- `order.Service.checkForceRechargeRequirement()``internal/service/order/service.go:2216`
- `order.Service.WechatPayJSAPI()``internal/service/order/service.go:2095`
- `purchaseValidation.ValidateCardPurchase()``internal/service/purchase_validation/service.go:44`
- Gateway 设备能力:`internal/gateway/device.go:41-67`
- Gateway 实名链接:`internal/gateway/flow_card.go:44`
## Goals
本次变更目标是交付 `/api/c/v1/` 下 18 个客户端业务端点,覆盖 5 个模块:
- 模块 B资产信息B1~B4
- 模块 C钱包与充值C1~C5
- 模块 D套餐购买D1~D3
- 模块 E实名跳转E1
- 模块 F设备能力F1~F5
## Non-Goals
- 不改动后台管理端 API 行为与路由。
- 不引入或改造 exchange交易所体系。
- 不重做既有支付网关对接协议,仅在客户端入口补齐调用与安全约束。
## Decisions
### 1) Handler 组织
客户端 Handler 按模块拆分为 5 个文件,统一放在 `internal/handler/app/`
- `client_asset.go`
- `client_wallet.go`
- `client_order.go`
- `client_realname.go`
- `client_device.go`
这样可保持模块边界清晰,减少单文件复杂度,便于后续迭代。
### 2) Service 复用策略
- 直接复用B1/B3/B4/C1/C2/C3 复用现有 Service 能力(补充客户端上下文约束)。
- 新增逻辑B2 需新增“渠道价 + 加油包前置校验 + 不可售过滤 + 价格排序”逻辑。
- 新建 `client_order` ServiceC4/D1 引入客户端订单编排,复用 `order/recharge` 的底层能力但增加客户端专属流程控制。
### 3) 数据权限绕过
客户端调用 `asset/wallet` 等后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)`,绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
### 4) 归属校验方案
所有涉及资产操作接口统一前置:
- 查询 `PersonalCustomerDevice` 条件:`customer_id = 当前登录客户``virtual_no = 资产虚拟号`
- 未命中即返回 403`无权限操作该资产或资源不存在`
该规则覆盖 B/C/D/E/F 全模块写操作与敏感读操作。
### 5) Generation 过滤
客户端历史查询统一附加条件:`WHERE generation = 资产当前 generation`,适用于订单、充值、套餐历史。
- 客户端:必须过滤
- 后台:不加该过滤(保留全量视图)
### 6) OpenID 安全规范 + 微信支付 SDK 实例选择
支付相关接口C4/D1所需 OpenID 必须由后端按 `customer_id + app_type` 查询 `PersonalCustomerOpenID`
- 客户端请求体禁止携带 `openid`,仅传 `app_type``official_account``miniapp`
- 缺失时返回 `OPENID_NOT_FOUND`
**微信支付 SDK 实例选择逻辑**
支付时需根据 `app_type` 创建不同的 `PaymentService` 实例,因为微信 JSAPI 支付绑定的 AppID 必须与用户 OpenID 所属的应用一致:
```
客户端传入 app_type
├─ "official_account" → 用 WechatConfig.oa_app_id 创建 Payment 实例
│ → 查 PersonalCustomerOpenID WHERE app_id=oa_app_id 获取 openid
└─ "miniapp" → 用 WechatConfig.miniapp_app_id 创建 Payment 实例
→ 查 PersonalCustomerOpenID WHERE app_id=miniapp_app_id 获取 openid
```
**使用的现有 SDK 方法**`pkg/wechat/payment.go`,不需要修改):
| 方法 | 签名 | 用途 |
|------|------|------|
| `CreateJSAPIOrder` | `(ctx, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)` | 公众号/小程序内拉起支付,返回 `prepay_id` + `PayConfig`(可直接传给前端 `wx.requestPayment` |
| `HandlePaymentNotify` | `(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)` | 支付回调验签+解密,回调函数接收 `*PaymentNotifyResult` |
| `QueryOrder` | `(ctx, orderNo string) (*OrderInfo, error)` | 主动查询订单状态 |
| `CloseOrder` | `(ctx, orderNo string) error` | 关闭未支付订单 |
**SDK 实例创建**(使用提案 1 新增的工厂函数):
```go
// 在 client_order Service 中:
config, _ := s.wechatConfigService.GetActiveConfig(ctx) // 从 DB/Redis 缓存
appID := config.OaAppID
if req.AppType == "miniapp" {
appID = config.MiniappAppID
}
paymentApp, _ := wechat.NewPaymentAppFromConfig(config, appID, cache, logger)
paymentService := wechat.NewPaymentService(paymentApp, logger)
// 调用 paymentService.CreateJSAPIOrder(ctx, orderNo, desc, openID, amount)
```
**注意**`CreateH5Order`(外部浏览器 H5 支付)在客户端场景中**不使用**——客户端始终在微信内(公众号 H5 或小程序),一律走 JSAPI 支付。
### 7) 强充两阶段设计
强充场景采用“同步入账 + 异步自动购买”两阶段:
- 第一阶段(同步事务内):
1. 钱入钱包
2. 更新充值记录状态
3. 更新累计充值/首充状态
- 第二阶段(异步 Asynq
1. 从钱包扣款
2. 创建套餐订单
3. 激活套餐
`AssetRechargeRecord` 新增 `auto_purchase_status` 字段追踪异步状态pending/success/failed
### 8) D1 返回结构分流
`POST /api/c/v1/orders/create` 根据是否触发强充返回不同结构:
- `order_type = "package"`:直接返回 `order + pay_config`
- `order_type = "recharge"`:返回 `recharge + pay_config + linked_package_info`
前端据 `order_type` 决定支付结果页与文案。
### 9) 实名闭环说明
运营商实名为外部流程,无平台回调。用户完成实名后需主动触发 B4 刷新资产状态,再重新发起购买流程。
## Risks / Trade-offs
1. **强充异步失败风险**:第二阶段失败会导致“钱已到账、套餐未生效”的中间态。权衡后采用可重试 + `auto_purchase_status=failed` + 用户可手动购买的降级方案。
2. **Gateway 超时风险**B4/F2/F3/F4/F5/E1(gateway) 依赖外部网关,网络抖动可能放大请求延迟。需统一超时、重试与可观测日志。
3. **OpenID 缺失风险**:用户未完成公众号授权时无法拉起支付。需明确错误码 `OPENID_NOT_FOUND` 并引导重新授权。
4. **Generation 不一致风险**:资产转手或切换后,若查询未按 generation 过滤会出现历史串数据。客户端侧强制过滤会增加查询条件复杂度,但可换取数据隔离正确性。
5. **服务复用边界风险**:复用后台 Service 可加速交付,但若遗漏客户端前置条件(归属、权限绕过),会造成逻辑缺口。需在 Handler 层统一封装公共校验。
## Migration Plan
数据库迁移新增字段:
- 表:`tb_asset_recharge_record`
- 字段:`auto_purchase_status`(建议 `varchar(20)`,默认 `pending`
- 用途:记录强充回调后二阶段自动购买状态
迁移步骤:
1. 新增迁移文件up/down
2. 执行迁移并确认版本无 dirty。
3. 更新对应 Model 与常量枚举。
4. 回调逻辑写入状态流转:`pending -> success/failed`

View File

@@ -0,0 +1,175 @@
## Why
认证系统就绪后(提案 1客户端需要完整的业务接口来支撑核心使用场景查看资产信息、购买套餐、钱包充值、查看订单、实名认证跳转、设备操作。本提案覆盖客户端**全部 15 个业务接口**,是 C 端体验的核心支撑。
其中**套餐购买含强充两阶段处理**是最复杂的接口,涉及强充判断 → 微信支付 → 充值回调 → 异步套餐购买的完整链路,需要特别严谨的设计。
**前置依赖**:提案 0数据模型修复、提案 1认证系统
## What Changes
### 模块 B资产信息4 个接口)
- **B1 资产基本信息** `GET /api/c/v1/asset/info?identifier=xxx`:复用 `asset.Service.Resolve()`,个人客户调用不走 shop_id 数据权限过滤
- **B2 可购买套餐列表** `GET /api/c/v1/asset/packages?identifier=xxx`:按渠道区分价格(代理→`allocation.retail_price`,平台→`SuggestedRetailPrice`);过滤条件包含 `Package.status``shelf_status`、加油包前置校验;按价格升序排序
- **B3 历史套餐列表** `GET /api/c/v1/asset/package-history?identifier=xxx`:按资产当前 `generation` 过滤,复用 `dto.AssetPackageResponse`
- **B4 手动刷新** `POST /api/c/v1/asset/refresh`:卡类型调 Gateway 刷新;设备类型有 Redis 冷却时间
### 模块 C钱包与充值5 个接口)
- **C1 钱包详情** `GET /api/c/v1/wallet/detail?identifier=xxx`:不存在则自动创建空钱包
- **C2 钱包流水列表** `GET /api/c/v1/wallet/transactions?identifier=xxx`:通过 wallet_id 天然隔离(不需 generation 过滤),支持 transaction_type / 时间范围筛选
- **C3 充值预检** `GET /api/c/v1/wallet/recharge-check?identifier=xxx`:复用 `recharge.Service.GetRechargeCheck()`,返回是否需要强充、强充金额、触发类型
- **C4 创建充值订单** `POST /api/c/v1/wallet/recharge`客户端仅支持微信支付OpenID 由后端查表获取(安全规范);`operator_type=personal_customer``generation` 写时快照;拉起 JSAPI 支付
- **C5 充值订单列表** `GET /api/c/v1/wallet/recharges?identifier=xxx`:按 `generation` 过滤
### 模块 D套餐购买3 个接口,含核心强充流程)
- **D1 创建套餐购买订单** `POST /api/c/v1/orders/create`**核心接口**。含资产归属校验、套餐校验、实名校验、强充两阶段处理、幂等性保证
- **D2 套餐订单列表** `GET /api/c/v1/orders?identifier=xxx`:按 `generation` 过滤
- **D3 套餐订单详情** `GET /api/c/v1/orders/:id`:归属校验(通过资产虚拟号匹配 PersonalCustomerDevice
### 模块 E实名认证1 个接口)
- **E1 获取实名跳转链接** `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`:两个入口(购买拦截 / 设备卡列表主动选择三种模式none/template/gateway
### 模块 F设备能力5 个接口)
- **F1 设备卡列表** `GET /api/c/v1/device/cards?identifier=xxx`:从 CMP 数据库查,不调 Gateway
- **F2 设备重启** `POST /api/c/v1/device/reboot`
- **F3 恢复出厂** `POST /api/c/v1/device/factory-reset`
- **F4 设置 WiFi** `POST /api/c/v1/device/wifi`:注意 Gateway WiFiReq 的 cardNo 字段实际传入设备 IMEI
- **F5 切卡** `POST /api/c/v1/device/switch-card`
### D1 套餐购买核心流程(含强充两阶段)
```
客户端发起 POST /api/c/v1/orders/create
① 解析标识符 → card/device + asset_type + asset_id
② 资产归属校验
查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=?
未绑定 → 403 "无权操作该资产"
③ 套餐购买校验
调 purchaseValidationService.ValidateCardPurchase/ValidateDevicePurchase
检查: series_id、Package.status、shelf_status、加油包前置条件
④ 实名校验
套餐 enable_realname_activation=true 且卡 real_name_status=0
→ 返回 { code: NEED_REALNAME, need_realname: true }
⑤ OpenID 查询
根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid
找不到 → 返回 { code: OPENID_NOT_FOUND }
⑥ 幂等性检查Redis 业务键 + 分布式锁)
⑦ 强充检查
调 checkForceRechargeRequirement()
├─── 不需要强充 ──────────────────────────────┐
│ │
│ ⑧A 创建 Order │
│ (source="client", generation=当前) │
│ 拉起微信 JSAPI 支付 │
│ → 返回 { order_type: "package", │
│ order, pay_config } │
│ │
└─── 需要强充 ────────────────────────┐ │
│ │ │
▼ │ │
pay_amount = max(force_amount, │ │
package_total_price) │ │
│ │ │
▼ │ │
⑧B 创建 AssetRechargeRecord │ │
(linked_package_ids=package_ids, │ │
generation=当前) │ │
拉起微信 JSAPI 支付 │ │
→ 返回 { order_type: "recharge", │ │
recharge, pay_config, │ │
linked_package_info } │ │
│ │
═══════════════════════════════════════════╧════════╧═══
强充支付成功后的两阶段回调处理:
微信支付成功 → 充值回调
第一阶段(同步,事务内):
├── 1. 钱入钱包(余额增加)
├── 2. 更新充值单状态为已完成
├── 3. 更新累计/首充状态
└── 4. 检查一次性佣金触发
第二阶段异步Asynq 任务):
├── 5. 入队 AutoPurchaseAfterRecharge(recharge_record_id)
├── 6. 异步执行:
│ ├── a. 从钱包扣款payment_method=wallet
│ ├── b. 创建 Ordersource="client", generation=当前)
│ └── c. 激活套餐
└── 7. 失败处理:
├── a. Asynq 自动重试(最多 3 次)
├── b. 全部失败 → 标记 auto_purchase_status="failed"
└── c. 钱已在钱包中,用户可手动操作
```
### 实名跳转流程
```
前端调用 GET /api/c/v1/realname/link?identifier=xxx[&iccid=yyy]
解析标识符 → 确定目标卡:
├── 直接是卡 → 用该卡
├── 是设备 + 传了 iccid → 查该 iccid 对应的卡
└── 是设备 + 没传 iccid → 查 DeviceSimBinding 中 isActive=1 的卡
检查 card.real_name_status == 1?
├── YES → "该卡已完成实名"
└── NO
查 Carrier WHERE id=card.carrier_id → 获取 realname_link_type:
├── 'none' → "该运营商暂不支持在线实名"
├── 'template' → 替换占位符 {iccid}/{msisdn}/{virtual_no} → 返回 URL
└── 'gateway' → 调 gateway.GetRealnameLink(card.ICCID) → 返回 URL
```
## Capabilities
### New Capabilities
- `client-asset-info`客户端资产信息查询B1、可购买套餐列表B2含渠道价格、加油包校验、上下架过滤、历史套餐列表B3含 generation 过滤、手动刷新B4
- `client-wallet-recharge`客户端钱包详情C1、流水列表C2、充值预检C3、创建充值订单C4含 OpenID 安全规范、operator_type、generation 快照、充值订单列表C5
- `client-order-purchase`套餐购买订单创建D1含归属校验、实名校验、强充两阶段、幂等性、订单列表D2、订单详情D3、强充回调异步购买AutoPurchaseAfterRecharge Asynq 任务)
- `client-realname-link`实名跳转链接E1三种模式、两个入口、设备多卡选择
- `client-device-capability`设备卡列表F1、重启F2、恢复出厂F3、WiFi 设置F4、切卡F5
### Modified Capabilities
- `asset-resolve`Resolve 方法增加"无数据权限过滤"的客户端调用入口
- `wallet-recharge`:充值回调增加两阶段处理——同步入账 + 异步自动购买AssetRechargeRecord 新增 `auto_purchase_status` 字段跟踪异步购买状态
- `package-purchase-validation`增加客户端场景的归属校验PersonalCustomerDevice和实名校验拦截
- `iot-order`Order 新增客户端创建路径source="client"),支持 generation 写入
- `force-recharge-check`:强充检查结果输出给客户端,支持前端提示强充金额和套餐价格拆分
## Impact
- **新增文件**`internal/handler/app/client_asset.go``client_wallet.go``client_order.go``client_realname.go``client_device.go`5 个 Handler`internal/service/client_order/service.go`(客户端订单 Service新增 Asynq 任务 `AutoPurchaseAfterRecharge`;新增 DTO 文件;常量和错误码
- **修改文件**`internal/service/order/service.go`(提取强充逻辑供客户端复用);`internal/service/recharge/service.go`(充值回调增加两阶段处理);`internal/service/asset/service.go`(增加无数据权限调用方式);`internal/routes/personal.go`(新增客户端业务路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)
- **新增 API 路由**`/api/c/v1/` 下 18 个端点
- **数据库变更**AssetRechargeRecord 新增 `auto_purchase_status` 字段
- **新增 Asynq 任务类型**`task:auto_purchase_after_recharge`

View File

@@ -0,0 +1,41 @@
# Capability: 客户端资产信息
## ADDED Requirements
### Requirement: B1 资产基本信息查询接口
系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证C 端 Token。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`ICCID、虚拟号、设备号之一。响应体 SHALL 返回 `asset_type``asset_id``identifier``virtual_no``status``real_name_status``carrier``generation``wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_NOT_FOUND/资产不存在`
#### Scenario: 个人客户查询已绑定资产
- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人
- **THEN** 系统返回 200包含资产基础信息与当前 generation
---
### Requirement: B2 可购买套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1``shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id``package_name``package_type``retail_price``cost_price``validity``is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``PACKAGE_NOT_AVAILABLE/当前无可购买套餐`
#### Scenario: 代理渠道价格与过滤生效
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price`
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出
---
### Requirement: B3 历史套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page``page_size`(默认 20最大 100。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 转手后历史隔离
- **WHEN** 资产已发生转手且存在历史套餐记录
- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据
---
### Requirement: B4 手动刷新接口
系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type``card`/`device`)、`accepted``cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备刷新冷却拦截
- **WHEN** 客户在冷却时间内重复调用设备刷新
- **THEN** 系统返回频率限制错误并告知剩余冷却时间

View File

@@ -0,0 +1,51 @@
# Capability: 客户端设备能力
## ADDED Requirements
### Requirement: F1 设备卡列表接口
系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id``iccid``msisdn``carrier_name``network_status``real_name_status``slot_position``is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失`
#### Scenario: 返回设备绑定卡列表
- **WHEN** 客户查询已绑定设备卡列表
- **THEN** 系统返回设备下全部卡及活跃标记
---
### Requirement: F2 设备重启接口
系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true``request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备重启成功受理
- **WHEN** 客户对合法设备发起重启
- **THEN** 系统调用网关成功并返回受理结果
---
### Requirement: F3 设备恢复出厂接口
系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 恢复出厂失败返回网关错误
- **WHEN** 网关返回失败
- **THEN** 系统返回网关调用失败错误
---
### Requirement: F4 设备 WiFi 设置接口
系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``ssid``password``enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: WiFi 请求 cardNo 使用 IMEI
- **WHEN** 客户调用设备 WiFi 设置
- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI
---
### Requirement: F5 设备切卡接口
系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true``target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 切卡成功返回目标卡号
- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功
- **THEN** 系统返回 `accepted=true` 与目标 ICCID

View File

@@ -0,0 +1,46 @@
# Capability: 客户端套餐购买
## ADDED Requirements
### Requirement: D1 创建套餐购买订单接口
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``package_ids[]``app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
- 无强充:创建套餐订单并返回 `order_type="package"``order``pay_config`
- 需强充:创建充值单并返回 `order_type="recharge"``recharge``pay_config``linked_package_info`
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``NEED_REALNAME/该套餐需实名认证后购买``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交``PACKAGE_NOT_AVAILABLE/套餐不可购买`
#### Scenario: 命中强充返回 recharge 结构
- **WHEN** 客户购买套餐触发强充要求
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
---
### Requirement: D2 套餐订单列表接口
系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项至少含 `order_id``order_no``total_amount``payment_status``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 支持支付状态筛选
- **WHEN** 客户带 `payment_status=paid` 查询订单
- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单
---
### Requirement: D3 套餐订单详情接口
系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ORDER_NOT_FOUND/订单不存在`
#### Scenario: 查询他人订单被拦截
- **WHEN** 客户请求不属于本人资产的订单详情
- **THEN** 系统返回 403错误消息为无权限操作该资产或资源不存在
---
### Requirement: AutoPurchaseAfterRecharge 异步任务
系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`

View File

@@ -0,0 +1,23 @@
# Capability: 客户端实名跳转
## ADDED Requirements
### Requirement: E1 获取实名跳转链接接口
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
1. 标识符直达卡:直接使用该卡
2. 标识符为设备且传 `iccid`:定位对应设备下卡
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
`real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持:
- `none`:不支持在线实名,直接报错
- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL
- `gateway`:调用网关获取实名链接
响应体 SHALL 至少包含 `realname_mode``realname_url``card_info{iccid,msisdn,virtual_no}``expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``REALNAME_ALREADY_DONE/该卡已完成实名``REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名``GATEWAY_ERROR/获取实名链接失败`
#### Scenario: 设备未传 iccid 自动选活跃卡
- **WHEN** 客户传入设备标识符且不传 `iccid`
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接

View File

@@ -0,0 +1,51 @@
# Capability: 客户端钱包与充值
## ADDED Requirements
### Requirement: C1 钱包详情接口
系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id``resource_type``resource_id``balance``frozen_balance``updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 首次访问自动建钱包
- **WHEN** 客户查询资产钱包详情且钱包记录不存在
- **THEN** 系统自动创建钱包并返回余额 0
---
### Requirement: C2 钱包流水列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type``start_time``end_time``page``page_size`。响应体 SHALL 包含 `list[]``total``page``page_size`,每条记录至少含 `transaction_id``type``amount``balance_after``created_at``remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: wallet_id 隔离生效
- **WHEN** 客户查询某资产流水
- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据
---
### Requirement: C3 充值预检接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge``force_recharge_amount``trigger_type``min_amount``max_amount``message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 返回强充预检结果
- **WHEN** 资产命中强充规则
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
---
### Requirement: C4 创建充值订单接口
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier``amount`100~10000000 分)、`payment_method=wechat``app_type`。接口 MUST 禁止客户端传入 OpenID并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge``pay_config`,其中 `recharge` 至少含 `recharge_id``recharge_no``amount``status``pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``FORBIDDEN/无权限操作该资产或资源不存在``PAYMENT_NOT_SUPPORTED/仅支持微信支付`
#### Scenario: 后端查 OpenID 并返回支付参数
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
---
### Requirement: C5 充值订单列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,每项至少含 `recharge_id``recharge_no``amount``status``payment_method``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: generation 过滤充值历史
- **WHEN** 资产存在多代充值记录
- **THEN** 系统仅返回当前 generation 对应的充值记录

View File

@@ -0,0 +1,21 @@
# Capability: 强充预检
## MODIFIED Requirements
### Requirement: 强充检查结果对客户端透出
系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge``force_recharge_amount``trigger_type``total_package_amount``actual_payment``wallet_credit``message`。若无强充,`need_force_recharge=false``actual_payment=total_package_amount`
#### Scenario: 客户端购买预检命中强充
- **WHEN** 客户端调用购买预检且命中强充规则
- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额
---
### Requirement: 前端展示套餐价与强充金额拆分
系统 SHALL 在强充场景提供可直接渲染的拆分语义套餐总价、需支付金额、充值入钱包金额并给出中文提示文案。当前端调用客户端下单接口D1若命中强充 MUST 返回 `order_type="recharge"``linked_package_info`,以便前端保持与预检展示一致。
#### Scenario: 套餐价低于强充金额
- **WHEN** 套餐总价 5000 分,强充金额 10000 分
- **THEN** 预检返回 `actual_payment=10000``wallet_credit=5000`、提示文案可用于前端直接展示

View File

@@ -0,0 +1,33 @@
# Capability: 钱包充值
## MODIFIED Requirements
### Requirement: 充值回调采用两阶段处理
系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。
#### Scenario: 强充回调同步入账成功并触发异步任务
- **WHEN** 强充充值支付回调验签成功
- **THEN** 系统在事务内完成钱包入账与充值单状态更新
- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务
---
### Requirement: 充值记录新增 auto_purchase_status 状态追踪
系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending``success``failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`
#### Scenario: 强充充值单创建时默认 pending
- **WHEN** 系统创建与套餐联动的强充充值单
- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending`
---
### Requirement: 异步自动购买失败处理规范
系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。
#### Scenario: 异步任务最终失败
- **WHEN** 自动购买任务连续失败并达到最大重试次数
- **THEN** 系统将 `auto_purchase_status` 标记为 `failed`
- **AND** 钱包余额保持可用,用户可手动下单

View File

@@ -0,0 +1,70 @@
## 1. 常量与错误码
- [x] 1.1 在 `pkg/constants/constants.go` 增加 `auto_purchase_status` 状态常量pending/success/failed
- [x] 1.2 在 `pkg/errors/codes.go` 增加 `NEED_REALNAME``OPENID_NOT_FOUND` 错误码并补充中文消息
- [x] 1.3 在 `pkg/constants/redis.go` 增加客户端购买幂等键与锁键生成函数
## 2. DTO 定义
- [x] 2.1 新增资产模块 DTOB1/B2/B3/B4 请求与响应结构(含 description/validate 标签)
- [x] 2.2 新增钱包充值模块 DTOC1~C5 请求与响应结构(含支付返回 `pay_config`
- [x] 2.3 新增订单模块 DTOD1~D3 请求与响应结构(含 `order_type` 分流结构)
- [x] 2.4 新增实名与设备模块 DTOE1、F1~F5 请求与响应结构
## 3. 模型变更与迁移
- [x] 3.1 在 `internal/model/asset_recharge_record.go` 增加 `auto_purchase_status` 字段与中文注释
- [x] 3.2 创建迁移文件为 `tb_asset_recharge_record` 添加 `auto_purchase_status` 字段up/down
- [x] 3.3 执行迁移并确认版本状态正常(非 dirty
## 4. 资产信息模块B1~B4
- [x] 4.1 新建 `internal/handler/app/client_asset.go` 并实现 B1~B4 路由处理
- [x] 4.2 抽取公共方法 `resolveAssetFromIdentifier`(解析标识符 + 归属校验 + 权限绕过上下文)
- [x] 4.3 实现 B2 渠道价格计算、加油包前置校验、上下架过滤与价格升序
- [x] 4.4 实现 B4 刷新分流:卡走 Gateway、设备走 Redis 冷却 + 批量刷新
## 5. 钱包与充值模块C1~C5
- [x] 5.1 新建 `internal/handler/app/client_wallet.go` 并实现 C1~C5 路由处理
- [x] 5.2 实现 C1 钱包不存在自动创建逻辑与 C2 wallet_id 隔离查询
- [x] 5.3 复用并接入 C3 强充预检返回结构
- [x] 5.4 实现 C4 创建充值订单:根据 `app_type` 查 PersonalCustomerOpenID 获取 openid → 根据 `app_type` 选择 AppID`official_account``oa_app_id``miniapp``miniapp_app_id`)→ 调用提案 1 新增的 `wechat.NewPaymentAppFromConfig(config, appID)` 创建支付实例 → 调用现有 `PaymentService.CreateJSAPIOrder(orderNo, desc, openID, amount)` 拉起支付 → 设置 `operator_type=personal_customer`、写入 `generation` 快照
- [x] 5.5 实现 C5 充值记录 generation 过滤查询
## 6. 套餐购买模块D1~D3
- [x] 6.1 新建 `internal/handler/app/client_order.go` 并实现 D1~D3 路由处理
- [x] 6.2 新建 `internal/service/client_order/service.go` 编排 D1 全流程(归属/套餐/实名/OpenID/幂等/强充分流)。支付调用链:根据 `app_type` 选择 AppID → `wechat.NewPaymentAppFromConfig(config, appID)``PaymentService.CreateJSAPIOrder()` → 返回 `pay_config` 给前端。**客户端一律走 JSAPI 支付(微信内环境),不使用 H5 支付**
- [x] 6.3 实现强充两阶段:同步入账 + 异步自动购买Asynq 入队)。注意第二阶段自动购买创建的订单使用 `payment_method=wallet`(钱包扣款),不涉及微信支付
- [x] 6.4 新增 `AutoPurchaseAfterRecharge` 任务处理器(钱包扣款→创建订单→激活套餐,失败重试 3 次)
- [x] 6.5 实现 D1 `order_type` 双结构返回package/recharge
- [x] 6.6 实现 D2/D3 generation 与归属校验约束
## 7. 实名跳转E1
- [x] 7.1 新建 `internal/handler/app/client_realname.go` 并实现 E1 接口
- [x] 7.2 实现目标卡定位三路径(直接卡/设备+iccid/设备活跃卡)
- [x] 7.3 实现实名模式三分支none/template/gateway与模板占位符替换
## 8. 设备能力F1~F5
- [x] 8.1 新建 `internal/handler/app/client_device.go` 并实现 F1~F5 路由处理
- [x] 8.2 实现通用前置校验(必须设备类型且 IMEI 非空)
- [x] 8.3 对接 Gateway`RebootDevice``ResetDevice``SetWiFi``SwitchCard`
- [x] 8.4 实现 F4 特殊映射:`WiFiReq.cardNo = 设备IMEI`
## 9. 路由注册与文档
- [x] 9.1 在 `internal/bootstrap/types.go` 增加客户端业务 Handler 字段
- [x] 9.2 在 `internal/bootstrap/handlers.go` 完成客户端业务 Handler 实例化
- [x] 9.3 在 `internal/routes/personal.go` 注册 `/api/c/v1/` 18 个端点(使用 `Register()`
- [x] 9.4 在 `cmd/api/docs.go` 注册新增 Handler 供文档生成器使用
- [x] 9.5 在 `cmd/gendocs/main.go` 注册新增 Handler 并生成 OpenAPI 文档
## 10. 验证
- [x] 10.1 运行 `go build ./...` 确认构建通过
- [x] 10.2 运行 LSP diagnostics确保改动文件无错误
- [x] 10.3 使用数据库验证流程确认 `auto_purchase_status` 字段已生效
- [x] 10.4 补充 `docs/client-core-business-api/功能总结.md` 并更新相关索引文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18