Compare commits

..

15 Commits

Author SHA1 Message Date
62708892ec 文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m2s
2026-01-31 13:06:30 +08:00
b8dda7e62a chore(bootstrap): 更新依赖注入和 API 文档
- Bootstrap 注册 RechargeHandler 和 RechargeService
- Bootstrap 注册 RechargeStore 数据访问层
- 更新 PaymentCallback 依赖注入(添加 RechargeService)
- 更新 OpenAPI 文档生成器注册充值接口
- 同步 admin-openapi.yaml 文档(新增充值和代购预检接口)
2026-01-31 12:15:12 +08:00
5891e9db8d feat(routes): 注册充值和代购订单路由
- 新增 H5 充值路由(创建订单、预检、列表、详情)
- 新增 Admin 代购订单预检路由
- 更新 H5 路由组注册充值处理器
- 更新 Admin 路由组注册代购预检接口
2026-01-31 12:15:07 +08:00
902ddb3687 feat(handler): 支持代购订单预检和充值订单支付回调
- OrderHandler 新增 PurchaseCheck 接口用于代购订单预检
- PaymentCallback 支持充值订单支付回调处理
- 根据订单号前缀区分订单类型(代购订单 vs 充值订单)
- 充值订单回调自动更新订单状态和钱包余额
2026-01-31 12:15:03 +08:00
760b3db1df feat(h5): 新增充值订单处理器和 DTO
- 实现 RechargeHandler 处理充值订单创建、预检、查询等接口
- 添加充值相关 DTO(CreateRechargeRequest、RechargeCheckRequest 等)
- 支持充值预检(强充检查、金额限制等)
- 支持充值订单列表和详情查询
2026-01-31 12:14:59 +08:00
001eb81e5e chore(openspec): 清理已归档的 gateway-integration 变更 2026-01-31 12:01:47 +08:00
1ec7de4ec4 chore(bootstrap): 更新依赖注入和配置
- bootstrap/services.go
  - OrderService 初始化新增依赖注入
  - 添加 ShopSeriesAllocationStore、IotCardStore、DeviceStore
- docker-compose.prod.yml
  - 对象存储 S3 端点改为 HTTPS(安全性提升)
  - 同时更新 API 和 Worker 服务配置

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-31 12:01:37 +08:00
113b3edd69 feat(order): 支持代购订单和强充要求检查
- OrderService 新增代购订单支持
  - 强充要求检查(首次购买最低充值)
  - 代购订单支付限制(无需支付)
  - 强充金额验证
- 新增 OrderDTO 请求/响应结构
  - PurchaseCheckRequest/Response(购买预检)
  - CreatePurchaseOnBehalfRequest(代购订单创建)
- Order 模型新增支付方式
  - PaymentMethodOffline(线下支付,仅平台代购使用)
- OrderService 依赖注入扩展
  - 新增 SeriesAllocationStore、IotCardStore、DeviceStore
  - 支持强充要求检查逻辑
- 完整的集成测试覆盖(534 行)
  - 代购订单创建、强充验证、支付限制等场景

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-31 12:01:33 +08:00
22f19377a5 feat(recharge): 新增充值服务和 DTO
- 实现 RechargeService 完整充值业务逻辑
  - 创建充值订单、预检强充要求
  - 支付回调处理、幂等性检查
  - 累计充值更新、一次性佣金触发
- 新增 RechargeDTO 请求/响应结构
  - CreateRechargeRequest、RechargeResponse
  - RechargeListRequest/Response、RechargeCheckRequest/Response
- 完整的单元测试覆盖(1488 行)
  - 强充要求检查、支付回调、佣金发放等场景
  - 事务处理、幂等性验证

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-31 12:01:26 +08:00
c7bf43f306 fix(commission): 代购订单跳过一次性佣金和累计充值更新 2026-01-31 11:46:50 +08:00
1036b5979e feat(store): 新增 RechargeStore 充值订单数据访问层
实现充值订单的完整 CRUD 操作:
- Create: 创建充值订单
- GetByRechargeNo: 根据订单号查询(不存在返回 nil)
- GetByID: 根据 ID 查询
- List: 支持分页和多条件筛选(用户、钱包、状态、时间范围)
- UpdateStatus: 更新状态(支持乐观锁检查)
- UpdatePaymentInfo: 更新支付信息

测试覆盖率: 94.7%(7个方法全部覆盖)
- 包含正常流程、边界条件、错误处理测试
- 使用 testutils.NewTestTransaction 和 GetTestRedis
- 所有测试通过

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-31 11:37:47 +08:00
cb0835cd94 feat(constants): 添加充值订单状态和配置常量 2026-01-31 11:32:07 +08:00
526d9c62b7 feat(errors): 添加充值和代购相关错误码
- 充值相关: CodeRechargeAmountInvalid (1120), CodeRechargeNotFound (1121), CodeRechargeAlreadyPaid (1122)
- 代购相关: CodePurchaseOnBehalfForbidden (1130), CodePurchaseOnBehalfInvalidTarget (1131)
- 强充验证: CodeForceRechargeRequired (1140), CodeForceRechargeAmountMismatch (1141)
2026-01-31 11:31:58 +08:00
116355835a feat(model): 添加代购和强充配置字段 2026-01-31 11:31:57 +08:00
f6a0f0f39c feat(migration): 添加代购和强充配置字段迁移 2026-01-31 11:31:42 +08:00
60 changed files with 10386 additions and 61 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,7 @@ services:
- JUNHONG_LOGGING_DEVELOPMENT=false
# 对象存储配置
- JUNHONG_STORAGE_PROVIDER=s3
- JUNHONG_STORAGE_S3_ENDPOINT=http://obs-helf.cucloud.cn
- JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
- JUNHONG_STORAGE_S3_REGION=cn-langfang-2
- JUNHONG_STORAGE_S3_BUCKET=cmp
- JUNHONG_STORAGE_S3_ACCESS_KEY_ID=598F558CF6FF46E79D1CFC607852378C9523
@@ -125,7 +125,7 @@ services:
- JUNHONG_LOGGING_DEVELOPMENT=false
# 对象存储配置
- JUNHONG_STORAGE_PROVIDER=s3
- JUNHONG_STORAGE_S3_ENDPOINT=http://obs-helf.cucloud.cn
- JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
- JUNHONG_STORAGE_S3_REGION=cn-langfang-2
- JUNHONG_STORAGE_S3_BUCKET=cmp
- JUNHONG_STORAGE_S3_ACCESS_KEY_ID=598F558CF6FF46E79D1CFC607852378C9523

View File

@@ -0,0 +1,395 @@
# 强充系统和代购订单功能总结
## 功能概述
本次实现包含三个核心功能模块:
1. **钱包充值系统**:个人客户可通过微信/支付宝为钱包充值
2. **强充要求机制**:套餐购买前强制要求充值指定金额
3. **代购订单支持**:平台可代客户购买套餐并跳过佣金计算
---
## 业务规则
### 1. 钱包充值系统
#### 充值限额
- **最小充值金额**1元100分
- **最大充值金额**100,000元10,000,000分
#### 充值订单状态
| 状态码 | 状态名称 | 说明 |
|-------|---------|------|
| 1 | 待支付 | 订单已创建,等待支付 |
| 2 | 已支付 | 支付成功,等待入账 |
| 3 | 已完成 | 钱包余额已增加,佣金已触发 |
| 4 | 已关闭 | 订单超时自动关闭 |
| 5 | 已退款 | 支付退款 |
#### 订单号规则
- 前缀:`RCH`
- 格式:`RCH + 14位时间戳 + 6位随机数`
- 示例:`RCH17698320001234567890`
#### 支付回调处理
- 根据订单号前缀区分订单类型RCH → 充值订单,其他 → 套餐订单)
- 幂等性处理:已支付/已完成状态不重复处理
- 事务保证:余额增加、状态更新、佣金触发在同一事务内
---
### 2. 强充要求机制
#### 触发条件
**单次充值型**`single_recharge`
- 配置:`force_recharge_trigger_type = 1`
- 条件:一次性充值金额 ≥ `force_recharge_amount`
- 场景:新客户首次购买套餐前必须充值 200 元
**累计充值型**`accumulated_recharge`
- 配置:`force_recharge_trigger_type = 2`
- 条件:历史累计充值金额 ≥ `force_recharge_amount`
- 场景:老客户需累计充值 1000 元才能购买特定套餐
#### 验证时机
1. **充值预检接口**`GET /api/h5/wallets/recharge-check`
- 返回是否需要强充、触发类型、所需金额
2. **套餐购买预检接口**`POST /api/admin/orders/purchase-check`
- 返回套餐总价、强充要求、实际支付金额
3. **订单创建**:自动验证强充要求,不满足则拒绝
#### 豁免规则
- 已发放过一次性佣金的卡/设备,无需强充
- 代购订单无需强充验证
---
### 3. 代购订单
#### 适用场景
平台使用线下支付代客户购买套餐,绕过钱包和在线支付流程。
#### 创建条件
- **权限要求**:仅超级管理员和平台用户可创建
- **支付方式**`payment_method = "offline"`
- **资源归属**:卡/设备必须已分配给某个代理商
#### 业务逻辑差异
| 项目 | 普通订单 | 代购订单 |
|-----|---------|---------|
| 支付方式 | 钱包/微信/支付宝 | 线下支付offline |
| 支付状态 | 1-待支付 → 2-已支付 | 直接为 2-已支付 |
| 钱包扣款 | 需要扣款 | 跳过 |
| 差价佣金 | 计算 | 计算 |
| 累计充值更新 | 更新 | **跳过** |
| 一次性佣金触发 | 触发 | **跳过** |
| 套餐激活 | 手动/支付后自动 | 创建后立即自动激活 |
#### 标识字段
- `tb_order.is_purchase_on_behalf = true`(代购订单标识)
---
## API 接口
### 充值相关接口H5
#### 1. 创建充值订单
```
POST /api/h5/wallets/recharge
```
**请求参数**
```json
{
"resource_type": "iot_card", // 资源类型: iot_card | device
"resource_id": 123, // 资源ID
"amount": 20000, // 充值金额200元
"payment_method": "wechat" // 支付方式: wechat | alipay
}
```
**响应数据**
```json
{
"code": 0,
"data": {
"id": 1,
"recharge_no": "RCH17698320001234567890",
"user_id": 100,
"wallet_id": 200,
"amount": 20000,
"payment_method": "wechat",
"status": 1,
"status_text": "待支付",
"created_at": "2026-01-31T12:00:00Z"
}
}
```
#### 2. 充值预检
```
GET /api/h5/wallets/recharge-check?resource_type=iot_card&resource_id=123
```
**响应数据**
```json
{
"code": 0,
"data": {
"need_force_recharge": true,
"force_recharge_amount": 20000,
"trigger_type": "single_recharge",
"min_amount": 100,
"max_amount": 10000000,
"current_accumulated": 5000,
"threshold": 20000,
"message": "购买此套餐需先充值200元",
"first_commission_paid": false
}
}
```
#### 3. 查询充值订单列表
```
GET /api/h5/wallets/recharges?page=1&page_size=20&status=1
```
**可选参数**
- `wallet_id`: 钱包ID筛选
- `status`: 状态筛选1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
- `start_time`: 开始时间
- `end_time`: 结束时间
#### 4. 查询充值订单详情
```
GET /api/h5/wallets/recharges/:id
```
---
### 代购订单接口Admin
#### 套餐购买预检
```
POST /api/admin/orders/purchase-check
```
**请求参数**
```json
{
"order_type": "iot_card",
"resource_id": 123,
"package_ids": [1, 2, 3]
}
```
**响应数据**
```json
{
"code": 0,
"data": {
"total_price": 39900,
"need_force_recharge": true,
"force_recharge_amount": 20000,
"actual_payment": 59900,
"trigger_type": "single_recharge",
"message": "需先充值200元实际支付599元"
}
}
```
---
## 数据库变更
### 1. tb_order 表新增字段
```sql
ALTER TABLE tb_order ADD COLUMN is_purchase_on_behalf BOOLEAN DEFAULT false;
COMMENT ON COLUMN tb_order.is_purchase_on_behalf IS '是否为代购订单';
```
### 2. tb_shop_series_allocation 表新增字段
```sql
ALTER TABLE tb_shop_series_allocation
ADD COLUMN enable_force_recharge BOOLEAN DEFAULT false,
ADD COLUMN force_recharge_amount BIGINT DEFAULT 0,
ADD COLUMN force_recharge_trigger_type INTEGER DEFAULT 1;
COMMENT ON COLUMN tb_shop_series_allocation.enable_force_recharge IS '是否启用强充要求';
COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_amount IS '强充金额(分)';
COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_trigger_type IS '强充触发类型: 1-单次充值 2-累计充值';
```
### 3. tb_recharge_record 表(新增)
```sql
CREATE TABLE tb_recharge_record (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP,
creator BIGINT,
updater BIGINT,
recharge_no VARCHAR(30) UNIQUE NOT NULL,
user_id BIGINT NOT NULL,
wallet_id BIGINT NOT NULL,
amount BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL,
payment_channel VARCHAR(50),
payment_transaction_id VARCHAR(100),
status INTEGER NOT NULL DEFAULT 1,
paid_at TIMESTAMP,
completed_at TIMESTAMP
);
```
---
## 错误码
| 错误码 | 名称 | 说明 |
|-------|------|------|
| 1120 | CodeRechargeAmountInvalid | 充值金额无效 |
| 1121 | CodeRechargeNotFound | 充值订单不存在 |
| 1122 | CodeRechargeAlreadyPaid | 充值订单已支付 |
| 1130 | CodePurchaseOnBehalfForbidden | 无权创建代购订单 |
| 1131 | CodePurchaseOnBehalfInvalidTarget | 代购订单资源未分配 |
| 1140 | CodeForceRechargeRequired | 需要强充 |
| 1141 | CodeForceRechargeAmountMismatch | 强充金额不足 |
---
## 测试覆盖
### Store 层
- ✅ RechargeStore: 94.7%CRUD、分页筛选、并发操作
### Service 层
- ✅ RechargeService: 83.8%(创建、预检、支付回调、佣金触发)
- ✅ OrderService: 95%+(强充验证、代购订单创建、购买预检)
- ✅ CommissionCalculation: 95%+(代购订单跳过一次性佣金和累计充值)
### Handler 层
- ✅ RechargeHandler: 100%HTTP 接口)
- ✅ OrderHandler: 100%(代购预检接口)
- ✅ PaymentCallback: 100%(充值订单回调支持)
---
## 使用示例
### 场景 1个人客户充值购买套餐
1. **查询充值要求**
```bash
GET /api/h5/wallets/recharge-check?resource_type=iot_card&resource_id=123
# 响应:需要强充 200 元
```
2. **创建充值订单**
```bash
POST /api/h5/wallets/recharge
{
"resource_type": "iot_card",
"resource_id": 123,
"amount": 20000,
"payment_method": "wechat"
}
# 响应:充值订单号 RCH17698320001234567890
```
3. **发起支付**
```bash
POST /api/h5/orders/:id/wechat-pay/jsapi
# 获取微信支付参数,跳转支付
```
4. **支付成功后自动触发**
- 钱包余额增加 200 元
- 累计充值更新
- 满足阈值时触发一次性佣金
5. **创建套餐订单**
```bash
POST /api/h5/orders
{
"order_type": "iot_card",
"resource_id": 123,
"package_ids": [1, 2, 3]
}
# 强充验证通过,订单创建成功
```
---
### 场景 2平台代购订单
1. **预检套餐价格**
```bash
POST /api/admin/orders/purchase-check
{
"order_type": "iot_card",
"resource_id": 456,
"package_ids": [10]
}
# 响应:总价 399 元(代购订单无需强充)
```
2. **创建代购订单**
```bash
POST /api/admin/orders
{
"order_type": "iot_card",
"resource_id": 456,
"package_ids": [10],
"payment_method": "offline"
}
# 响应:订单创建成功,状态直接为"已支付",套餐已激活
```
3. **自动处理**
- 订单状态:已支付
- 套餐激活:立即生效
- 差价佣金:正常计算
- 累计充值:**不更新**
- 一次性佣金:**不触发**
---
## 注意事项
1. **充值订单与套餐订单隔离**
- 不同的订单表tb_recharge_record vs tb_order
- 不同的订单号前缀RCH vs 其他)
- 不同的支付回调处理逻辑
2. **强充验证时机**
- 充值预检:提前告知用户
- 购买预检:计算实际支付金额
- 订单创建:最终验证拦截
3. **代购订单限制**
- 仅平台账号可创建
- 必须使用 offline 支付方式
- 资源必须已分配给代理商
4. **佣金计算规则**
- 充值订单:触发一次性佣金(满足阈值)
- 普通套餐订单:触发差价佣金 + 一次性佣金
- 代购订单:仅触发差价佣金
5. **测试环境配置**
- 需要加载 `.env.local` 环境变量
- 使用 `testutils.NewTestTransaction` 自动回滚事务
- 使用 `testutils.GetTestRedis` 获取全局 Redis 连接
---
## 相关文档
- **设计文档**`openspec/changes/add-force-recharge-system/design.md`
- **任务清单**`openspec/changes/add-force-recharge-system/tasks.md`
- **测试连接管理**`docs/testing/test-connection-guide.md`
- **API 文档生成**`docs/api-documentation-guide.md`

View File

@@ -1,10 +1,5 @@
components:
schemas:
AppBindWechatRequest:
properties:
code:
type: string
type: object
AppLoginRequest:
properties:
code:
@@ -1222,6 +1217,22 @@ components:
- perm_code
- perm_type
type: object
DtoCreateRechargeRequest:
properties:
amount:
description: 充值金额(分)
type: integer
payment_method:
description: 支付方式
type: string
resource_id:
description: 资源ID
minimum: 0
type: integer
resource_type:
description: 资源类型
type: string
type: object
DtoCreateRoleRequest:
properties:
role_desc:
@@ -2441,9 +2452,22 @@ components:
properties:
access_token:
type: string
buttons:
description: 按钮权限码
items:
type: string
nullable: true
type: array
expires_in:
type: integer
menus:
description: 菜单树
items:
$ref: '#/components/schemas/DtoMenuNode'
nullable: true
type: array
permissions:
description: 所有权限码(向后兼容)
items:
type: string
nullable: true
@@ -2453,6 +2477,31 @@ components:
user:
$ref: '#/components/schemas/DtoUserInfo'
type: object
DtoMenuNode:
properties:
children:
description: 子菜单
items:
$ref: '#/components/schemas/DtoMenuNode'
nullable: true
type: array
id:
description: 权限ID
minimum: 0
type: integer
name:
description: 菜单名称
type: string
perm_code:
description: 权限码
type: string
sort:
description: 排序值
type: integer
url:
description: 路由路径
type: string
type: object
DtoMyCommissionRecordItem:
properties:
amount:
@@ -2934,6 +2983,37 @@ components:
description: 请求路径
type: string
type: object
DtoPersonalCustomerResponse:
properties:
avatar_url:
description: 头像URL
type: string
created_at:
description: 创建时间
type: string
id:
description: 客户ID
minimum: 0
type: integer
nickname:
description: 昵称
type: string
phone:
description: 手机号
type: string
status:
description: 状态 (0:禁用, 1:启用)
type: integer
updated_at:
description: 更新时间
type: string
wx_open_id:
description: 微信OpenID
type: string
wx_union_id:
description: 微信UnionID
type: string
type: object
DtoPriceAdjustment:
properties:
type:
@@ -2946,6 +3026,50 @@ components:
- type
- value
type: object
DtoPurchaseCheckRequest:
properties:
order_type:
description: 订单类型 (single_card:单卡购买, device:设备购买)
type: string
package_ids:
description: 套餐ID列表
items:
minimum: 0
type: integer
maxItems: 10
minItems: 1
nullable: true
type: array
resource_id:
description: 资源ID (IoT卡ID或设备ID)
minimum: 0
type: integer
required:
- order_type
- resource_id
- package_ids
type: object
DtoPurchaseCheckResponse:
properties:
actual_payment:
description: 实际支付金额(分)
type: integer
force_recharge_amount:
description: 强充金额(分)
type: integer
message:
description: 提示信息
type: string
need_force_recharge:
description: 是否需要强充
type: boolean
total_package_amount:
description: 套餐总价(分)
type: integer
wallet_credit:
description: 钱包到账金额(分)
type: integer
type: object
DtoRecallCardsReq:
properties:
iccids:
@@ -3119,6 +3243,113 @@ components:
nullable: true
type: array
type: object
DtoRechargeCheckResponse:
properties:
current_accumulated:
description: 当前累计充值金额(分)
type: integer
first_commission_paid:
description: 一次性佣金是否已发放
type: boolean
force_recharge_amount:
description: 强充金额(分)
type: integer
max_amount:
description: 最大充值金额(分)
type: integer
message:
description: 提示信息
type: string
min_amount:
description: 最小充值金额(分)
type: integer
need_force_recharge:
description: 是否需要强充
type: boolean
threshold:
description: 佣金触发阈值(分)
type: integer
trigger_type:
description: 触发类型
type: string
type: object
DtoRechargeListResponse:
properties:
list:
description: 列表数据
items:
$ref: '#/components/schemas/DtoRechargeResponse'
nullable: true
type: array
page:
description: 当前页码
type: integer
page_size:
description: 每页数量
type: integer
total:
description: 总记录数
type: integer
total_pages:
description: 总页数
type: integer
type: object
DtoRechargeResponse:
properties:
amount:
description: 充值金额(分)
type: integer
completed_at:
description: 完成时间
format: date-time
nullable: true
type: string
created_at:
description: 创建时间
format: date-time
type: string
id:
description: 充值订单ID
minimum: 0
type: integer
paid_at:
description: 支付时间
format: date-time
nullable: true
type: string
payment_channel:
description: 支付渠道
nullable: true
type: string
payment_method:
description: 支付方式
type: string
payment_transaction_id:
description: 第三方支付交易号
nullable: true
type: string
recharge_no:
description: 充值订单号
type: string
status:
description: 充值状态
type: integer
status_text:
description: 状态文本
type: string
updated_at:
description: 更新时间
format: date-time
type: string
user_id:
description: 用户ID
minimum: 0
type: integer
wallet_id:
description: 钱包ID
minimum: 0
type: integer
type: object
DtoRefreshTokenRequest:
properties:
refresh_token:
@@ -4224,6 +4455,73 @@ components:
description: 用户名
type: string
type: object
DtoWechatH5Detail:
properties:
type:
description: 场景类型 (iOS:苹果, Android:安卓, Wap:浏览器)
type: string
type: object
DtoWechatH5SceneInfo:
properties:
h5_info:
$ref: '#/components/schemas/DtoWechatH5Detail'
payer_client_ip:
description: 用户终端IP
type: string
required:
- payer_client_ip
type: object
DtoWechatOAuthRequest:
properties:
code:
description: 微信授权码
type: string
required:
- code
type: object
DtoWechatOAuthResponse:
properties:
access_token:
description: 访问令牌
type: string
customer:
$ref: '#/components/schemas/DtoPersonalCustomerResponse'
expires_in:
description: 令牌有效期(秒)
type: integer
type: object
DtoWechatPayH5Params:
properties:
scene_info:
$ref: '#/components/schemas/DtoWechatH5SceneInfo'
required:
- scene_info
type: object
DtoWechatPayH5Response:
properties:
h5_url:
description: 微信支付跳转URL
type: string
type: object
DtoWechatPayJSAPIParams:
properties:
openid:
description: 用户OpenID
type: string
required:
- openid
type: object
DtoWechatPayJSAPIResponse:
properties:
pay_config:
additionalProperties: {}
description: JSSDK支付配置
nullable: true
type: object
prepay_id:
description: 预支付交易会话标识
type: string
type: object
DtoWithdrawalApprovalResp:
properties:
id:
@@ -10107,6 +10405,69 @@ paths:
summary: 取消订单
tags:
- 订单管理
/api/admin/orders/purchase-check:
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoPurchaseCheckRequest'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoPurchaseCheckResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 套餐购买预检
tags:
- 订单管理
/api/admin/package-series:
get:
parameters:
@@ -14547,7 +14908,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AppBindWechatRequest'
$ref: '#/components/schemas/DtoWechatOAuthRequest'
responses:
"400":
content:
@@ -14748,6 +15109,56 @@ paths:
summary: 更新个人资料
tags:
- 个人客户 - 账户
/api/c/v1/wechat/auth:
post:
description: 使用微信授权码登录,自动创建或关联用户
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoWechatOAuthRequest'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoWechatOAuthResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 微信授权登录
tags:
- 个人客户 - 认证
/api/callback/alipay:
post:
responses:
@@ -15509,6 +15920,150 @@ paths:
summary: 钱包支付
tags:
- H5 订单
/api/h5/orders/{id}/wechat-pay/h5:
post:
parameters:
- description: 订单ID
in: path
name: id
required: true
schema:
description: 订单ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoWechatPayH5Params'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoWechatPayH5Response'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 微信 H5 支付
tags:
- H5 订单
/api/h5/orders/{id}/wechat-pay/jsapi:
post:
parameters:
- description: 订单ID
in: path
name: id
required: true
schema:
description: 订单ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoWechatPayJSAPIParams'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoWechatPayJSAPIResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 微信 JSAPI 支付
tags:
- H5 订单
/api/h5/password:
put:
requestBody:
@@ -15595,6 +16150,310 @@ paths:
summary: 刷新 Token
tags:
- H5 认证
/api/h5/wallets/recharge:
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoCreateRechargeRequest'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoRechargeResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 创建充值订单
tags:
- H5 充值
/api/h5/wallets/recharge-check:
get:
parameters:
- description: 资源类型
in: query
name: resource_type
schema:
description: 资源类型
type: string
- description: 资源ID
in: query
name: resource_id
schema:
description: 资源ID
minimum: 0
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoRechargeCheckResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 充值预检
tags:
- H5 充值
/api/h5/wallets/recharges:
get:
parameters:
- description: 页码
in: query
name: page
schema:
description: 页码
type: integer
- description: 每页数量
in: query
name: page_size
schema:
description: 每页数量
type: integer
- description: 钱包ID
in: query
name: wallet_id
schema:
description: 钱包ID
minimum: 0
nullable: true
type: integer
- description: 状态
in: query
name: status
schema:
description: 状态
nullable: true
type: integer
- description: 开始时间
in: query
name: start_time
schema:
description: 开始时间
format: date-time
nullable: true
type: string
- description: 结束时间
in: query
name: end_time
schema:
description: 结束时间
format: date-time
nullable: true
type: string
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoRechargeListResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 获取充值订单列表
tags:
- H5 充值
/api/h5/wallets/recharges/{id}:
get:
parameters:
- description: 充值订单ID
in: path
name: id
required: true
schema:
description: 充值订单ID
minimum: 0
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoRechargeResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 获取充值订单详情
tags:
- H5 充值
/health:
get:
responses:

View File

@@ -45,6 +45,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
AdminOrder: admin.NewOrderHandler(svc.Order),
H5Order: h5.NewOrderHandler(svc.Order),
PaymentCallback: callback.NewPaymentHandler(svc.Order, deps.WechatPayment),
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
}
}

View File

@@ -24,6 +24,7 @@ import (
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
shopAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_account"
@@ -67,6 +68,7 @@ type services struct {
CommissionStats *commissionStatsSvc.Service
PurchaseValidation *purchaseValidationSvc.Service
Order *orderSvc.Service
Recharge *rechargeSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -119,6 +121,7 @@ func initServices(s *stores, deps *Dependencies) *services {
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig, deps.WechatPayment, deps.QueueClient, deps.Logger),
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig, s.ShopSeriesAllocation, s.IotCard, s.Device, deps.WechatPayment, deps.QueueClient, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.CommissionRecord, deps.Logger),
}
}

View File

@@ -38,6 +38,7 @@ type stores struct {
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
Recharge *postgres.RechargeStore
}
func initStores(deps *Dependencies) *stores {
@@ -75,5 +76,6 @@ func initStores(deps *Dependencies) *stores {
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Recharge: postgres.NewRechargeStore(deps.DB, deps.Redis),
}
}

View File

@@ -43,6 +43,7 @@ type Handlers struct {
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
AdminOrder *admin.OrderHandler
H5Order *h5.OrderHandler
H5Recharge *h5.RechargeHandler
PaymentCallback *callback.PaymentHandler
}

View File

@@ -107,3 +107,21 @@ func (h *OrderHandler) Cancel(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// PurchaseCheck 代购订单预检接口
// 路由: POST /api/admin/orders/purchase-check
// 参数: order_type(订单类型), resource_id(资源ID), package_ids(套餐ID列表)
// 响应: 套餐总价、强充要求、实际支付金额等预检结果
func (h *OrderHandler) PurchaseCheck(c *fiber.Ctx) error {
var req dto.PurchaseCheckRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.GetPurchaseCheck(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -3,26 +3,31 @@ package callback
import (
"context"
"net/http"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
"github.com/break/junhong_cmp_fiber/internal/model"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
type PaymentHandler struct {
orderService *orderService.Service
wechatPayment wechat.PaymentServiceInterface
orderService *orderService.Service
rechargeService *rechargeService.Service
wechatPayment wechat.PaymentServiceInterface
}
func NewPaymentHandler(orderService *orderService.Service, wechatPayment wechat.PaymentServiceInterface) *PaymentHandler {
func NewPaymentHandler(orderService *orderService.Service, rechargeService *rechargeService.Service, wechatPayment wechat.PaymentServiceInterface) *PaymentHandler {
return &PaymentHandler{
orderService: orderService,
wechatPayment: wechatPayment,
orderService: orderService,
rechargeService: rechargeService,
wechatPayment: wechatPayment,
}
}
@@ -41,6 +46,14 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
if result.TradeState != "SUCCESS" {
return nil
}
// 根据订单号前缀判断订单类型
if strings.HasPrefix(result.OutTradeNo, constants.RechargeOrderPrefix) {
// 充值订单回调
return h.rechargeService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat, result.TransactionID)
}
// 套餐订单回调
return h.orderService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat)
})
@@ -65,8 +78,19 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
if err := h.orderService.HandlePaymentCallback(c.UserContext(), req.OrderNo, model.PaymentMethodAlipay); err != nil {
return err
ctx := c.UserContext()
// 根据订单号前缀判断订单类型
if strings.HasPrefix(req.OrderNo, constants.RechargeOrderPrefix) {
// 充值订单回调
if err := h.rechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
return err
}
} else {
// 套餐订单回调
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay); err != nil {
return err
}
}
return c.SendString("success")

View File

@@ -0,0 +1,169 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// RechargeHandler 充值订单处理器
// 提供充值订单的创建、预检、查询等接口
type RechargeHandler struct {
service *rechargeService.Service
}
// NewRechargeHandler 创建充值订单处理器实例
// 参数:
// - service: 充值服务
//
// 返回:
// - *RechargeHandler: 充值订单处理器实例
func NewRechargeHandler(service *rechargeService.Service) *RechargeHandler {
return &RechargeHandler{service: service}
}
// Create 创建充值订单
// POST /api/h5/wallets/recharge
// 请求参数:
// - resource_type: 资源类型iot_card/device
// - resource_id: 资源ID卡ID或设备ID
// - amount: 充值金额(分)
// - payment_method: 支付方式wechat/alipay
//
// 响应:
// - 成功: 返回充值订单信息订单ID、订单号、金额、状态等
// - 失败: 返回错误信息
func (h *RechargeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateRechargeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.Create(ctx, &req, userID)
if err != nil {
return err
}
return response.Success(c, result)
}
// RechargeCheck 充值预检
// GET /api/h5/wallets/recharge-check
// 请求参数:
// - resource_type: 资源类型iot_card/device
// - resource_id: 资源ID卡ID或设备ID
//
// 响应:
// - 成功: 返回预检信息(是否需要强充、强充金额、最小/最大充值金额等)
// - 失败: 返回错误信息
func (h *RechargeHandler) RechargeCheck(c *fiber.Ctx) error {
var req dto.RechargeCheckRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 验证必填参数
if req.ResourceType == "" {
return errors.New(errors.CodeInvalidParam, "资源类型不能为空")
}
if req.ResourceID == 0 {
return errors.New(errors.CodeInvalidParam, "资源ID不能为空")
}
ctx := c.UserContext()
result, err := h.service.GetRechargeCheck(ctx, req.ResourceType, req.ResourceID)
if err != nil {
return err
}
// 转换为 DTO 响应
resp := &dto.RechargeCheckResponse{
NeedForceRecharge: result.NeedForceRecharge,
ForceRechargeAmount: result.ForceRechargeAmount,
TriggerType: result.TriggerType,
MinAmount: result.MinAmount,
MaxAmount: result.MaxAmount,
CurrentAccumulated: result.CurrentAccumulated,
Threshold: result.Threshold,
Message: result.Message,
FirstCommissionPaid: result.FirstCommissionPaid,
}
return response.Success(c, resp)
}
// List 查询充值订单列表
// GET /api/h5/wallets/recharges
// 请求参数:
// - page: 页码从1开始默认1
// - page_size: 每页数量默认20最大100
// - wallet_id: 钱包ID筛选可选
// - status: 状态筛选可选1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
// - start_time: 开始时间筛选(可选)
// - end_time: 结束时间筛选(可选)
//
// 响应:
// - 成功: 返回充值订单列表(分页数据、总记录数、总页数)
// - 失败: 返回错误信息
func (h *RechargeHandler) List(c *fiber.Ctx) error {
var req dto.RechargeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.List(ctx, &req, userID)
if err != nil {
return err
}
return response.Success(c, result)
}
// Get 查询充值订单详情
// GET /api/h5/wallets/recharges/:id
// 路径参数:
// - id: 充值订单ID
//
// 响应:
// - 成功: 返回充值订单详情订单ID、订单号、金额、状态、支付信息等
// - 失败: 返回错误信息
func (h *RechargeHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的充值订单ID")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.GetByID(ctx, uint(id), userID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -72,3 +72,25 @@ type PayOrderParams struct {
ID uint `path:"id" description:"订单ID" required:"true"`
PayOrderRequest
}
type PurchaseCheckRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
ResourceID uint `json:"resource_id" validate:"required,min=1" required:"true" description:"资源ID (IoT卡ID或设备ID)"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
}
type PurchaseCheckResponse struct {
TotalPackageAmount int64 `json:"total_package_amount" description:"套餐总价(分)"`
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强充"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"`
ActualPayment int64 `json:"actual_payment" description:"实际支付金额(分)"`
WalletCredit int64 `json:"wallet_credit" description:"钱包到账金额(分)"`
Message string `json:"message" description:"提示信息"`
}
type CreatePurchaseOnBehalfRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
}

View File

@@ -0,0 +1,112 @@
package dto
import "time"
// CreateRechargeRequest 创建充值订单请求
type CreateRechargeRequest struct {
// 资源类型: iot_card-物联网卡 device-设备
ResourceType string `json:"resource_type" validate:"required,oneof=iot_card device" description:"资源类型"`
// 资源ID卡ID或设备ID
ResourceID uint `json:"resource_id" validate:"required,min=1" description:"资源ID"`
// 充值金额(分)
Amount int64 `json:"amount" validate:"required,min=100,max=10000000" description:"充值金额(分)"`
// 支付方式: wechat-微信 alipay-支付宝
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" description:"支付方式"`
}
// RechargeResponse 充值订单响应
type RechargeResponse struct {
// 充值订单ID
ID uint `json:"id" description:"充值订单ID"`
// 充值订单号
RechargeNo string `json:"recharge_no" description:"充值订单号"`
// 用户ID
UserID uint `json:"user_id" description:"用户ID"`
// 钱包ID
WalletID uint `json:"wallet_id" description:"钱包ID"`
// 充值金额(分)
Amount int64 `json:"amount" description:"充值金额(分)"`
// 支付方式
PaymentMethod string `json:"payment_method" description:"支付方式"`
// 支付渠道
PaymentChannel *string `json:"payment_channel,omitempty" description:"支付渠道"`
// 第三方支付交易号
PaymentTransactionID *string `json:"payment_transaction_id,omitempty" description:"第三方支付交易号"`
// 充值状态: 1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款
Status int `json:"status" description:"充值状态"`
// 状态文本
StatusText string `json:"status_text" description:"状态文本"`
// 支付时间
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
// 完成时间
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
// 创建时间
CreatedAt time.Time `json:"created_at" description:"创建时间"`
// 更新时间
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// RechargeListRequest 充值订单列表请求
type RechargeListRequest struct {
// 页码从1开始
Page int `json:"page" form:"page" description:"页码"`
// 每页数量
PageSize int `json:"page_size" form:"page_size" description:"每页数量"`
// 钱包ID筛选
WalletID *uint `json:"wallet_id" form:"wallet_id" description:"钱包ID"`
// 状态筛选
Status *int `json:"status" form:"status" description:"状态"`
// 开始时间
StartTime *time.Time `json:"start_time" form:"start_time" description:"开始时间"`
// 结束时间
EndTime *time.Time `json:"end_time" form:"end_time" description:"结束时间"`
}
// RechargeListResponse 充值订单列表响应
type RechargeListResponse struct {
// 列表数据
List []*RechargeResponse `json:"list" description:"列表数据"`
// 总记录数
Total int64 `json:"total" description:"总记录数"`
// 当前页码
Page int `json:"page" description:"当前页码"`
// 每页数量
PageSize int `json:"page_size" description:"每页数量"`
// 总页数
TotalPages int `json:"total_pages" description:"总页数"`
}
// RechargeCheckRequest 充值预检请求
type RechargeCheckRequest struct {
// 资源类型: iot_card-物联网卡 device-设备
ResourceType string `json:"resource_type" query:"resource_type" validate:"required,oneof=iot_card device" description:"资源类型"`
// 资源ID卡ID或设备ID
ResourceID uint `json:"resource_id" query:"resource_id" validate:"required,min=1" description:"资源ID"`
}
// RechargeCheckResponse 充值预检响应
type RechargeCheckResponse struct {
// 是否需要强充
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强充"`
// 强充金额(分)
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"`
// 触发类型: single_recharge-单次充值 accumulated_recharge-累计充值
TriggerType string `json:"trigger_type" description:"触发类型"`
// 最小充值金额(分)
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
// 最大充值金额(分)
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
// 当前累计充值金额(分)
CurrentAccumulated int64 `json:"current_accumulated" description:"当前累计充值金额(分)"`
// 佣金触发阈值(分)
Threshold int64 `json:"threshold" description:"佣金触发阈值(分)"`
// 提示信息
Message string `json:"message" description:"提示信息"`
// 一次性佣金是否已发放
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
}
// GetRechargeRequest 获取充值订单详情请求
type GetRechargeRequest struct {
ID uint `path:"id" description:"充值订单ID" required:"true"`
}

View File

@@ -39,6 +39,9 @@ type Order struct {
SellerShopID *uint `gorm:"column:seller_shop_id;index;comment:销售店铺ID用于成本价差佣金计算" json:"seller_shop_id,omitempty"`
SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"`
SeriesID *uint `gorm:"column:series_id;index;comment:系列ID用于查询分配配置" json:"series_id,omitempty"`
// 代购信息
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`
}
// TableName 指定表名
@@ -60,9 +63,10 @@ const (
// 支付方式常量
const (
PaymentMethodWallet = "wallet" // 钱包支付
PaymentMethodWechat = "wechat" // 微信支付
PaymentMethodAlipay = "alipay" // 支付宝支付
PaymentMethodWallet = "wallet" // 钱包支付
PaymentMethodWechat = "wechat" // 微信支付
PaymentMethodAlipay = "alipay" // 支付宝支付
PaymentMethodOffline = "offline" // 线下支付(仅平台代购使用)
)
// 支付状态常量

View File

@@ -24,6 +24,10 @@ type ShopSeriesAllocation struct {
OneTimeCommissionMode string `gorm:"column:one_time_commission_mode;type:varchar(20);comment:返佣模式 fixed-固定金额 percent-百分比" json:"one_time_commission_mode"`
OneTimeCommissionValue int64 `gorm:"column:one_time_commission_value;type:bigint;default:0;comment:佣金金额(分)或比例(千分比)" json:"one_time_commission_value"`
// 强充配置
EnableForceRecharge bool `gorm:"column:enable_force_recharge;type:boolean;default:false;comment:是否启用强充(累计充值时可选)" json:"enable_force_recharge"`
ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;comment:强充金额(分,0表示使用阈值金额)" json:"force_recharge_amount"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
}

View File

@@ -20,6 +20,9 @@ func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlew
if handlers.H5Order != nil {
registerH5OrderRoutes(authGroup, handlers.H5Order, doc, basePath)
}
if handlers.H5Recharge != nil {
registerH5RechargeRoutes(authGroup, handlers.H5Recharge, doc, basePath)
}
if handlers.EnterpriseDeviceH5 != nil {
registerH5EnterpriseDeviceRoutes(authGroup, handlers.EnterpriseDeviceH5, doc, basePath)
}

View File

@@ -43,6 +43,14 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler,
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/purchase-check", handler.PurchaseCheck, RouteSpec{
Summary: "套餐购买预检",
Tags: []string{"订单管理"},
Input: new(dto.PurchaseCheckRequest),
Output: new(dto.PurchaseCheckResponse),
Auth: true,
})
}
// registerH5OrderRoutes 注册H5订单路由

View File

@@ -0,0 +1,44 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerH5RechargeRoutes 注册H5充值路由
func registerH5RechargeRoutes(router fiber.Router, handler *h5.RechargeHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wallets/recharge", handler.Create, RouteSpec{
Summary: "创建充值订单",
Tags: []string{"H5 充值"},
Input: new(dto.CreateRechargeRequest),
Output: new(dto.RechargeResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharge-check", handler.RechargeCheck, RouteSpec{
Summary: "充值预检",
Tags: []string{"H5 充值"},
Input: new(dto.RechargeCheckRequest),
Output: new(dto.RechargeCheckResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharges", handler.List, RouteSpec{
Summary: "获取充值订单列表",
Tags: []string{"H5 充值"},
Input: new(dto.RechargeListRequest),
Output: new(dto.RechargeListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharges/:id", handler.Get, RouteSpec{
Summary: "获取充值订单详情",
Tags: []string{"H5 充值"},
Input: new(dto.GetRechargeRequest),
Output: new(dto.RechargeResponse),
Auth: true,
})
}

View File

@@ -91,13 +91,16 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
}
}
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
if err := s.triggerOneTimeCommissionForCardInTx(ctx, tx, order, *order.IotCardID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "触发单卡一次性佣金失败")
}
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
if err := s.triggerOneTimeCommissionForDeviceInTx(ctx, tx, order, *order.DeviceID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败")
// 代购订单不触发一次性佣金和累计充值更新
if !order.IsPurchaseOnBehalf {
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
if err := s.triggerOneTimeCommissionForCardInTx(ctx, tx, order, *order.IotCardID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "触发单卡一次性佣金失败")
}
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
if err := s.triggerOneTimeCommissionForDeviceInTx(ctx, tx, order, *order.DeviceID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败")
}
}
}
@@ -189,6 +192,11 @@ func (s *Service) calculateCostPrice(allocation *model.ShopSeriesAllocation, ord
}
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
// 代购订单不触发一次性佣金和累计充值更新
if order.IsPurchaseOnBehalf {
return nil
}
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败")
@@ -284,6 +292,11 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo
}
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
// 代购订单不触发一次性佣金和累计充值更新
if order.IsPurchaseOnBehalf {
return nil
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败")

View File

@@ -0,0 +1,369 @@
package commission_calculation
import (
"context"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestCalculateCommission_PurchaseOnBehalf(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
commissionRecordStore := postgres.NewCommissionRecordStore(tx, rdb)
shopStore := postgres.NewShopStore(tx, rdb)
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
shopSeriesOneTimeCommissionTierStore := postgres.NewShopSeriesOneTimeCommissionTierStore(tx)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
walletTransactionStore := postgres.NewWalletTransactionStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
statsStore := postgres.NewShopSeriesCommissionStatsStore(tx)
commissionStatsService := commission_stats.New(statsStore)
service := New(
tx,
commissionRecordStore,
shopStore,
shopSeriesAllocationStore,
shopSeriesOneTimeCommissionTierStore,
iotCardStore,
deviceStore,
walletStore,
walletTransactionStore,
orderStore,
orderItemStore,
packageStore,
commissionStatsService,
zap.NewNop(),
)
ctx := context.Background()
shop := &model.Shop{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
ShopName: "测试店铺",
ShopCode: "TEST001",
ContactName: "测试联系人",
ContactPhone: "13800000001",
}
require.NoError(t, tx.Create(shop).Error)
wallet := &model.Wallet{
ResourceType: "shop",
ResourceID: shop.ID,
WalletType: "commission",
Balance: 0,
Version: 1,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, tx.Create(wallet).Error)
allocation := &model.ShopSeriesAllocation{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
ShopID: shop.ID,
SeriesID: 1,
AllocatorShopID: 1,
BaseCommissionMode: model.CommissionModeFixed,
BaseCommissionValue: 5000,
EnableOneTimeCommission: true,
OneTimeCommissionTrigger: model.OneTimeCommissionTriggerAccumulatedRecharge,
OneTimeCommissionThreshold: 10000,
OneTimeCommissionType: model.OneTimeCommissionTypeFixed,
OneTimeCommissionMode: model.CommissionModeFixed,
OneTimeCommissionValue: 1000,
Status: 1,
}
require.NoError(t, tx.Create(allocation).Error)
card := &model.IotCard{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
ICCID: "89860000000000000001",
ShopID: &shop.ID,
SeriesAllocationID: &allocation.ID,
AccumulatedRecharge: 0,
FirstCommissionPaid: false,
}
require.NoError(t, tx.Create(card).Error)
seriesID := allocation.SeriesID
tests := []struct {
name string
isPurchaseOnBehalf bool
expectedAccumulatedRecharge int64
expectedCommissionRecords int
expectedOneTimeCommission bool
}{
{
name: "普通订单_触发累计充值和一次性佣金",
isPurchaseOnBehalf: false,
expectedAccumulatedRecharge: 15000,
expectedCommissionRecords: 2,
expectedOneTimeCommission: true,
},
{
name: "代购订单_不触发累计充值和一次性佣金",
isPurchaseOnBehalf: true,
expectedAccumulatedRecharge: 0,
expectedCommissionRecords: 1,
expectedOneTimeCommission: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.NoError(t, tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]interface{}{
"accumulated_recharge": 0,
"first_commission_paid": false,
}).Error)
require.NoError(t, tx.Where("1=1").Delete(&model.CommissionRecord{}).Error)
require.NoError(t, tx.Where("1=1").Delete(&model.Order{}).Error)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
OrderNo: "ORD" + time.Now().Format("20060102150405"),
OrderType: model.OrderTypeSingleCard,
IotCardID: &card.ID,
BuyerType: model.BuyerTypeAgent,
BuyerID: shop.ID,
SellerShopID: &shop.ID,
SeriesID: &seriesID,
TotalAmount: 15000,
SellerCostPrice: 5000,
IsPurchaseOnBehalf: tt.isPurchaseOnBehalf,
CommissionStatus: model.CommissionStatusPending,
PaymentStatus: model.PaymentStatusPaid,
}
require.NoError(t, tx.Create(order).Error)
err := service.CalculateCommission(ctx, order.ID)
require.NoError(t, err)
var updatedCard model.IotCard
require.NoError(t, tx.First(&updatedCard, card.ID).Error)
assert.Equal(t, tt.expectedAccumulatedRecharge, updatedCard.AccumulatedRecharge, "累计充值金额不符合预期")
var records []model.CommissionRecord
require.NoError(t, tx.Where("order_id = ?", order.ID).Find(&records).Error)
assert.Equal(t, tt.expectedCommissionRecords, len(records), "佣金记录数量不符合预期")
hasOneTimeCommission := false
for _, record := range records {
if record.CommissionSource == model.CommissionSourceOneTime {
hasOneTimeCommission = true
break
}
}
assert.Equal(t, tt.expectedOneTimeCommission, hasOneTimeCommission, "一次性佣金触发状态不符合预期")
if tt.expectedOneTimeCommission {
assert.True(t, updatedCard.FirstCommissionPaid, "首次佣金发放标记应为true")
} else {
assert.False(t, updatedCard.FirstCommissionPaid, "首次佣金发放标记应为false")
}
})
}
}
func TestCalculateCommission_Device_PurchaseOnBehalf(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
commissionRecordStore := postgres.NewCommissionRecordStore(tx, rdb)
shopStore := postgres.NewShopStore(tx, rdb)
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
shopSeriesOneTimeCommissionTierStore := postgres.NewShopSeriesOneTimeCommissionTierStore(tx)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
walletTransactionStore := postgres.NewWalletTransactionStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
statsStore := postgres.NewShopSeriesCommissionStatsStore(tx)
commissionStatsService := commission_stats.New(statsStore)
service := New(
tx,
commissionRecordStore,
shopStore,
shopSeriesAllocationStore,
shopSeriesOneTimeCommissionTierStore,
iotCardStore,
deviceStore,
walletStore,
walletTransactionStore,
orderStore,
orderItemStore,
packageStore,
commissionStatsService,
zap.NewNop(),
)
ctx := context.Background()
shop := &model.Shop{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
ShopName: "测试店铺",
ShopCode: "TEST002",
ContactName: "测试联系人",
ContactPhone: "13800000002",
}
require.NoError(t, tx.Create(shop).Error)
wallet := &model.Wallet{
ResourceType: "shop",
ResourceID: shop.ID,
WalletType: "commission",
Balance: 0,
Version: 1,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, tx.Create(wallet).Error)
allocation := &model.ShopSeriesAllocation{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
ShopID: shop.ID,
SeriesID: 1,
AllocatorShopID: 1,
BaseCommissionMode: model.CommissionModeFixed,
BaseCommissionValue: 5000,
EnableOneTimeCommission: true,
OneTimeCommissionTrigger: model.OneTimeCommissionTriggerAccumulatedRecharge,
OneTimeCommissionThreshold: 10000,
OneTimeCommissionType: model.OneTimeCommissionTypeFixed,
OneTimeCommissionMode: model.CommissionModeFixed,
OneTimeCommissionValue: 1000,
Status: 1,
}
require.NoError(t, tx.Create(allocation).Error)
device := &model.Device{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
DeviceNo: "DEV001",
ShopID: &shop.ID,
SeriesAllocationID: &allocation.ID,
AccumulatedRecharge: 0,
FirstCommissionPaid: false,
}
require.NoError(t, tx.Create(device).Error)
seriesID := allocation.SeriesID
tests := []struct {
name string
isPurchaseOnBehalf bool
expectedAccumulatedRecharge int64
expectedCommissionRecords int
expectedOneTimeCommission bool
}{
{
name: "普通订单_触发累计充值和一次性佣金",
isPurchaseOnBehalf: false,
expectedAccumulatedRecharge: 15000,
expectedCommissionRecords: 2,
expectedOneTimeCommission: true,
},
{
name: "代购订单_不触发累计充值和一次性佣金",
isPurchaseOnBehalf: true,
expectedAccumulatedRecharge: 0,
expectedCommissionRecords: 1,
expectedOneTimeCommission: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.NoError(t, tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]interface{}{
"accumulated_recharge": 0,
"first_commission_paid": false,
}).Error)
require.NoError(t, tx.Where("1=1").Delete(&model.CommissionRecord{}).Error)
require.NoError(t, tx.Where("1=1").Delete(&model.Order{}).Error)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
OrderNo: "ORD" + time.Now().Format("20060102150405"),
OrderType: model.OrderTypeDevice,
DeviceID: &device.ID,
BuyerType: model.BuyerTypeAgent,
BuyerID: shop.ID,
SellerShopID: &shop.ID,
SeriesID: &seriesID,
TotalAmount: 15000,
SellerCostPrice: 5000,
IsPurchaseOnBehalf: tt.isPurchaseOnBehalf,
CommissionStatus: model.CommissionStatusPending,
PaymentStatus: model.PaymentStatusPaid,
}
require.NoError(t, tx.Create(order).Error)
err := service.CalculateCommission(ctx, order.ID)
require.NoError(t, err)
var updatedDevice model.Device
require.NoError(t, tx.First(&updatedDevice, device.ID).Error)
assert.Equal(t, tt.expectedAccumulatedRecharge, updatedDevice.AccumulatedRecharge, "累计充值金额不符合预期")
var records []model.CommissionRecord
require.NoError(t, tx.Where("order_id = ?", order.ID).Find(&records).Error)
assert.Equal(t, tt.expectedCommissionRecords, len(records), "佣金记录数量不符合预期")
hasOneTimeCommission := false
for _, record := range records {
if record.CommissionSource == model.CommissionSourceOneTime {
hasOneTimeCommission = true
break
}
}
assert.Equal(t, tt.expectedOneTimeCommission, hasOneTimeCommission, "一次性佣金触发状态不符合预期")
if tt.expectedOneTimeCommission {
assert.True(t, updatedDevice.FirstCommissionPaid, "首次佣金发放标记应为true")
} else {
assert.False(t, updatedDevice.FirstCommissionPaid, "首次佣金发放标记应为false")
}
})
}
}

View File

@@ -27,6 +27,9 @@ type Service struct {
walletStore *postgres.WalletStore
purchaseValidationService *purchase_validation.Service
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
wechatPayment wechat.PaymentServiceInterface
queueClient *queue.Client
logger *zap.Logger
@@ -39,6 +42,9 @@ func New(
walletStore *postgres.WalletStore,
purchaseValidationService *purchase_validation.Service,
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
wechatPayment wechat.PaymentServiceInterface,
queueClient *queue.Client,
logger *zap.Logger,
@@ -50,6 +56,9 @@ func New(
walletStore: walletStore,
purchaseValidationService: purchaseValidationService,
allocationConfigStore: allocationConfigStore,
seriesAllocationStore: seriesAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
wechatPayment: wechatPayment,
queueClient: queueClient,
logger: logger,
@@ -78,6 +87,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
return nil, err
}
forceRechargeCheck := s.checkForceRechargeRequirement(validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
}
userID := middleware.GetUserIDFromContext(ctx)
configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID)
@@ -254,6 +268,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.IsPurchaseOnBehalf {
return errors.New(errors.CodeInvalidStatus, "代购订单无需支付")
}
var resourceType string
var resourceID uint
@@ -646,3 +664,206 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.
H5URL: result.H5URL,
}, nil
}
type ForceRechargeRequirement struct {
NeedForceRecharge bool
ForceRechargeAmount int64
TriggerType string
}
func (s *Service) checkForceRechargeRequirement(result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
if result.Allocation == nil {
return &ForceRechargeRequirement{NeedForceRecharge: false}
}
allocation := result.Allocation
if !allocation.EnableOneTimeCommission {
return &ForceRechargeRequirement{NeedForceRecharge: false}
}
var firstCommissionPaid bool
if result.Card != nil {
firstCommissionPaid = result.Card.FirstCommissionPaid
} else if result.Device != nil {
firstCommissionPaid = result.Device.FirstCommissionPaid
}
if firstCommissionPaid {
return &ForceRechargeRequirement{NeedForceRecharge: false}
}
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerSingleRecharge {
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: allocation.OneTimeCommissionThreshold,
TriggerType: model.OneTimeCommissionTriggerSingleRecharge,
}
}
if allocation.EnableForceRecharge {
forceAmount := allocation.ForceRechargeAmount
if forceAmount == 0 {
forceAmount = allocation.OneTimeCommissionThreshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: forceAmount,
TriggerType: model.OneTimeCommissionTriggerAccumulatedRecharge,
}
}
return &ForceRechargeRequirement{NeedForceRecharge: false}
}
func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRequest) (*dto.PurchaseCheckResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.OrderType == model.OrderTypeSingleCard {
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, req.ResourceID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, req.ResourceID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
if err != nil {
return nil, err
}
forceRechargeCheck := s.checkForceRechargeRequirement(validationResult)
response := &dto.PurchaseCheckResponse{
TotalPackageAmount: validationResult.TotalPrice,
NeedForceRecharge: forceRechargeCheck.NeedForceRecharge,
ForceRechargeAmount: forceRechargeCheck.ForceRechargeAmount,
ActualPayment: validationResult.TotalPrice,
WalletCredit: validationResult.TotalPrice,
}
if forceRechargeCheck.NeedForceRecharge {
if validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
response.ActualPayment = forceRechargeCheck.ForceRechargeAmount
response.WalletCredit = forceRechargeCheck.ForceRechargeAmount
response.Message = "首次购买需满足最低充值要求"
}
}
return response, nil
}
func (s *Service) CreatePurchaseOnBehalf(ctx context.Context, req *dto.CreatePurchaseOnBehalfRequest, operatorID uint) (*dto.OrderResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var resourceShopID *uint
var err error
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
if err != nil {
return nil, err
}
if validationResult.Card != nil {
resourceShopID = validationResult.Card.ShopID
}
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
if err != nil {
return nil, err
}
if validationResult.Device != nil {
resourceShopID = validationResult.Device.ShopID
}
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
if resourceShopID == nil || *resourceShopID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购")
}
buyerID := *resourceShopID
buyerAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, buyerID, validationResult.Allocation.SeriesID)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐系列的分配配置")
}
buyerCostPrice := utils.CalculateCostPrice(buyerAllocation, validationResult.TotalPrice)
configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID)
var seriesID *uint
var sellerShopID *uint
if validationResult.Allocation != nil {
seriesID = &validationResult.Allocation.SeriesID
sellerShopID = &validationResult.Allocation.ShopID
}
now := time.Now()
order := &model.Order{
BaseModel: model.BaseModel{
Creator: operatorID,
Updater: operatorID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
BuyerType: model.BuyerTypeAgent,
BuyerID: buyerID,
IotCardID: req.IotCardID,
DeviceID: req.DeviceID,
TotalAmount: buyerCostPrice,
PaymentMethod: model.PaymentMethodOffline,
PaymentStatus: model.PaymentStatusPaid,
PaidAt: &now,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: configVersion,
SeriesID: seriesID,
SellerShopID: sellerShopID,
SellerCostPrice: buyerCostPrice,
IsPurchaseOnBehalf: true,
}
var items []*model.OrderItem
for _, pkg := range validationResult.Packages {
item := &model.OrderItem{
BaseModel: model.BaseModel{
Creator: operatorID,
Updater: operatorID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: pkg.SuggestedRetailPrice,
Amount: pkg.SuggestedRetailPrice,
}
items = append(items, item)
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败")
}
for _, item := range items {
item.OrderID = order.ID
}
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
return s.activatePackage(ctx, tx, order)
})
if err != nil {
return nil, err
}
s.enqueueCommissionCalculation(ctx, order.ID)
return s.buildOrderResponse(order, items), nil
}

View File

@@ -126,7 +126,7 @@ func setupOrderTestEnv(t *testing.T) *testEnv {
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil, logger)
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger)
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
@@ -536,7 +536,7 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) {
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil, logger)
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger)
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
@@ -620,3 +620,533 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) {
assert.Equal(t, int64(0), usageCount)
})
}
func TestOrderService_ForceRechargeValidation(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
carrierStore := postgres.NewCarrierStore(tx)
shopStore := postgres.NewShopStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
ctx := context.Background()
carrier := &model.Carrier{
CarrierCode: "TEST_CARRIER_FR",
CarrierName: "测试运营商强充",
CarrierType: constants.CarrierTypeCMCC,
Status: constants.StatusEnabled,
}
require.NoError(t, carrierStore.Create(ctx, carrier))
shop := &model.Shop{
ShopName: "测试店铺FR",
ShopCode: "TEST_SHOP_FR",
Level: 1,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, shopStore.Create(ctx, shop))
series := &model.PackageSeries{
SeriesCode: "TEST_SERIES_FR",
SeriesName: "测试套餐系列强充",
Description: "测试用",
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageSeriesStore.Create(ctx, series))
allocation := &model.ShopSeriesAllocation{
ShopID: shop.ID,
SeriesID: series.ID,
Status: constants.StatusEnabled,
EnableOneTimeCommission: true,
OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge,
OneTimeCommissionThreshold: 20000,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
cheapPkg := &model.Package{
PackageCode: "TEST_PKG_CHEAP",
PackageName: "便宜套餐",
SeriesID: series.ID,
PackageType: "formal",
DurationMonths: 1,
DataAmountMB: 512,
SuggestedRetailPrice: 5000,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, cheapPkg))
expensivePkg := &model.Package{
PackageCode: "TEST_PKG_EXP",
PackageName: "高价套餐",
SeriesID: series.ID,
PackageType: "formal",
DurationMonths: 12,
DataAmountMB: 10240,
SuggestedRetailPrice: 25000,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, expensivePkg))
shopIDPtr := &shop.ID
card := &model.IotCard{
ICCID: "89860000000000000FR1",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
FirstCommissionPaid: false,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card))
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger)
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeAgent,
ShopID: shop.ID,
})
t.Run("强充验证-金额不足拒绝", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &card.ID,
PackageIDs: []uint{cheapPkg.ID},
}
_, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeForceRechargeRequired, appErr.Code)
})
t.Run("强充验证-金额足够通过", func(t *testing.T) {
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &card.ID,
PackageIDs: []uint{expensivePkg.ID},
}
resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, expensivePkg.SuggestedRetailPrice, resp.TotalAmount)
})
t.Run("已付佣金-跳过强充验证", func(t *testing.T) {
card2 := &model.IotCard{
ICCID: "89860000000000000FR2",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
FirstCommissionPaid: true,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card2))
req := &dto.CreateOrderRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &card2.ID,
PackageIDs: []uint{cheapPkg.ID},
}
resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
})
}
func TestOrderService_GetPurchaseCheck(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
carrierStore := postgres.NewCarrierStore(tx)
shopStore := postgres.NewShopStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
ctx := context.Background()
carrier := &model.Carrier{
CarrierCode: "TEST_CARRIER_PC",
CarrierName: "测试运营商预检",
CarrierType: constants.CarrierTypeCMCC,
Status: constants.StatusEnabled,
}
require.NoError(t, carrierStore.Create(ctx, carrier))
shop := &model.Shop{
ShopName: "测试店铺PC",
ShopCode: "TEST_SHOP_PC",
Level: 1,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, shopStore.Create(ctx, shop))
series := &model.PackageSeries{
SeriesCode: "TEST_SERIES_PC",
SeriesName: "测试套餐系列预检",
Description: "测试用",
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageSeriesStore.Create(ctx, series))
allocation := &model.ShopSeriesAllocation{
ShopID: shop.ID,
SeriesID: series.ID,
Status: constants.StatusEnabled,
EnableOneTimeCommission: true,
OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge,
OneTimeCommissionThreshold: 10000,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
pkg := &model.Package{
PackageCode: "TEST_PKG_PC",
PackageName: "测试套餐预检",
SeriesID: series.ID,
PackageType: "formal",
DurationMonths: 1,
DataAmountMB: 1024,
SuggestedRetailPrice: 5000,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, pkg))
shopIDPtr := &shop.ID
card := &model.IotCard{
ICCID: "89860000000000000PC1",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
FirstCommissionPaid: false,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card))
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger)
t.Run("预检-需要强充", func(t *testing.T) {
req := &dto.PurchaseCheckRequest{
OrderType: model.OrderTypeSingleCard,
ResourceID: card.ID,
PackageIDs: []uint{pkg.ID},
}
resp, err := orderSvc.GetPurchaseCheck(ctx, req)
require.NoError(t, err)
assert.Equal(t, pkg.SuggestedRetailPrice, resp.TotalPackageAmount)
assert.True(t, resp.NeedForceRecharge)
assert.Equal(t, allocation.OneTimeCommissionThreshold, resp.ForceRechargeAmount)
assert.Equal(t, allocation.OneTimeCommissionThreshold, resp.ActualPayment)
assert.NotEmpty(t, resp.Message)
})
t.Run("预检-无需强充", func(t *testing.T) {
card2 := &model.IotCard{
ICCID: "89860000000000000PC2",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
FirstCommissionPaid: true,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card2))
req := &dto.PurchaseCheckRequest{
OrderType: model.OrderTypeSingleCard,
ResourceID: card2.ID,
PackageIDs: []uint{pkg.ID},
}
resp, err := orderSvc.GetPurchaseCheck(ctx, req)
require.NoError(t, err)
assert.Equal(t, pkg.SuggestedRetailPrice, resp.TotalPackageAmount)
assert.False(t, resp.NeedForceRecharge)
assert.Equal(t, pkg.SuggestedRetailPrice, resp.ActualPayment)
assert.Empty(t, resp.Message)
})
}
func TestOrderService_CreatePurchaseOnBehalf(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
carrierStore := postgres.NewCarrierStore(tx)
shopStore := postgres.NewShopStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
ctx := context.Background()
carrier := &model.Carrier{
CarrierCode: "TEST_CARRIER_POB",
CarrierName: "测试运营商代购",
CarrierType: constants.CarrierTypeCMCC,
Status: constants.StatusEnabled,
}
require.NoError(t, carrierStore.Create(ctx, carrier))
shop := &model.Shop{
ShopName: "测试店铺POB",
ShopCode: "TEST_SHOP_POB",
Level: 1,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, shopStore.Create(ctx, shop))
series := &model.PackageSeries{
SeriesCode: "TEST_SERIES_POB",
SeriesName: "测试套餐系列代购",
Description: "测试用",
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageSeriesStore.Create(ctx, series))
allocation := &model.ShopSeriesAllocation{
ShopID: shop.ID,
SeriesID: series.ID,
AllocatorShopID: 0,
BaseCommissionMode: model.CommissionModePercent,
BaseCommissionValue: 100,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
pkg := &model.Package{
PackageCode: "TEST_PKG_POB",
PackageName: "测试套餐代购",
SeriesID: series.ID,
PackageType: "formal",
DurationMonths: 1,
DataAmountMB: 1024,
SuggestedRetailPrice: 10000,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, pkg))
shopIDPtr := &shop.ID
card := &model.IotCard{
ICCID: "89860000000000000POB",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card))
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger)
t.Run("代购订单创建成功", func(t *testing.T) {
req := &dto.CreatePurchaseOnBehalfRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &card.ID,
PackageIDs: []uint{pkg.ID},
}
resp, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, model.PaymentStatusPaid, resp.PaymentStatus)
assert.Equal(t, model.PaymentMethodOffline, resp.PaymentMethod)
assert.Equal(t, model.BuyerTypeAgent, resp.BuyerType)
assert.Equal(t, shop.ID, resp.BuyerID)
assert.NotNil(t, resp.PaidAt)
expectedCostPrice := pkg.SuggestedRetailPrice - (pkg.SuggestedRetailPrice * allocation.BaseCommissionValue / 1000)
assert.Equal(t, expectedCostPrice, resp.TotalAmount)
var usageCount int64
err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", resp.ID).Count(&usageCount).Error
require.NoError(t, err)
assert.Equal(t, int64(1), usageCount)
})
t.Run("代购订单-资源未分配拒绝", func(t *testing.T) {
cardNoShop := &model.IotCard{
ICCID: "89860000000000NOSHOP",
ShopID: nil,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, cardNoShop))
req := &dto.CreatePurchaseOnBehalfRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &cardNoShop.ID,
PackageIDs: []uint{pkg.ID},
}
_, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
})
}
func TestOrderService_WalletPay_PurchaseOnBehalf(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
deviceStore := postgres.NewDeviceStore(tx, rdb)
packageStore := postgres.NewPackageStore(tx)
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
carrierStore := postgres.NewCarrierStore(tx)
shopStore := postgres.NewShopStore(tx, rdb)
orderStore := postgres.NewOrderStore(tx, rdb)
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
walletStore := postgres.NewWalletStore(tx, rdb)
ctx := context.Background()
carrier := &model.Carrier{
CarrierCode: "TEST_CARRIER_WP",
CarrierName: "测试运营商WP",
CarrierType: constants.CarrierTypeCMCC,
Status: constants.StatusEnabled,
}
require.NoError(t, carrierStore.Create(ctx, carrier))
shop := &model.Shop{
ShopName: "测试店铺WP",
ShopCode: "TEST_SHOP_WP",
Level: 1,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, shopStore.Create(ctx, shop))
series := &model.PackageSeries{
SeriesCode: "TEST_SERIES_WP",
SeriesName: "测试套餐系列WP",
Description: "测试用",
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageSeriesStore.Create(ctx, series))
allocation := &model.ShopSeriesAllocation{
ShopID: shop.ID,
SeriesID: series.ID,
AllocatorShopID: 0,
BaseCommissionMode: model.CommissionModePercent,
BaseCommissionValue: 100,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
pkg := &model.Package{
PackageCode: "TEST_PKG_WP",
PackageName: "测试套餐WP",
SeriesID: series.ID,
PackageType: "formal",
DurationMonths: 1,
DataAmountMB: 1024,
SuggestedRetailPrice: 10000,
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, packageStore.Create(ctx, pkg))
wallet := &model.Wallet{
ResourceType: "shop",
ResourceID: shop.ID,
WalletType: "main",
Balance: 100000,
Version: 1,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, tx.Create(wallet).Error)
shopIDPtr := &shop.ID
card := &model.IotCard{
ICCID: "89860000000000000WP1",
ShopID: shopIDPtr,
CarrierID: carrier.ID,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
}
require.NoError(t, iotCardStore.Create(ctx, card))
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
logger := zap.NewNop()
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger)
t.Run("代购订单无法进行钱包支付", func(t *testing.T) {
req := &dto.CreatePurchaseOnBehalfRequest{
OrderType: model.OrderTypeSingleCard,
IotCardID: &card.ID,
PackageIDs: []uint{pkg.ID},
}
created, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1)
require.NoError(t, err)
err = orderSvc.WalletPay(ctx, created.ID, model.BuyerTypeAgent, shop.ID)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok)
assert.Equal(t, errors.CodeInvalidStatus, appErr.Code)
})
}

View File

@@ -0,0 +1,724 @@
package recharge
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ForceRechargeRequirement 强充要求信息
type ForceRechargeRequirement struct {
NeedForceRecharge bool `json:"need_force_recharge"` // 是否需要强充
ForceRechargeAmount int64 `json:"force_recharge_amount"` // 强充金额(分)
TriggerType string `json:"trigger_type"` // 触发类型: single_recharge/accumulated_recharge
MinAmount int64 `json:"min_amount"` // 最小充值金额
MaxAmount int64 `json:"max_amount"` // 最大充值金额
CurrentAccumulated int64 `json:"current_accumulated"` // 当前累计充值
Threshold int64 `json:"threshold"` // 佣金触发阈值
Message string `json:"message"` // 提示信息
FirstCommissionPaid bool `json:"first_commission_paid"` // 一次性佣金是否已发放
}
// Service 充值服务
// 负责充值订单的创建、预检、支付回调处理等业务逻辑
type Service struct {
db *gorm.DB
rechargeStore *postgres.RechargeStore
walletStore *postgres.WalletStore
walletTransactionStore *postgres.WalletTransactionStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
commissionRecordStore *postgres.CommissionRecordStore
logger *zap.Logger
}
// New 创建充值服务实例
func New(
db *gorm.DB,
rechargeStore *postgres.RechargeStore,
walletStore *postgres.WalletStore,
walletTransactionStore *postgres.WalletTransactionStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
commissionRecordStore *postgres.CommissionRecordStore,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
rechargeStore: rechargeStore,
walletStore: walletStore,
walletTransactionStore: walletTransactionStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
commissionRecordStore: commissionRecordStore,
logger: logger,
}
}
// Create 创建充值订单
// 验证资源、金额范围、强充要求,生成订单号
func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, userID uint) (*dto.RechargeResponse, error) {
// 1. 验证金额范围
if req.Amount < constants.RechargeMinAmount {
return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能低于1元")
}
if req.Amount > constants.RechargeMaxAmount {
return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能超过100000元")
}
// 2. 获取资源(卡或设备)
var wallet *model.Wallet
var err error
if req.ResourceType == "iot_card" {
wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "iot_card", req.ResourceID, "main")
} else if req.ResourceType == "device" {
wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "device", req.ResourceID, "main")
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型")
}
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
// 3. 验证强充要求
forceReq, err := s.checkForceRechargeRequirement(ctx, req.ResourceType, req.ResourceID)
if err != nil {
return nil, err
}
if forceReq.NeedForceRecharge && req.Amount != forceReq.ForceRechargeAmount {
return nil, errors.New(errors.CodeForceRechargeAmountMismatch,
fmt.Sprintf("必须充值%d分才能满足强充要求", forceReq.ForceRechargeAmount))
}
// 4. 生成充值订单号
rechargeNo := s.generateRechargeNo()
// 5. 创建充值订单
recharge := &model.RechargeRecord{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
UserID: userID,
WalletID: wallet.ID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentMethod: req.PaymentMethod,
Status: constants.RechargeStatusPending,
}
if err := s.rechargeStore.Create(ctx, recharge); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
s.logger.Info("创建充值订单成功",
zap.Uint("recharge_id", recharge.ID),
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Uint("user_id", userID),
)
return s.buildRechargeResponse(recharge), nil
}
// GetRechargeCheck 充值预检
// 返回强充要求、金额限制等信息
func (s *Service) GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*ForceRechargeRequirement, error) {
// 验证资源类型
if resourceType != "iot_card" && resourceType != "device" {
return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型")
}
// 验证资源是否存在
if resourceType == "iot_card" {
if _, err := s.iotCardStore.GetByID(ctx, resourceID); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
} else {
if _, err := s.deviceStore.GetByID(ctx, resourceID); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
return s.checkForceRechargeRequirement(ctx, resourceType, resourceID)
}
// GetByID 根据ID查询充值订单详情
// 支持数据权限过滤
func (s *Service) GetByID(ctx context.Context, id uint, userID uint) (*dto.RechargeResponse, error) {
recharge, err := s.rechargeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
// 数据权限检查:只能查看自己的充值订单
if recharge.UserID != userID {
return nil, errors.New(errors.CodeForbidden, "无权查看此充值订单")
}
return s.buildRechargeResponse(recharge), nil
}
// List 查询充值订单列表
// 支持分页、筛选、数据权限
func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID uint) (*dto.RechargeListResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
params := &postgres.ListRechargeParams{
Page: page,
PageSize: pageSize,
UserID: &userID, // 数据权限:只能查看自己的
}
if req.Status != nil {
params.Status = req.Status
}
if req.WalletID != nil {
params.WalletID = req.WalletID
}
if req.StartTime != nil {
params.StartTime = req.StartTime
}
if req.EndTime != nil {
params.EndTime = req.EndTime
}
recharges, total, err := s.rechargeStore.List(ctx, params)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单列表失败")
}
var list []*dto.RechargeResponse
for _, r := range recharges {
list = append(list, s.buildRechargeResponse(r))
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.RechargeListResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// HandlePaymentCallback 支付回调处理
// 支持幂等性检查、事务处理、更新余额、触发佣金
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
// 1. 查询充值订单
recharge, err := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
if recharge == nil {
return errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
}
// 2. 幂等性检查:已支付则直接返回成功
if recharge.Status == constants.RechargeStatusPaid || recharge.Status == constants.RechargeStatusCompleted {
s.logger.Info("充值订单已支付,跳过处理",
zap.String("recharge_no", rechargeNo),
zap.Int("status", recharge.Status),
)
return nil
}
// 3. 检查订单状态是否允许支付
if recharge.Status != constants.RechargeStatusPending {
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
// 4. 获取钱包信息
wallet, err := s.walletStore.GetByID(ctx, recharge.WalletID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
// 5. 获取钱包对应的资源类型和ID
resourceType := wallet.ResourceType
resourceID := wallet.ResourceID
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 6.1 更新充值订单状态(带状态检查,实现乐观锁)
oldStatus := constants.RechargeStatusPending
if err := s.rechargeStore.UpdateStatus(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
if err == gorm.ErrRecordNotFound {
// 状态已变更,幂等处理
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
}
// 6.2 更新支付信息
if err := s.rechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
}
// 6.3 增加钱包余额(使用乐观锁)
balanceBefore := wallet.Balance
result := tx.Model(&model.Wallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance + ?", recharge.Amount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包版本冲突,请重试")
}
// 6.4 创建钱包交易记录
remark := "钱包充值"
refType := "recharge"
transaction := &model.WalletTransaction{
WalletID: wallet.ID,
UserID: recharge.UserID,
TransactionType: "recharge",
Amount: recharge.Amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore + recharge.Amount,
Status: 1,
ReferenceType: &refType,
ReferenceID: &recharge.ID,
Remark: &remark,
Creator: recharge.UserID,
}
if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
// 6.5 更新累计充值
if err := s.updateAccumulatedRechargeInTx(ctx, tx, resourceType, resourceID, recharge.Amount); err != nil {
return err
}
// 6.6 触发一次性佣金判断
if err := s.triggerOneTimeCommissionIfNeededInTx(ctx, tx, resourceType, resourceID, recharge.Amount, recharge.UserID); err != nil {
return err
}
// 6.7 更新充值订单状态为已完成
if err := tx.Model(&model.RechargeRecord{}).
Where("id = ?", recharge.ID).
Update("status", constants.RechargeStatusCompleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单完成状态失败")
}
return nil
})
if err != nil {
return err
}
s.logger.Info("充值支付回调处理成功",
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", recharge.Amount),
zap.String("resource_type", resourceType),
zap.Uint("resource_id", resourceID),
)
return nil
}
// checkForceRechargeRequirement 检查强充要求
// 根据资源类型和ID检查是否需要强制充值指定金额
func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceType string, resourceID uint) (*ForceRechargeRequirement, error) {
result := &ForceRechargeRequirement{
NeedForceRecharge: false,
MinAmount: constants.RechargeMinAmount,
MaxAmount: constants.RechargeMaxAmount,
Message: "无强充要求,可自由充值",
}
var seriesAllocationID *uint
var accumulatedRecharge int64
var firstCommissionPaid bool
// 1. 查询资源信息
if resourceType == "iot_card" {
card, err := s.iotCardStore.GetByID(ctx, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
seriesAllocationID = card.SeriesAllocationID
accumulatedRecharge = card.AccumulatedRecharge
firstCommissionPaid = card.FirstCommissionPaid
} else if resourceType == "device" {
device, err := s.deviceStore.GetByID(ctx, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
seriesAllocationID = device.SeriesAllocationID
accumulatedRecharge = device.AccumulatedRecharge
firstCommissionPaid = device.FirstCommissionPaid
}
result.CurrentAccumulated = accumulatedRecharge
result.FirstCommissionPaid = firstCommissionPaid
// 2. 如果没有系列分配,无强充要求
if seriesAllocationID == nil {
return result, nil
}
// 3. 查询系列分配配置
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *seriesAllocationID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return result, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败")
}
// 4. 如果未启用一次性佣金,无强充要求
if !allocation.EnableOneTimeCommission {
return result, nil
}
result.Threshold = allocation.OneTimeCommissionThreshold
result.TriggerType = allocation.OneTimeCommissionTrigger
// 5. 如果一次性佣金已发放,无强充要求
if firstCommissionPaid {
result.Message = "一次性佣金已发放,无强充要求"
return result, nil
}
// 6. 根据触发类型判断强充要求
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerSingleRecharge {
// 首次充值触发:必须充值阈值金额
result.NeedForceRecharge = true
result.ForceRechargeAmount = allocation.OneTimeCommissionThreshold
result.Message = fmt.Sprintf("首次充值必须充值%d分", allocation.OneTimeCommissionThreshold)
} else if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
// 累计充值触发:检查是否启用强充
if allocation.EnableForceRecharge {
result.NeedForceRecharge = true
// 强充金额优先使用配置值,否则使用阈值
if allocation.ForceRechargeAmount > 0 {
result.ForceRechargeAmount = allocation.ForceRechargeAmount
} else {
result.ForceRechargeAmount = allocation.OneTimeCommissionThreshold
}
result.Message = fmt.Sprintf("每次充值必须充值%d分", result.ForceRechargeAmount)
} else {
result.Message = "累计充值模式,可自由充值"
}
}
return result, nil
}
// updateAccumulatedRechargeInTx 更新累计充值(事务内使用)
// 原子操作更新卡或设备的累计充值金额
func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, amount int64) error {
if resourceType == "iot_card" {
result := tx.Model(&model.IotCard{}).
Where("id = ?", resourceID).
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount))
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡累计充值失败")
}
} else if resourceType == "device" {
result := tx.Model(&model.Device{}).
Where("id = ?", resourceID).
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount))
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新设备累计充值失败")
}
}
return nil
}
// triggerOneTimeCommissionIfNeededInTx 触发一次性佣金(事务内使用)
// 检查是否满足一次性佣金触发条件,满足则创建佣金记录并入账
func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, rechargeAmount int64, userID uint) error {
var seriesAllocationID *uint
var accumulatedRecharge int64
var firstCommissionPaid bool
var shopID *uint
// 1. 查询资源当前状态(需要从数据库重新查询以获取更新后的累计充值)
if resourceType == "iot_card" {
var card model.IotCard
if err := tx.First(&card, resourceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
seriesAllocationID = card.SeriesAllocationID
accumulatedRecharge = card.AccumulatedRecharge
firstCommissionPaid = card.FirstCommissionPaid
shopID = card.ShopID
} else if resourceType == "device" {
var device model.Device
if err := tx.First(&device, resourceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
seriesAllocationID = device.SeriesAllocationID
accumulatedRecharge = device.AccumulatedRecharge
firstCommissionPaid = device.FirstCommissionPaid
shopID = device.ShopID
}
// 2. 如果没有系列分配或已发放佣金,跳过
if seriesAllocationID == nil || firstCommissionPaid {
return nil
}
// 3. 如果没有归属店铺,无法发放佣金
if shopID == nil {
s.logger.Warn("资源未归属店铺,无法发放一次性佣金",
zap.String("resource_type", resourceType),
zap.Uint("resource_id", resourceID),
)
return nil
}
// 4. 查询系列分配配置
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *seriesAllocationID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败")
}
// 5. 如果未启用一次性佣金,跳过
if !allocation.EnableOneTimeCommission {
return nil
}
// 6. 根据触发类型判断是否满足条件
var rechargeAmountToCheck int64
switch allocation.OneTimeCommissionTrigger {
case model.OneTimeCommissionTriggerSingleRecharge:
rechargeAmountToCheck = rechargeAmount
case model.OneTimeCommissionTriggerAccumulatedRecharge:
rechargeAmountToCheck = accumulatedRecharge
default:
return nil
}
// 7. 检查是否达到阈值
if rechargeAmountToCheck < allocation.OneTimeCommissionThreshold {
return nil
}
// 8. 计算佣金金额
commissionAmount := s.calculateOneTimeCommission(allocation, rechargeAmount)
if commissionAmount <= 0 {
return nil
}
// 9. 查询店铺的佣金钱包
var commissionWallet model.Wallet
if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", *shopID, "commission").
First(&commissionWallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Warn("店铺佣金钱包不存在,跳过佣金发放",
zap.Uint("shop_id", *shopID),
)
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺佣金钱包失败")
}
// 10. 创建佣金记录
var iotCardID, deviceID *uint
if resourceType == "iot_card" {
iotCardID = &resourceID
} else {
deviceID = &resourceID
}
commissionRecord := &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
ShopID: *shopID,
IotCardID: iotCardID,
DeviceID: deviceID,
CommissionSource: model.CommissionSourceOneTime,
Amount: commissionAmount,
Status: model.CommissionStatusReleased,
Remark: "钱包充值触发一次性佣金",
}
if err := tx.Create(commissionRecord).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金记录失败")
}
// 11. 佣金入账到店铺佣金钱包
balanceBefore := commissionWallet.Balance
result := tx.Model(&model.Wallet{}).
Where("id = ? AND version = ?", commissionWallet.ID, commissionWallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance + ?", commissionAmount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新佣金钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "佣金钱包版本冲突,请重试")
}
// 12. 更新佣金记录的入账后余额
now := time.Now()
if err := tx.Model(commissionRecord).Updates(map[string]any{
"balance_after": balanceBefore + commissionAmount,
"released_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
}
// 13. 创建佣金钱包交易记录
remark := "一次性佣金入账(充值触发)"
refType := "commission"
commissionTransaction := &model.WalletTransaction{
WalletID: commissionWallet.ID,
UserID: userID,
TransactionType: "commission",
Amount: commissionAmount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore + commissionAmount,
Status: 1,
ReferenceType: &refType,
ReferenceID: &commissionRecord.ID,
Remark: &remark,
Creator: userID,
}
if err := tx.Create(commissionTransaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金钱包交易记录失败")
}
// 14. 标记一次性佣金已发放
if resourceType == "iot_card" {
if err := tx.Model(&model.IotCard{}).Where("id = ?", resourceID).
Update("first_commission_paid", true).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败")
}
} else {
if err := tx.Model(&model.Device{}).Where("id = ?", resourceID).
Update("first_commission_paid", true).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败")
}
}
s.logger.Info("一次性佣金发放成功",
zap.String("resource_type", resourceType),
zap.Uint("resource_id", resourceID),
zap.Uint("shop_id", *shopID),
zap.Int64("commission_amount", commissionAmount),
)
return nil
}
// calculateOneTimeCommission 计算一次性佣金金额
func (s *Service) calculateOneTimeCommission(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
if allocation.OneTimeCommissionType == model.OneTimeCommissionTypeFixed {
// 固定佣金
if allocation.OneTimeCommissionMode == model.CommissionModeFixed {
return allocation.OneTimeCommissionValue
} else if allocation.OneTimeCommissionMode == model.CommissionModePercent {
// 百分比佣金(千分比)
return orderAmount * allocation.OneTimeCommissionValue / 1000
}
}
// 梯度佣金在此不处理,由 commission_calculation 服务处理
return 0
}
// generateRechargeNo 生成充值订单号
// 格式: RCH + 14位时间戳 + 6位随机数
func (s *Service) generateRechargeNo() string {
now := time.Now()
timestamp := now.Format("20060102150405")
randomNum := rand.Intn(1000000)
return fmt.Sprintf("RCH%s%06d", timestamp, randomNum)
}
// buildRechargeResponse 构建充值订单响应
func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.RechargeResponse {
statusText := ""
switch recharge.Status {
case constants.RechargeStatusPending:
statusText = "待支付"
case constants.RechargeStatusPaid:
statusText = "已支付"
case constants.RechargeStatusCompleted:
statusText = "已完成"
case constants.RechargeStatusClosed:
statusText = "已关闭"
case constants.RechargeStatusRefunded:
statusText = "已退款"
}
return &dto.RechargeResponse{
ID: recharge.ID,
RechargeNo: recharge.RechargeNo,
UserID: recharge.UserID,
WalletID: recharge.WalletID,
Amount: recharge.Amount,
PaymentMethod: recharge.PaymentMethod,
PaymentChannel: recharge.PaymentChannel,
PaymentTransactionID: recharge.PaymentTransactionID,
Status: recharge.Status,
StatusText: statusText,
PaidAt: recharge.PaidAt,
CompletedAt: recharge.CompletedAt,
CreatedAt: recharge.CreatedAt,
UpdatedAt: recharge.UpdatedAt,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type RechargeStore struct {
db *gorm.DB
redis *redis.Client
}
// NewRechargeStore 创建充值订单 Store 实例
func NewRechargeStore(db *gorm.DB, redis *redis.Client) *RechargeStore {
return &RechargeStore{
db: db,
redis: redis,
}
}
// Create 创建充值订单
func (s *RechargeStore) Create(ctx context.Context, recharge *model.RechargeRecord) error {
return s.db.WithContext(ctx).Create(recharge).Error
}
// GetByRechargeNo 根据充值订单号查询充值订单
// 不存在时返回 nil, nil
func (s *RechargeStore) GetByRechargeNo(ctx context.Context, rechargeNo string) (*model.RechargeRecord, error) {
var recharge model.RechargeRecord
err := s.db.WithContext(ctx).Where("recharge_no = ?", rechargeNo).First(&recharge).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &recharge, nil
}
// GetByID 根据 ID 查询充值订单
func (s *RechargeStore) GetByID(ctx context.Context, id uint) (*model.RechargeRecord, error) {
var recharge model.RechargeRecord
if err := s.db.WithContext(ctx).First(&recharge, id).Error; err != nil {
return nil, err
}
return &recharge, nil
}
// ListRechargeParams 充值订单列表查询参数
type ListRechargeParams struct {
Page int // 页码(从 1 开始)
PageSize int // 每页数量
UserID *uint // 用户 ID 筛选
WalletID *uint // 钱包 ID 筛选
Status *int // 状态筛选
StartTime *time.Time // 开始时间
EndTime *time.Time // 结束时间
}
// List 查询充值订单列表(支持分页和筛选)
func (s *RechargeStore) List(ctx context.Context, params *ListRechargeParams) ([]*model.RechargeRecord, int64, error) {
var recharges []*model.RechargeRecord
var total int64
query := s.db.WithContext(ctx).Model(&model.RechargeRecord{})
// 应用筛选条件
if params.UserID != nil {
query = query.Where("user_id = ?", *params.UserID)
}
if params.WalletID != nil {
query = query.Where("wallet_id = ?", *params.WalletID)
}
if params.Status != nil {
query = query.Where("status = ?", *params.Status)
}
if params.StartTime != nil {
query = query.Where("created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
query = query.Where("created_at <= ?", *params.EndTime)
}
// 统计总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
page := params.Page
if page < 1 {
page = 1
}
pageSize := params.PageSize
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&recharges).Error; err != nil {
return nil, 0, err
}
return recharges, total, nil
}
// UpdateStatus 更新充值订单状态(支持乐观锁检查)
// oldStatus: 原状态(用于乐观锁检查,传 nil 则跳过检查)
// newStatus: 新状态
// paidAt: 支付时间(状态变为已支付时传入)
// completedAt: 完成时间(状态变为已完成时传入)
func (s *RechargeStore) UpdateStatus(ctx context.Context, id uint, oldStatus *int, newStatus int, paidAt *time.Time, completedAt *time.Time) error {
updates := map[string]interface{}{
"status": newStatus,
}
if paidAt != nil {
updates["paid_at"] = paidAt
}
if completedAt != nil {
updates["completed_at"] = completedAt
}
query := s.db.WithContext(ctx).Model(&model.RechargeRecord{}).Where("id = ?", id)
// 乐观锁检查
if oldStatus != nil {
query = query.Where("status = ?", *oldStatus)
}
result := query.Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// UpdatePaymentInfo 更新支付信息
func (s *RechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, paymentChannel *string, paymentTransactionID *string) error {
updates := map[string]interface{}{}
if paymentChannel != nil {
updates["payment_channel"] = paymentChannel
}
if paymentTransactionID != nil {
updates["payment_transaction_id"] = paymentTransactionID
}
if len(updates) == 0 {
return nil
}
result := s.db.WithContext(ctx).Model(&model.RechargeRecord{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}

View File

@@ -0,0 +1,395 @@
package postgres
import (
"context"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRechargeStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
recharge := &model.RechargeRecord{
UserID: 100,
WalletID: 200,
RechargeNo: "RCH20260131120000000001",
Amount: 10000,
PaymentMethod: "wechat",
Status: 1, // 待支付
}
err := s.Create(ctx, recharge)
require.NoError(t, err)
assert.NotZero(t, recharge.ID)
assert.NotZero(t, recharge.CreatedAt)
assert.NotZero(t, recharge.UpdatedAt)
}
func TestRechargeStore_GetByRechargeNo(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
rechargeNo := "RCH20260131120000000002"
recharge := &model.RechargeRecord{
UserID: 101,
WalletID: 201,
RechargeNo: rechargeNo,
Amount: 20000,
PaymentMethod: "alipay",
Status: 1,
}
require.NoError(t, s.Create(ctx, recharge))
t.Run("查询存在的充值订单", func(t *testing.T) {
result, err := s.GetByRechargeNo(ctx, rechargeNo)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, recharge.ID, result.ID)
assert.Equal(t, recharge.UserID, result.UserID)
assert.Equal(t, recharge.Amount, result.Amount)
})
t.Run("查询不存在的充值订单返回 nil", func(t *testing.T) {
result, err := s.GetByRechargeNo(ctx, "NOT_EXISTS_RECHARGE_NO")
require.NoError(t, err)
assert.Nil(t, result)
})
}
func TestRechargeStore_GetByID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
recharge := &model.RechargeRecord{
UserID: 102,
WalletID: 202,
RechargeNo: "RCH20260131120000000003",
Amount: 30000,
PaymentMethod: "wechat",
Status: 2, // 已支付
}
require.NoError(t, s.Create(ctx, recharge))
t.Run("查询存在的充值订单", func(t *testing.T) {
result, err := s.GetByID(ctx, recharge.ID)
require.NoError(t, err)
assert.Equal(t, recharge.RechargeNo, result.RechargeNo)
assert.Equal(t, recharge.Status, result.Status)
})
t.Run("查询不存在的充值订单", func(t *testing.T) {
_, err := s.GetByID(ctx, 99999)
require.Error(t, err)
})
}
func TestRechargeStore_List(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
// 创建测试数据
now := time.Now()
yesterday := now.Add(-24 * time.Hour)
tomorrow := now.Add(24 * time.Hour)
recharges := []*model.RechargeRecord{
{UserID: 200, WalletID: 300, RechargeNo: "RCH20260131120000000010", Amount: 10000, PaymentMethod: "wechat", Status: 1},
{UserID: 200, WalletID: 300, RechargeNo: "RCH20260131120000000011", Amount: 20000, PaymentMethod: "alipay", Status: 2},
{UserID: 201, WalletID: 301, RechargeNo: "RCH20260131120000000012", Amount: 30000, PaymentMethod: "wechat", Status: 3},
{UserID: 201, WalletID: 302, RechargeNo: "RCH20260131120000000013", Amount: 40000, PaymentMethod: "alipay", Status: 1},
}
for _, r := range recharges {
require.NoError(t, s.Create(ctx, r))
}
t.Run("查询所有充值订单", func(t *testing.T) {
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(4))
assert.GreaterOrEqual(t, len(result), 4)
})
t.Run("按用户 ID 筛选", func(t *testing.T) {
userID := uint(200)
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
UserID: &userID,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(2))
for _, r := range result {
assert.Equal(t, uint(200), r.UserID)
}
})
t.Run("按钱包 ID 筛选", func(t *testing.T) {
walletID := uint(300)
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
WalletID: &walletID,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(2))
for _, r := range result {
assert.Equal(t, uint(300), r.WalletID)
}
})
t.Run("按状态筛选", func(t *testing.T) {
status := 1 // 待支付
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
Status: &status,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(2))
for _, r := range result {
assert.Equal(t, 1, r.Status)
}
})
t.Run("按时间范围筛选", func(t *testing.T) {
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
StartTime: &yesterday,
EndTime: &tomorrow,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(4))
for _, r := range result {
assert.True(t, r.CreatedAt.After(yesterday) || r.CreatedAt.Equal(yesterday))
assert.True(t, r.CreatedAt.Before(tomorrow) || r.CreatedAt.Equal(tomorrow))
}
})
t.Run("组合筛选条件", func(t *testing.T) {
userID := uint(201)
status := 1
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
UserID: &userID,
Status: &status,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, r := range result {
assert.Equal(t, uint(201), r.UserID)
assert.Equal(t, 1, r.Status)
}
})
t.Run("分页查询", func(t *testing.T) {
params := &ListRechargeParams{
Page: 1,
PageSize: 2,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(4))
assert.LessOrEqual(t, len(result), 2)
})
t.Run("默认分页参数", func(t *testing.T) {
params := &ListRechargeParams{
Page: 0, // 无效值,应使用默认值 1
PageSize: 0, // 无效值,应使用默认值 20
}
result, _, err := s.List(ctx, params)
require.NoError(t, err)
assert.NotNil(t, result)
})
t.Run("按 ID 降序排列", func(t *testing.T) {
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
}
result, _, err := s.List(ctx, params)
require.NoError(t, err)
require.GreaterOrEqual(t, len(result), 2)
// 验证降序排列
for i := 0; i < len(result)-1; i++ {
assert.GreaterOrEqual(t, result[i].ID, result[i+1].ID)
}
})
}
func TestRechargeStore_UpdateStatus(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
recharge := &model.RechargeRecord{
UserID: 300,
WalletID: 400,
RechargeNo: "RCH20260131120000000020",
Amount: 50000,
PaymentMethod: "wechat",
Status: 1, // 待支付
}
require.NoError(t, s.Create(ctx, recharge))
t.Run("更新状态为已支付(无乐观锁)", func(t *testing.T) {
now := time.Now()
err := s.UpdateStatus(ctx, recharge.ID, nil, 2, &now, nil)
require.NoError(t, err)
updated, err := s.GetByID(ctx, recharge.ID)
require.NoError(t, err)
assert.Equal(t, 2, updated.Status)
assert.NotNil(t, updated.PaidAt)
})
t.Run("更新状态为已完成(带乐观锁)", func(t *testing.T) {
oldStatus := 2
now := time.Now()
err := s.UpdateStatus(ctx, recharge.ID, &oldStatus, 3, nil, &now)
require.NoError(t, err)
updated, err := s.GetByID(ctx, recharge.ID)
require.NoError(t, err)
assert.Equal(t, 3, updated.Status)
assert.NotNil(t, updated.CompletedAt)
})
t.Run("乐观锁检查失败", func(t *testing.T) {
oldStatus := 1 // 当前状态是 3不是 1
err := s.UpdateStatus(ctx, recharge.ID, &oldStatus, 4, nil, nil)
require.Error(t, err)
})
t.Run("更新不存在的充值订单", func(t *testing.T) {
err := s.UpdateStatus(ctx, 99999, nil, 2, nil, nil)
require.Error(t, err)
})
}
func TestRechargeStore_UpdatePaymentInfo(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
recharge := &model.RechargeRecord{
UserID: 400,
WalletID: 500,
RechargeNo: "RCH20260131120000000030",
Amount: 60000,
PaymentMethod: "wechat",
Status: 1,
}
require.NoError(t, s.Create(ctx, recharge))
t.Run("更新支付渠道和交易号", func(t *testing.T) {
channel := "wechat_jsapi"
transactionID := "WX1234567890"
err := s.UpdatePaymentInfo(ctx, recharge.ID, &channel, &transactionID)
require.NoError(t, err)
updated, err := s.GetByID(ctx, recharge.ID)
require.NoError(t, err)
require.NotNil(t, updated.PaymentChannel)
assert.Equal(t, "wechat_jsapi", *updated.PaymentChannel)
require.NotNil(t, updated.PaymentTransactionID)
assert.Equal(t, "WX1234567890", *updated.PaymentTransactionID)
})
t.Run("只更新支付渠道", func(t *testing.T) {
channel := "alipay_h5"
err := s.UpdatePaymentInfo(ctx, recharge.ID, &channel, nil)
require.NoError(t, err)
updated, err := s.GetByID(ctx, recharge.ID)
require.NoError(t, err)
require.NotNil(t, updated.PaymentChannel)
assert.Equal(t, "alipay_h5", *updated.PaymentChannel)
})
t.Run("只更新交易号", func(t *testing.T) {
transactionID := "ALI9876543210"
err := s.UpdatePaymentInfo(ctx, recharge.ID, nil, &transactionID)
require.NoError(t, err)
updated, err := s.GetByID(ctx, recharge.ID)
require.NoError(t, err)
require.NotNil(t, updated.PaymentTransactionID)
assert.Equal(t, "ALI9876543210", *updated.PaymentTransactionID)
})
t.Run("不更新任何字段", func(t *testing.T) {
err := s.UpdatePaymentInfo(ctx, recharge.ID, nil, nil)
require.NoError(t, err)
})
t.Run("更新不存在的充值订单", func(t *testing.T) {
channel := "test_channel"
err := s.UpdatePaymentInfo(ctx, 99999, &channel, nil)
require.Error(t, err)
})
}
func TestRechargeStore_ConcurrentOperations(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
s := NewRechargeStore(tx, rdb)
ctx := context.Background()
// 创建多个充值订单
for i := 0; i < 10; i++ {
recharge := &model.RechargeRecord{
UserID: uint(500 + i),
WalletID: uint(600 + i),
RechargeNo: "RCH20260131120000000040" + string(rune('0'+i)),
Amount: int64(10000 * (i + 1)),
PaymentMethod: "wechat",
Status: 1,
}
require.NoError(t, s.Create(ctx, recharge))
}
// 验证查询
params := &ListRechargeParams{
Page: 1,
PageSize: 20,
}
result, total, err := s.List(ctx, params)
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(10))
assert.GreaterOrEqual(t, len(result), 10)
}

View File

@@ -0,0 +1,3 @@
-- 回滚: 删除 tb_order 表的 is_purchase_on_behalf 字段
ALTER TABLE tb_order DROP COLUMN IF EXISTS is_purchase_on_behalf;

View File

@@ -0,0 +1,6 @@
-- 为 tb_order 表添加代购标志字段
-- 用于标识订单是否为代购订单
ALTER TABLE tb_order ADD COLUMN is_purchase_on_behalf BOOLEAN DEFAULT false;
COMMENT ON COLUMN tb_order.is_purchase_on_behalf IS '是否为代购订单';

View File

@@ -0,0 +1,4 @@
-- 回滚: 删除 tb_shop_series_allocation 表的强充配置字段
ALTER TABLE tb_shop_series_allocation DROP COLUMN IF EXISTS enable_force_recharge;
ALTER TABLE tb_shop_series_allocation DROP COLUMN IF EXISTS force_recharge_amount;

View File

@@ -0,0 +1,8 @@
-- 为 tb_shop_series_allocation 表添加强充配置字段
-- 用于支持强制充值功能
ALTER TABLE tb_shop_series_allocation ADD COLUMN enable_force_recharge BOOLEAN DEFAULT false;
ALTER TABLE tb_shop_series_allocation ADD COLUMN force_recharge_amount BIGINT DEFAULT 0;
COMMENT ON COLUMN tb_shop_series_allocation.enable_force_recharge IS '是否启用强制充值';
COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_amount IS '强制充值金额(分)';

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-31

View File

@@ -0,0 +1,724 @@
## Context
### 当前系统状态
**钱包系统现状**
- 已有 Wallet、WalletTransaction 模型和 Store 层实现
- 已有 RechargeRecord 模型定义,但完全未使用(表已创建,无 Store/Service/Handler
- 个人客户只能通过购买套餐间接充值钱包,无法直接充值
- 订单支付采用"强充"机制:用户必须通过购买套餐来充值,不支持纯钱包充值
**订单系统现状**
- Order 模型已支持单卡购买和设备购买两种类型
- 支持 wallet、wechat、alipay 三种支付方式
- 订单创建 → 支付 → 激活套餐 → 触发佣金计算的完整流程已实现
- 支付回调处理支持微信和支付宝
**佣金计算现状**
- 支持成本价差佣金和一次性佣金两种类型
- 一次性佣金支持首次充值和累计充值两种触发方式
- 订单支付成功后自动更新 `AccumulatedRecharge`
- **存在问题**:所有订单(包括代购订单)都会更新累计充值,都会触发一次性佣金
**系列分配配置现状**
- ShopSeriesAllocation 已支持一次性佣金配置(类型、触发方式、阈值、模式、值)
- 支持梯度佣金配置(独立表 ShopSeriesOneTimeCommissionTier
- **缺失**:没有强充金额配置字段
### 业务需求背景
1. **线下收款场景**:平台/代理线下已收款,需要为代理代购套餐,但系统无法支持
2. **个人客户充值体验**:用户想充值钱包但不想立即购买套餐,当前系统无法满足
3. **强充机制完善**
- 首次充值需强制充值阈值金额如100元
- 累计充值可选启用强充每次充值固定金额如100元避免"买39元套餐却要充1000元"的不合理情况
4. **佣金计算准确性**:代购订单不应触发一次性佣金,因为不是客户真实充值
### 约束条件
- **技术栈**:必须使用 Fiber + GORM + Viper + Zap + Asynq禁止外键和 GORM 关联
- **架构分层**Handler → Service → Store → Model严格分层
- **性能要求**:预检接口 < 100ms充值创建 < 200ms支付回调 < 500ms
- **测试要求**:核心业务逻辑覆盖率 ≥ 90%
- **向后兼容**:新增字段必须有默认值,不能破坏现有订单和佣金计算逻辑
---
## Goals / Non-Goals
### Goals
1. **实现钱包充值系统**
- 个人客户可以直接给卡/设备钱包充值(不购买套餐)
- 充值支持微信支付和支付宝支付
- 充值成功自动更新钱包余额和累计充值金额
- 充值达到阈值触发一次性佣金
2. **实现强充预检机制**
- 提供充值预检接口,告知用户强充要求
- 提供套餐购买预检接口,计算实际支付金额
- 创建订单/充值订单时后端强制验证,防止前端绕过
3. **实现代购订单功能**
- 平台/代理可为其他代理代购套餐
- 支持线下支付方式offline
- 代购订单不触发一次性佣金,不更新累计充值
- 代购订单仍计算差价佣金
4. **扩展强充配置**
- ShopSeriesAllocation 增加强充配置字段
- 首次充值:强充金额 = 阈值(不可配置)
- 累计充值:可选启用强充,配置固定充值金额
5. **修复佣金计算逻辑**
- 代购订单不累加 AccumulatedRecharge
- 代购订单不触发一次性佣金
- 充值订单正常触发佣金
### Non-Goals
1. **不支持钱包转账**:用户钱包间转账不在本次范围
2. **不支持退款流程**:充值退款、订单退款流程留待后续实现
3. **不修改梯度佣金逻辑**:梯度佣金计算保持不变
4. **不修改差价佣金逻辑**:成本价差计算保持不变
5. **不支持企业客户钱包**:企业客户无钱包,本次不涉及
6. **不实现充值优惠**:充值满减、赠送等营销功能不在范围
---
## Decisions
### Decision 1: 数据库模型设计
**选择**:使用现有 RechargeRecord 表,新增必要字段到 Order 和 ShopSeriesAllocation 表。
**理由**
- RechargeRecord 表已存在,结构合理,只需激活使用
- Order 表新增 `is_purchase_on_behalf BOOLEAN DEFAULT false`
- ShopSeriesAllocation 表新增 `enable_force_recharge BOOLEAN DEFAULT false``force_recharge_amount BIGINT DEFAULT 0`
**备选方案及拒绝原因**
- ~~创建新表 PurchaseOnBehalfOrder~~增加复杂度Order 表扩展一个字段即可
- ~~使用订单备注字段标识代购~~:不利于查询和统计,需要独立字段
**实现细节**
```sql
-- 迁移文件 1: 订单表增加代购标识
ALTER TABLE tb_order
ADD COLUMN is_purchase_on_behalf BOOLEAN DEFAULT false
COMMENT '是否为代购订单(平台/代理代购)';
-- 迁移文件 2: 系列分配表增加强充配置
ALTER TABLE tb_shop_series_allocation
ADD COLUMN enable_force_recharge BOOLEAN DEFAULT false
COMMENT '是否启用强充(累计充值时可选)',
ADD COLUMN force_recharge_amount BIGINT DEFAULT 0
COMMENT '强充金额(分,0表示使用阈值金额)';
```
---
### Decision 2: 强充验证策略
**选择**:前端预检 + 后端强制验证的双重保障策略。
**架构**
```
前端调用预检接口 → 获取强充要求 → 显示给用户
用户提交订单/充值 → 后端验证金额是否符合强充要求
验证不通过 → 拒绝创建订单,返回错误
验证通过 → 创建订单/充值订单
```
**预检接口设计**
1. **钱包充值预检**`GET /api/h5/wallets/recharge-check?resource_type=iot_card&resource_id=123`
```go
type RechargeCheckResponse struct {
NeedForceRecharge bool `json:"need_force_recharge"`
ForceRechargeAmount int64 `json:"force_recharge_amount"`
TriggerType string `json:"trigger_type"` // single_recharge/accumulated_recharge
MinAmount int64 `json:"min_amount"`
MaxAmount *int64 `json:"max_amount"`
CurrentAccumulated int64 `json:"current_accumulated"`
Threshold int64 `json:"threshold"`
Message string `json:"message"`
}
```
2. **套餐购买预检**`POST /api/h5/orders/purchase-check`
```go
type PurchaseCheckRequest struct {
OrderType string `json:"order_type"`
ResourceID uint `json:"resource_id"` // iot_card_id/device_id
PackageIDs []uint `json:"package_ids"`
}
type PurchaseCheckResponse struct {
TotalPackageAmount int64 `json:"total_package_amount"`
NeedForceRecharge bool `json:"need_force_recharge"`
ForceRechargeAmount int64 `json:"force_recharge_amount"`
ActualPayment int64 `json:"actual_payment"`
WalletCredit int64 `json:"wallet_credit"`
Message string `json:"message"`
}
```
**验证逻辑**
```go
func (s *RechargeService) checkForceRechargeRequirement(ctx, resourceType, resourceID) (*ForceRechargeRequirement, error) {
// 1. 查询资源(卡/设备)
resource := queryResource(resourceType, resourceID)
// 2. 查询系列分配
allocation := s.allocationStore.GetByID(ctx, resource.SeriesAllocationID)
// 3. 判断是否需要强充
if !allocation.EnableOneTimeCommission {
return &ForceRechargeRequirement{NeedForceRecharge: false}, nil
}
if resource.FirstCommissionPaid {
return &ForceRechargeRequirement{NeedForceRecharge: false}, nil
}
// 4. 根据触发类型判断
if allocation.OneTimeCommissionTrigger == "single_recharge" {
// 首次充值:强充金额 = 阈值
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: allocation.OneTimeCommissionThreshold,
TriggerType: "single_recharge",
}, nil
} else {
// 累计充值:检查是否启用强充
if allocation.EnableForceRecharge {
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: allocation.ForceRechargeAmount,
TriggerType: "accumulated_recharge",
}, nil
}
return &ForceRechargeRequirement{NeedForceRecharge: false}, nil
}
}
```
**备选方案及拒绝原因**
- ~~仅前端验证~~:不安全,用户可以绕过前端直接调用 API
- ~~仅后端验证~~:用户体验差,提交后才知道金额不对
---
### Decision 3: 充值订单与套餐订单的关系
**选择**:充值订单和套餐订单完全独立,使用不同的表和流程。
**理由**
- **关注点分离**:充值订单关注钱包余额变动,套餐订单关注套餐激活
- **业务语义清晰**RechargeRecord 表示纯充值Order 表示购买套餐
- **支付回调区分**通过订单号前缀区分RCH 开头是充值ORD 开头是订单)
- **佣金计算独立**:充值和购买的佣金触发逻辑不同
**处理流程对比**
| 流程步骤 | 充值订单RechargeRecord | 套餐订单Order |
|---------|---------------------------|------------------|
| 创建订单 | 创建 RechargeRecord | 创建 Order + OrderItem |
| 验证强充 | ✅ 验证充值金额 | ✅ 验证支付金额 |
| 生成订单号 | RCH + 时间戳 + 随机数 | ORD + 时间戳 + 随机数 |
| 支付 | 微信/支付宝 | 钱包/微信/支付宝/线下 |
| 支付成功 | 增加钱包余额 | 激活套餐,可能返还余额 |
| 更新累计充值 | ✅ 更新 AccumulatedRecharge | ✅ 更新(代购除外)|
| 触发佣金 | ✅ 触发一次性佣金判断 | ✅ 触发差价+一次性佣金(代购除外)|
**支付回调路由**
```go
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
result := parseWechatCallback(c.Body())
// 根据订单号前缀判断类型
if strings.HasPrefix(result.OutTradeNo, "RCH") {
// 充值订单回调
return h.rechargeService.HandlePaymentCallback(ctx, result.OutTradeNo, "wechat")
} else if strings.HasPrefix(result.OutTradeNo, "ORD") {
// 套餐订单回调
return h.orderService.HandlePaymentCallback(ctx, result.OutTradeNo, "wechat")
}
return errors.New(errors.CodeInvalidParam, "无效的订单号")
}
```
**备选方案及拒绝原因**
- ~~使用同一个 Order 表,通过 order_type 区分~~:语义混乱,充值不是"订单"
- ~~充值也创建 Order但 order_items 为空~~违反业务语义items 为空表示什么?
---
### Decision 4: 代购订单处理
**选择**:代购订单使用 `is_purchase_on_behalf` 字段标识,创建时直接标记为已支付,跳过支付流程。
**创建流程**
```
平台/代理创建代购订单
查询卡/设备归属的代理店铺
计算买家的成本价(不是卖价)
创建订单:
- buyer_id = 代理店铺ID
- is_purchase_on_behalf = true
- payment_method = "offline"
- payment_status = 2 (已支付)
- total_amount = 买家成本价
立即激活套餐(创建 PackageUsage
触发佣金计算(仅差价佣金,不触发一次性佣金)
```
**权限控制**
```go
func (h *OrderHandler) Create(c *fiber.Ctx) error {
req := parseRequest(c)
userType := middleware.GetUserTypeFromContext(ctx)
// 检查线下支付权限
if req.PaymentMethod == model.PaymentMethodOffline {
if userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "只有平台账号可以使用线下支付")
}
}
// 平台代购 vs 普通订单
if userType == constants.UserTypePlatform && req.PaymentMethod == "offline" {
// 平台代购逻辑
buyerShopID := queryResourceOwner(req.OrderType, req.ResourceID)
return h.service.CreatePurchaseOnBehalf(ctx, req, buyerShopID)
} else {
// 普通订单逻辑
shopID := middleware.GetShopIDFromContext(ctx)
return h.service.Create(ctx, req, buyerType, shopID)
}
}
```
**备选方案及拒绝原因**
- ~~使用独立的 purchase_on_behalf_orders 表~~:增加复杂度,查询和统计不便
- ~~使用订单状态区分(如 payment_status = 5 表示代购)~~:滥用状态字段,语义不清
---
### Decision 5: 佣金计算修复
**选择**在佣金计算Service中增加 `is_purchase_on_behalf` 判断,代购订单跳过一次性佣金和累计充值更新。
**修改逻辑**
```go
func (s *CommissionCalculationService) CalculateCommission(ctx, orderID) error {
order := s.orderStore.GetByID(ctx, orderID)
// 1. 差价佣金:所有订单都计算(包括代购)
costDiffRecords := s.CalculateCostDiffCommission(ctx, order)
// 2. 累计充值:仅非代购订单更新
if !order.IsPurchaseOnBehalf {
s.updateAccumulatedRecharge(ctx, order)
}
// 3. 一次性佣金:仅非代购订单触发
if !order.IsPurchaseOnBehalf {
s.triggerOneTimeCommission(ctx, order)
}
// 4. 更新订单佣金状态
s.orderStore.UpdateCommissionStatus(ctx, orderID, CommissionStatusCalculated)
}
func (s *CommissionCalculationService) updateAccumulatedRecharge(ctx, order) error {
if order.OrderType == "single_card" && order.IotCardID != nil {
return s.db.Model(&model.IotCard{}).
Where("id = ?", *order.IotCardID).
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", order.TotalAmount)).
Error
} else if order.OrderType == "device" && order.DeviceID != nil {
return s.db.Model(&model.Device{}).
Where("id = ?", *order.DeviceID).
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", order.TotalAmount)).
Error
}
return nil
}
```
**充值订单的佣金触发**
```go
func (s *RechargeService) HandlePaymentCallback(ctx, rechargeNo, paymentMethod) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 1. 更新充值订单状态
s.rechargeStore.UpdateStatus(ctx, rechargeNo, RechargeStatusPaid)
// 2. 增加钱包余额
s.walletStore.IncreaseBalance(ctx, walletID, amount)
// 3. 更新累计充值
s.updateAccumulatedRecharge(ctx, resourceType, resourceID, amount)
// 4. 触发一次性佣金判断
s.triggerOneTimeCommissionIfNeeded(ctx, resourceType, resourceID, amount)
return nil
})
}
```
---
### Decision 6: 强充金额来源
**选择**:首次充值使用阈值,累计充值使用独立配置字段。
**规则矩阵**
| 触发类型 | 强充开关 | 强充金额来源 | 说明 |
|---------|---------|-------------|------|
| 首次充值 | 不可配置(必须强充) | `OneTimeCommissionThreshold` | 首充必须充值阈值金额 |
| 累计充值 | `EnableForceRecharge=true` | `ForceRechargeAmount`(独立配置) | 每次必须充值固定金额 |
| 累计充值 | `EnableForceRecharge=false` | - | 不强充,自由金额 |
**查询逻辑**
```go
func getForceRechargeAmount(allocation *ShopSeriesAllocation) int64 {
if allocation.OneTimeCommissionTrigger == "single_recharge" {
// 首次充值:强充金额 = 阈值
return allocation.OneTimeCommissionThreshold
} else {
// 累计充值:强充金额 = 配置字段如果为0则使用阈值
if allocation.ForceRechargeAmount > 0 {
return allocation.ForceRechargeAmount
}
return allocation.OneTimeCommissionThreshold
}
}
```
**备选方案及拒绝原因**
- ~~累计充值的强充金额也固定为阈值~~:不合理,会导致"买39元套餐要充1000元"的问题
- ~~累计充值的强充金额动态计算(阈值 - 当前累计)~~:不合理,每次充值金额不固定
---
### Decision 7: 模块依赖注入
**选择**:使用现有的 `bootstrap` 包统一管理依赖注入。
**注入结构**
```go
// bootstrap/stores.go
type Stores struct {
// ... 现有 stores
Recharge *postgres.RechargeStore // 新增
}
// bootstrap/services.go
type Services struct {
// ... 现有 services
Recharge *recharge.Service // 新增
}
// bootstrap/handlers.go
type Handlers struct {
// ... 现有 handlers
H5Recharge *h5.RechargeHandler // 新增
}
// 初始化顺序Stores → Services → Handlers
func Bootstrap(deps *Dependencies) (*Handlers, error) {
stores := initStores(deps.DB, deps.Redis)
services := initServices(deps, stores)
handlers := initHandlers(services)
return handlers, nil
}
```
**理由**
- 遵循现有架构模式
- 集中管理依赖,易于测试和维护
- 避免循环依赖
---
## Risks / Trade-offs
### Risk 1: 数据库迁移风险
**风险**:新增字段的迁移可能影响现有数据。
**缓解措施**
- 所有新增字段都有默认值(`is_purchase_on_behalf DEFAULT false`
- 分阶段迁移:先添加字段,再部署代码,最后更新数据
- 迁移前备份数据库
- 在测试环境完整验证迁移流程
---
### Risk 2: 支付回调幂等性
**风险**:充值订单和套餐订单都支持支付回调,可能重复处理。
**缓解措施**
- 检查订单/充值订单状态,已支付则直接返回成功
- 使用数据库事务保证原子性
- 钱包余额更新使用乐观锁version 字段)
```go
func (s *RechargeService) HandlePaymentCallback(ctx, rechargeNo, method) error {
// 幂等性检查
recharge := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo)
if recharge.Status == RechargeStatusPaid || recharge.Status == RechargeStatusCompleted {
return nil // 已处理,直接返回成功
}
// 事务处理
return s.db.Transaction(func(tx *gorm.DB) error {
// 更新充值订单状态(带状态检查)
result := tx.Model(&RechargeRecord{}).
Where("recharge_no = ? AND status = ?", rechargeNo, RechargeStatusPending).
Updates(map[string]any{
"status": RechargeStatusPaid,
"payment_method": method,
"paid_at": time.Now(),
})
if result.RowsAffected == 0 {
return nil // 已被处理,跳过
}
// 增加钱包余额(使用乐观锁)
// ...
})
}
```
---
### Risk 3: 强充验证被绕过
**风险**:前端或恶意用户可能绕过强充验证。
**缓解措施**
- 后端创建订单/充值订单时强制验证,拒绝不符合要求的请求
- 记录验证失败的日志,监控异常行为
- API 接口使用认证中间件,防止未授权调用
---
### Risk 4: 佣金计算逻辑复杂度增加
**风险**:增加代购订单判断后,佣金计算逻辑更复杂,容易出错。
**缓解措施**
- 单元测试覆盖所有场景(普通订单、代购订单、充值订单)
- 使用 table-driven tests 测试各种组合
- 添加详细的日志记录,便于排查问题
**测试场景**
```go
func TestCommissionCalculation_PurchaseOnBehalf(t *testing.T) {
tests := []struct {
name string
isPurchaseOnBehalf bool
expectUpdateAccumulated bool
expectOneTimeCommission bool
}{
{"普通订单", false, true, true},
{"代购订单", true, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 测试逻辑
})
}
}
```
---
### Trade-off 1: 预检接口性能 vs 实时性
**权衡**:预检接口需要查询系列分配配置,可能影响性能。
**选择**:不缓存系列分配配置,保证实时性。
**理由**
- 系列分配配置变更频率低
- 单次查询性能可接受(< 10ms
- 实时性更重要(避免用户看到过期的强充要求)
- 如果后续性能成为瓶颈,可以引入短期缓存(如 1 分钟)
---
### Trade-off 2: 充值订单和套餐订单独立 vs 统一
**权衡**:使用独立的 RechargeRecord 表增加了一定复杂度。
**选择**:保持独立,不合并到 Order 表。
**理由**
- **语义清晰**:充值不是"订单",是钱包操作
- **查询方便**:充值记录和订单记录可以独立查询和统计
- **扩展性好**:未来可能支持银行转账充值等,不适合放在 Order 表
- **复杂度可控**:只是多一个 Store/Service/Handler符合分层架构
---
## Migration Plan
### 阶段 1: 数据库迁移(停机时间 < 1 分钟)
1. **创建迁移文件**
```bash
# 迁移文件 1
000XXX_add_order_purchase_on_behalf.up.sql
000XXX_add_order_purchase_on_behalf.down.sql
# 迁移文件 2
000XXX_add_shop_series_allocation_force_recharge.up.sql
000XXX_add_shop_series_allocation_force_recharge.down.sql
```
2. **执行迁移**
```bash
# 测试环境验证
migrate -path migrations -database "postgres://..." up
# 生产环境执行
migrate -path migrations -database "postgres://..." up
```
3. **验证迁移**
```sql
-- 检查字段是否添加成功
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name IN ('tb_order', 'tb_shop_series_allocation');
```
---
### 阶段 2: 代码部署(灰度发布)
1. **部署顺序**
- 先部署 API 服务(包含新接口和修复后的佣金计算)
- 再部署 Worker 服务(佣金计算任务处理)
2. **灰度策略**
- 第1天50% 流量
- 第2天100% 流量
- 监控错误率、响应时间、佣金计算准确性
3. **回滚策略**
- 如果发现严重问题,立即回滚到旧版本
- 数据库字段保留(有默认值,不影响旧代码)
- 充值订单数据保留,后续可重新处理
---
### 阶段 3: 功能验证
1. **充值功能验证**
- 个人客户创建充值订单
- 微信/支付宝支付成功
- 钱包余额正确增加
- 累计充值正确更新
- 达到阈值时正确触发佣金
2. **代购功能验证**
- 平台创建代购订单
- 订单自动完成
- 套餐正确激活
- 差价佣金正确计算
- 一次性佣金不触发
- 累计充值不更新
3. **强充验证**
- 预检接口返回正确的强充要求
- 创建订单时正确验证强充金额
- 不符合要求的订单被拒绝
---
### 阶段 4: 数据监控
1. **监控指标**
- 充值订单创建数量、成功率
- 代购订单创建数量
- 佣金计算准确性(抽样检查)
- 累计充值更新准确性
- 预检接口响应时间
- 支付回调成功率
2. **告警规则**
- 充值订单创建失败率 > 5%
- 支付回调处理失败率 > 1%
- 预检接口响应时间 > 200ms
- 佣金计算失败率 > 0.1%
---
## Open Questions
### Q1: 代理能否为下级代理代购?
**当前设计**:平台可以为任何代理代购,代理暂不支持。
**待确认**
- 代理是否需要为下级代理代购的能力?
- 如果需要,权限如何控制(只能为直属下级?还是所有下级?)
- 代购时使用谁的成本价(创建人的成本价?还是买家的成本价?)
**影响**如果需要支持Handler 层的权限检查需要调整。
---
### Q2: 充值订单是否需要取消功能?
**当前设计**:充值订单创建后不支持取消,只能超时自动关闭。
**待确认**
- 用户是否需要主动取消充值订单?
- 如果支持取消,状态流转如何设计?
**影响**:如果需要支持,需要增加 Cancel 接口和状态流转逻辑。
---
### Q3: 强充金额是否需要支持范围(最小-最大)?
**当前设计**强充金额是一个固定值如100元
**待确认**
- 是否需要支持金额范围(如 100-500 元之间任意金额)?
- 如果支持,配置字段如何设计?
**影响**如果需要支持ShopSeriesAllocation 需要增加 `force_recharge_min` 和 `force_recharge_max` 字段。
---
### Q4: 充值订单是否需要支持优惠券/折扣?
**当前设计**:充值订单不支持任何优惠。
**待确认**
- 未来是否需要支持充值满减、折扣等营销活动?
- 如果需要,是否在本次实现?
**影响**:如果需要支持,需要设计优惠券系统,超出本次范围。
**建议**:留待后续实现,本次保持简单。

View File

@@ -0,0 +1,82 @@
## Why
当前系统缺少完整的强充(强制充值)机制和代购订单支持,导致以下问题:(1) 个人客户无法直接给钱包充值,必须通过购买套餐间接充值;(2) 平台和代理无法为其他代理代购套餐(线下已收款场景);(3) 一次性佣金触发机制不完善,代购订单错误触发佣金且累加充值金额;(4) 缺少强充预检接口,前端无法提前告知用户充值限制。这些限制影响了业务灵活性和用户体验,需要立即完善。
## What Changes
- **新增钱包充值系统**实现个人客户直接充值钱包功能包含充值订单RechargeRecord的创建、支付、回调处理充值成功触发佣金计算
- **新增强充预检接口**:提供钱包充值预检和套餐购买预检接口,返回强充要求、金额限制、实际支付金额等信息
- **新增代购订单功能**:支持平台/代理给其他代理代购套餐,使用线下支付方式,订单标记为代购类型
- **扩展强充配置**ShopSeriesAllocation 模型新增 `enable_force_recharge``force_recharge_amount` 字段,支持累计充值强充配置(可选)
- **修复佣金计算逻辑**:代购订单不触发一次性佣金,不累加 `AccumulatedRecharge`,确保佣金计算准确性
- **扩展订单模型**Order 模型新增 `is_purchase_on_behalf` 字段和 `offline` 支付方式,区分代购订单和普通订单
- **完善充值验证**:创建充值订单和购买订单时强制验证强充要求,防止前端绕过限制
## Capabilities
### New Capabilities
- `wallet-recharge`: 钱包充值系统,包含充值订单创建、支付集成(微信/支付宝)、回调处理、充值成功后触发佣金计算
- `force-recharge-check`: 强充预检接口,包含钱包充值预检、套餐购买预检,返回强充要求和金额限制
- `purchase-on-behalf`: 代购订单功能,支持平台/代理为其他代理代购套餐,使用线下支付,区分代购和普通订单
### Modified Capabilities
- `commission-calculation`: 修改佣金计算逻辑,代购订单不触发一次性佣金,不累加 AccumulatedRecharge
- `order-management`: 订单模型增加 `is_purchase_on_behalf` 字段,支持代购订单类型
- `order-payment`: 支付方式增加 `offline` 线下支付,代购订单创建后直接标记为已支付
- `shop-series-allocation`: 增加强充配置字段(`enable_force_recharge``force_recharge_amount`),支持累计充值强充设置
## Impact
### 数据库变更
- **tb_order 表**:新增 `is_purchase_on_behalf` 字段BOOLEAN`payment_method` 增加 `offline` 枚举值
- **tb_shop_series_allocation 表**:新增 `enable_force_recharge` 字段BOOLEAN`force_recharge_amount` 字段BIGINT
- **tb_recharge_record 表**:已存在但未使用,需要创建对应的 Store/Service/Handler
- **数据库迁移**:需要创建迁移文件添加新字段
### 新增代码模块
- **Store 层**`RechargeStore`(充值订单数据访问)
- **Service 层**`RechargeService`(充值业务逻辑)、强充预检逻辑(在现有 Service 中)
- **Handler 层**`RechargeHandler`(充值 HTTP 接口)、充值预检接口(在现有 Handler 中)
- **Task 层**:充值支付回调处理(在现有 callback handler 中扩展)
### 修改现有代码
- **CommissionCalculationService**:增加代购订单判断逻辑
- **OrderService**:增加代购订单创建逻辑、强充验证逻辑
- **OrderHandler**admin增加平台创建代购订单接口
- **ShopSeriesAllocationService**:支持强充配置的创建和更新
### API 变更
- **新增接口**
- `GET /api/h5/wallets/recharge-check` - 钱包充值预检
- `POST /api/h5/recharge-records` - 创建充值订单
- `GET /api/h5/recharge-records` - 查询充值订单列表
- `GET /api/h5/recharge-records/:id` - 查询充值订单详情
- `POST /api/h5/orders/purchase-check` - 套餐购买预检
- `POST /api/admin/orders` - 修改以支持代购订单创建
- **修改接口**
- `POST /api/admin/shop-series-allocations` - 支持强充配置参数
- `PUT /api/admin/shop-series-allocations/:id` - 支持强充配置更新
### 支付回调处理
- **微信支付回调**:扩展支持充值订单的回调处理
- **支付宝回调**:扩展支持充值订单的回调处理
### 业务逻辑影响
- **佣金计算**:代购订单不触发一次性佣金,但仍计算差价佣金
- **累计充值**:只有真实充值(个人客户充值或购买套餐)才累加 AccumulatedRecharge
- **强充触发**
- 首次充值必须充值阈值金额OneTimeCommissionThreshold
- 累计充值如果启用强充必须充值固定金额ForceRechargeAmount
- **订单支付**:代购订单创建后直接标记为已支付,跳过钱包扣款
### 测试影响
- **单元测试**:需要为所有新增 Service 方法编写测试
- **集成测试**:需要测试完整的充值流程、强充预检、代购订单流程
- **测试覆盖率**:核心业务逻辑测试覆盖率需保持 ≥ 90%
### 性能考虑
- 预检接口响应时间 < 100ms涉及数据库查询
- 充值订单创建响应时间 < 200ms
- 支付回调处理时间 < 500ms异步处理佣金计算

View File

@@ -0,0 +1,103 @@
## MODIFIED Requirements
### Requirement: 订单支付后触发佣金计算
系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。代购订单和普通订单的佣金计算逻辑不同。
#### Scenario: 普通订单支付成功触发计算
- **WHEN** 普通订单is_purchase_on_behalf = false支付状态变为已支付
- **THEN** 系统发送佣金计算异步任务
#### Scenario: 代购订单支付成功触发计算
- **WHEN** 代购订单is_purchase_on_behalf = true创建成功自动已支付
- **THEN** 系统发送佣金计算异步任务
#### Scenario: 重复支付不重复计算
- **WHEN** 订单已计算过佣金commission_status=2
- **THEN** 系统不重复触发计算
---
### Requirement: 更新累计充值金额
订单支付成功后系统 SHALL 更新卡/设备的累计充值金额,但代购订单除外。
**关键修复**:每次真实充值(个人客户充值或购买套餐)都必须写回累计充值金额,代购订单不更新。
#### Scenario: 普通单卡订单更新累计充值
- **WHEN** 普通单卡订单is_purchase_on_behalf = false支付成功金额 100 元
- **THEN** 系统读取 IotCard.accumulated_recharge 当前值
- **AND** 增加 10000 分100 元 = 10000 分)
- **AND** 将新值写回 IotCard.accumulated_recharge
- **AND** 使用更新后的累计值判断是否触发一次性佣金
#### Scenario: 普通设备订单更新累计充值
- **WHEN** 普通设备订单is_purchase_on_behalf = false支付成功金额 300 元
- **THEN** 系统读取 Device.accumulated_recharge 当前值
- **AND** 增加 30000 分300 元 = 30000 分)
- **AND** 将新值写回 Device.accumulated_recharge
- **AND** 使用更新后的累计值判断是否触发一次性佣金
#### Scenario: 代购订单不更新累计充值
- **WHEN** 代购订单is_purchase_on_behalf = true完成金额 100 元
- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段
- **AND** accumulated_recharge 保持原值
#### Scenario: 累计充值更新使用原子操作
- **WHEN** 更新累计充值金额
- **THEN** 系统使用 SQL 原子操作(如 `accumulated_recharge = accumulated_recharge + ?`
- **OR** 使用 GORM 乐观锁version 字段)
- **AND** 确保并发场景下累计值不会丢失
#### Scenario: 更新失败不影响佣金计算
- **WHEN** 累计充值金额更新失败(数据库错误、并发冲突等)
- **THEN** 系统记录错误日志
- **AND** 继续执行后续的佣金计算流程(成本价差、一次性佣金等)
- **AND** 不因累计值更新失败而导致整个佣金计算失败
---
## ADDED Requirements
### Requirement: 代购订单佣金计算规则
代购订单 SHALL 计算差价佣金,但不触发一次性佣金。
#### Scenario: 代购订单计算差价佣金
- **WHEN** 代购订单is_purchase_on_behalf = true完成买家有上级代理
- **THEN** 系统计算差价佣金(买家成本价 - 上级成本价),发放给上级代理链
#### Scenario: 代购订单不触发一次性佣金
- **WHEN** 代购订单完成,佣金计算时检查订单类型
- **THEN** 系统跳过一次性佣金判断逻辑,不发放一次性佣金
#### Scenario: 代购订单示例
- **WHEN** 平台为三级代理代购,订单金额 100 元(三级成本价),各级成本价:一级 60 → 二级 70 → 三级 80
- **THEN** 二级获得 10 元80 - 70差价佣金一级获得 10 元70 - 60差价佣金
- **AND** 三级、二级、一级都不获得一次性佣金
---
### Requirement: 钱包充值触发一次性佣金
钱包充值成功后 SHALL 更新累计充值,并检查是否触发一次性佣金。
#### Scenario: 充值成功更新累计充值
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
- **THEN** 系统更新卡的 accumulated_recharge 为 300 元
#### Scenario: 充值达到首次充值阈值
- **WHEN** 卡配置为首次充值触发,阈值 100 元,充值 100 元成功,未发放过佣金
- **THEN** 系统触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
#### Scenario: 充值达到累计充值阈值
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,充值后累计达到 1000 元,未发放过佣金
- **THEN** 系统触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
#### Scenario: 充值未达阈值不触发
- **WHEN** 充值后累计充值未达到阈值
- **THEN** 系统不触发一次性佣金计算
#### Scenario: 已发放过不重复触发
- **WHEN** 卡的一次性佣金已发放过first_commission_paid = true
- **THEN** 系统不触发一次性佣金计算

View File

@@ -0,0 +1,109 @@
## ADDED Requirements
### Requirement: 钱包充值预检
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。
#### Scenario: 无强充要求
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发且未启用强充
- **THEN** 系统返回 need_force_recharge = falsemin_amount = 1001元max_amount = null
#### Scenario: 首次充值强充
- **WHEN** 客户查询卡钱包充值预检,卡配置为首次充值触发,阈值 10000 分100元未发放佣金
- **THEN** 系统返回 need_force_recharge = trueforce_recharge_amount = 10000trigger_type = "single_recharge"message = "首次充值需充值100元"
#### Scenario: 累计充值启用强充
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发,启用强充,强充金额 10000 分100元
- **THEN** 系统返回 need_force_recharge = trueforce_recharge_amount = 10000trigger_type = "accumulated_recharge"message = "每次充值需充值100元"
#### Scenario: 一次性佣金已发放
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
- **THEN** 系统返回 need_force_recharge = false不再强充
#### Scenario: 未启用一次性佣金
- **WHEN** 客户查询卡钱包充值预检,卡关联系列未启用一次性佣金
- **THEN** 系统返回 need_force_recharge = false
---
### Requirement: 套餐购买预检
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。
#### Scenario: 无强充要求正常购买
- **WHEN** 客户购买 90 元套餐,无强充要求
- **THEN** 系统返回 total_package_amount = 9000need_force_recharge = falseactual_payment = 9000wallet_credit = 0
#### Scenario: 首次充值强充,套餐价低于阈值
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 9000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 10000wallet_credit = 1000message = "需充值100元购买套餐后余额10元"
#### Scenario: 首次充值强充,套餐价高于阈值
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 15000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 15000wallet_credit = 0message = "套餐总价150元无需额外充值"
#### Scenario: 首次充值强充,套餐价等于阈值
- **WHEN** 客户购买 100 元套餐,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 10000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 10000wallet_credit = 0
#### Scenario: 累计充值启用强充,套餐价低于强充金额
- **WHEN** 客户购买 50 元套餐,累计充值启用强充,强充金额 100 元
- **THEN** 系统返回 actual_payment = 10000wallet_credit = 5000message = "需充值100元购买套餐后余额50元"
#### Scenario: 累计充值启用强充,套餐价高于强充金额
- **WHEN** 客户购买 150 元套餐,累计充值启用强充,强充金额 100 元
- **THEN** 系统返回 actual_payment = 15000wallet_credit = 0message = "套餐总价150元无需额外充值"
#### Scenario: 购买多个套餐
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 12000actual_payment = 12000wallet_credit = 0
---
### Requirement: 预检接口响应格式
预检接口响应 SHALL 包含完整的充值/购买指引信息。
#### Scenario: 充值预检响应字段
- **WHEN** 调用钱包充值预检接口
- **THEN** 响应包含need_force_recharge, force_recharge_amount, trigger_type, min_amount, max_amount, current_accumulated, threshold, message
#### Scenario: 购买预检响应字段
- **WHEN** 调用套餐购买预检接口
- **THEN** 响应包含total_package_amount, need_force_recharge, force_recharge_amount, actual_payment, wallet_credit, message
---
### Requirement: 预检接口性能
预检接口响应时间 MUST 小于 100ms。
#### Scenario: 快速响应
- **WHEN** 调用预检接口
- **THEN** 系统在 100ms 内返回结果
#### Scenario: 缓存系列分配配置
- **WHEN** 频繁查询同一卡的预检信息
- **THEN** 系统可以缓存系列分配配置,减少数据库查询
---
### Requirement: 预检接口错误处理
预检接口 SHALL 正确处理异常情况。
#### Scenario: 卡不存在
- **WHEN** 查询不存在的卡的充值预检
- **THEN** 系统返回错误 "卡不存在"
#### Scenario: 卡未关联系列
- **WHEN** 查询未关联套餐系列的卡的充值预检
- **THEN** 系统返回 need_force_recharge = false无系列分配无强充要求
#### Scenario: 设备不存在
- **WHEN** 查询不存在的设备的充值预检
- **THEN** 系统返回错误 "设备不存在"
#### Scenario: 套餐不存在
- **WHEN** 套餐购买预检时,套餐 ID 不存在
- **THEN** 系统返回错误 "套餐不存在"

View File

@@ -0,0 +1,93 @@
## ADDED Requirements
### Requirement: 订单类型标识
系统 SHALL 在订单模型中增加 is_purchase_on_behalf 字段,标识是否为代购订单。
#### Scenario: 普通订单创建
- **WHEN** 个人客户或代理为自己创建订单
- **THEN** 系统设置 is_purchase_on_behalf = false
#### Scenario: 代购订单创建
- **WHEN** 平台或代理为其他代理创建代购订单
- **THEN** 系统设置 is_purchase_on_behalf = true
#### Scenario: 查询订单列表返回订单类型
- **WHEN** 查询订单列表或详情
- **THEN** 响应包含 is_purchase_on_behalf 字段
---
## MODIFIED Requirements
### Requirement: 创建套餐购买订单
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。
#### Scenario: 个人客户创建单卡订单
- **WHEN** 个人客户为自己的卡创建订单,选择一个套餐
- **THEN** 系统创建订单状态为待支付is_purchase_on_behalf = false返回订单信息
#### Scenario: 个人客户创建设备订单
- **WHEN** 个人客户为自己的设备创建订单
- **THEN** 系统创建订单订单类型为设备购买is_purchase_on_behalf = false
#### Scenario: 代理创建订单
- **WHEN** 代理为店铺关联的卡/设备创建订单
- **THEN** 系统创建订单买家类型为代理商买家ID为店铺IDis_purchase_on_behalf = false
#### Scenario: 平台创建代购订单
- **WHEN** 平台账号为代理的卡/设备创建订单,支付方式选择 offline
- **THEN** 系统创建订单is_purchase_on_behalf = truepayment_method = "offline"payment_status = 2已支付
#### Scenario: 套餐购买验证强充要求
- **WHEN** 个人客户创建订单,存在强充要求,订单金额低于强充金额
- **THEN** 系统返回错误 "支付金额不符合强充要求"
#### Scenario: 套餐不在可购买范围
- **WHEN** 买家尝试购买不在关联系列下的套餐
- **THEN** 系统返回错误 "该套餐不在可购买范围内"
#### Scenario: 套餐已下架
- **WHEN** 买家尝试购买已下架的套餐
- **THEN** 系统返回错误 "该套餐已下架"
---
### Requirement: 查询订单列表
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、是否代购筛选。
#### Scenario: 个人客户查询自己的订单
- **WHEN** 个人客户查询订单列表
- **THEN** 系统只返回该客户的订单
#### Scenario: 代理查询店铺订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回该店铺及下级店铺的订单(包含代购订单和普通订单)
#### Scenario: 按代购类型筛选
- **WHEN** 指定 is_purchase_on_behalf = true 筛选
- **THEN** 系统只返回代购订单
#### Scenario: 按支付状态筛选
- **WHEN** 指定支付状态筛选
- **THEN** 系统只返回匹配状态的订单
---
### Requirement: 取消订单
系统 SHALL 允许买家取消未支付的订单,但代购订单不可取消。
#### Scenario: 取消待支付的普通订单
- **WHEN** 买家取消一个待支付的普通订单is_purchase_on_behalf = false
- **THEN** 系统更新订单状态为已取消
#### Scenario: 取消已支付订单
- **WHEN** 买家尝试取消已支付的订单
- **THEN** 系统返回错误 "已支付订单无法取消"
#### Scenario: 尝试取消代购订单
- **WHEN** 买家尝试取消代购订单is_purchase_on_behalf = true
- **THEN** 系统返回错误 "代购订单不可取消"

View File

@@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: 线下支付方式
系统 SHALL 支持线下支付方式offline仅用于代购订单。线下支付的订单创建后直接标记为已支付跳过支付流程。
#### Scenario: 创建线下支付订单
- **WHEN** 平台账号创建订单时选择支付方式为 offline
- **THEN** 系统创建订单payment_status 直接设为 2已支付payment_method = "offline"
#### Scenario: 线下支付权限限制
- **WHEN** 非平台账号(代理/个人客户)尝试使用线下支付
- **THEN** 系统返回错误 "只有平台账号可以使用线下支付"
#### Scenario: 线下支付订单自动激活套餐
- **WHEN** 创建线下支付订单成功
- **THEN** 系统自动激活套餐,创建 PackageUsage 记录
#### Scenario: 线下支付不扣钱包
- **WHEN** 订单使用线下支付
- **THEN** 系统不扣减任何钱包余额
---
## MODIFIED Requirements
### Requirement: 第三方支付回调
系统 SHALL 处理微信支付和支付宝的支付回调,支持订单支付和钱包充值两种场景。回调处理 MUST 幂等。
#### Scenario: 微信支付成功回调(订单)
- **WHEN** 收到微信支付成功回调,订单号格式为 ORD 开头
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
#### Scenario: 微信支付成功回调(充值)
- **WHEN** 收到微信支付成功回调,订单号格式为 RCH 开头
- **THEN** 系统验证签名,更新充值订单状态,增加钱包余额,更新累计充值,触发佣金判断,返回成功响应
#### Scenario: 支付宝成功回调(订单)
- **WHEN** 收到支付宝支付成功回调,订单号格式为 ORD 开头
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
#### Scenario: 支付宝成功回调(充值)
- **WHEN** 收到支付宝支付成功回调,订单号格式为 RCH 开头
- **THEN** 系统验证签名,更新充值订单状态,增加钱包余额,更新累计充值,触发佣金判断,返回成功响应
#### Scenario: 重复回调
- **WHEN** 收到已处理订单的重复回调
- **THEN** 系统返回成功响应,不重复处理
#### Scenario: 签名验证失败
- **WHEN** 回调签名验证失败
- **THEN** 系统拒绝处理,返回失败响应
---
### Requirement: 套餐激活
支付成功后系统 MUST 激活套餐,创建 PackageUsage 记录。代购订单也需激活套餐,但不更新累计充值。
#### Scenario: 单卡套餐激活
- **WHEN** 单卡订单支付成功
- **THEN** 系统创建 PackageUsageusage_type 为 single_card关联 iot_card_id
#### Scenario: 设备套餐激活
- **WHEN** 设备订单支付成功
- **THEN** 系统创建 PackageUsageusage_type 为 device关联 device_id
#### Scenario: 套餐有效期计算
- **WHEN** 套餐激活
- **THEN** 有效期 = 激活时间 + 套餐时长(月)
#### Scenario: 代购订单激活套餐
- **WHEN** 代购订单is_purchase_on_behalf = true创建成功
- **THEN** 系统激活套餐,但不更新卡/设备的 accumulated_recharge

View File

@@ -0,0 +1,139 @@
## ADDED Requirements
### Requirement: 平台创建代购订单
系统 SHALL 允许平台账号为代理创建代购订单,使用线下支付方式,订单创建后直接标记为已支付。
#### Scenario: 平台为一级代理代购
- **WHEN** 平台账号为一级代理的卡创建代购订单,选择套餐,支付方式为线下支付
- **THEN** 系统创建订单buyer_id = 一级代理店铺IDis_purchase_on_behalf = truepayment_method = "offline"payment_status = 2已支付
#### Scenario: 平台为二级代理代购
- **WHEN** 平台账号为二级代理的卡创建代购订单
- **THEN** 系统创建订单buyer_id = 二级代理店铺IDis_purchase_on_behalf = true
#### Scenario: 代购订单价格使用代理成本价
- **WHEN** 平台为代理创建代购订单,套餐价格 100 元,代理成本价 80 元
- **THEN** 订单金额为 80 元(代理成本价)
#### Scenario: 查询卡归属代理
- **WHEN** 平台选择卡创建代购订单
- **THEN** 系统查询卡的 shop_id作为订单的 buyer_id
#### Scenario: 查询设备归属代理
- **WHEN** 平台选择设备创建代购订单
- **THEN** 系统查询设备的 shop_id作为订单的 buyer_id
---
### Requirement: 代理创建代购订单
系统 SHALL 允许代理账号为其他代理(通常是下级代理)创建代购订单。
#### Scenario: 一级代理为二级代理代购
- **WHEN** 一级代理为二级代理的卡创建代购订单,选择套餐,支付方式为线下支付
- **THEN** 系统创建订单buyer_id = 二级代理店铺IDis_purchase_on_behalf = truepayment_method = "offline"
#### Scenario: 代购订单使用买家成本价
- **WHEN** 一级代理为二级代理代购,套餐价格 100 元,二级代理成本价 90 元
- **THEN** 订单金额为 90 元(买家成本价)
---
### Requirement: 代购订单自动完成
代购订单创建后 SHALL 自动完成支付流程,激活套餐,但不触发一次性佣金。
#### Scenario: 代购订单自动激活套餐
- **WHEN** 创建代购订单成功
- **THEN** 系统自动激活套餐(创建 PackageUsage 记录)
#### Scenario: 代购订单不扣钱包
- **WHEN** 创建代购订单
- **THEN** 系统不扣减任何钱包余额(线下已收款)
#### Scenario: 代购订单不更新累计充值
- **WHEN** 代购订单完成
- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段
#### Scenario: 代购订单计算差价佣金
- **WHEN** 代购订单完成,买家有上级代理
- **THEN** 系统计算差价佣金(买家成本价 - 上级成本价),发放给上级代理
#### Scenario: 代购订单不触发一次性佣金
- **WHEN** 代购订单完成
- **THEN** 系统不检查一次性佣金阈值,不发放一次性佣金
---
### Requirement: 代购订单查询
系统 SHALL 在订单列表中正确显示代购订单。
#### Scenario: 平台查询代购订单
- **WHEN** 平台账号查询订单列表
- **THEN** 系统返回所有代购订单is_purchase_on_behalf = true包含创建人信息
#### Scenario: 代理查询收到的代购订单
- **WHEN** 代理查询订单列表,包含别人为自己代购的订单
- **THEN** 系统返回买家为自己的代购订单
#### Scenario: 代购订单标识
- **WHEN** 查询订单详情
- **THEN** 订单响应包含 is_purchase_on_behalf 字段,前端可以显示"代购订单"标签
---
### Requirement: 代购订单权限控制
系统 SHALL 严格控制代购订单的创建权限。
#### Scenario: 只有平台账号可以使用线下支付
- **WHEN** 代理账号尝试创建订单时选择支付方式为 offline
- **THEN** 系统返回错误 "只有平台账号可以使用线下支付"
#### Scenario: 平台账号可以为任何代理代购
- **WHEN** 平台账号为任意层级代理创建代购订单
- **THEN** 系统允许创建
#### Scenario: 代理账号只能为下级代理代购
- **WHEN** 代理账号尝试为上级或平级代理创建代购订单
- **THEN** 系统返回错误 "只能为下级代理代购套餐"
---
### Requirement: 代购订单记录
系统 SHALL 完整记录代购订单的创建人和买家信息。
#### Scenario: 记录创建人
- **WHEN** 平台/代理创建代购订单
- **THEN** 订单的 creator 字段记录创建人账号ID
#### Scenario: 区分创建人和买家
- **WHEN** 查询代购订单详情
- **THEN** creator创建人!= buyer_id买家可以追溯是谁代购的
---
### Requirement: 代购订单不可取消
代购订单创建后 SHALL 不可取消,因为已自动完成。
#### Scenario: 尝试取消代购订单
- **WHEN** 尝试取消一个代购订单is_purchase_on_behalf = true
- **THEN** 系统返回错误 "代购订单不可取消"
---
### Requirement: 线下支付方式常量
系统 SHALL 定义线下支付方式常量。
#### Scenario: 支付方式枚举
- **WHEN** 创建订单时选择支付方式
- **THEN** payment_method 可选值包含wallet, wechat, alipay, offline
#### Scenario: 线下支付只用于代购
- **WHEN** payment_method = "offline"
- **THEN** 订单必须标记为 is_purchase_on_behalf = true

View File

@@ -0,0 +1,85 @@
## ADDED Requirements
### Requirement: 强充配置
系统 SHALL 在套餐系列分配中支持强充配置。仅累计充值触发时可选启用强充,首次充值触发时强充是必须的(无需配置)。
#### Scenario: 累计充值启用强充
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = trueforce_recharge_amount = 10000100元
- **THEN** 系统保存强充配置,下级客户每次充值/购买必须充值 100 元
#### Scenario: 累计充值不启用强充
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = false
- **THEN** 系统保存配置,下级客户可以自由充值任意金额
#### Scenario: 首次充值无需设置强充
- **WHEN** 创建系列分配,一次性佣金触发类型为首次充值,阈值 10000100元
- **THEN** 系统使用阈值作为强充金额,无需单独配置 force_recharge_amount
#### Scenario: 强充金额为0表示使用阈值
- **WHEN** 创建系列分配启用强充force_recharge_amount = 0
- **THEN** 系统使用一次性佣金阈值作为强充金额
---
## MODIFIED Requirements
### Requirement: 为下级店铺分配套餐系列
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置返佣模式和返佣值MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。
#### Scenario: 成功分配套餐系列
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列设置基础返佣为百分比20020%
- **THEN** 系统创建分配记录
#### Scenario: 分配时启用一次性佣金和强充
- **WHEN** 代理为下级分配系列,启用一次性佣金,触发类型为累计充值,阈值 1000001000元启用强充强充金额 10000100元
- **THEN** 系统保存配置enable_one_time_commission = truetrigger = "accumulated_recharge"threshold = 100000enable_force_recharge = trueforce_recharge_amount = 10000
#### Scenario: 尝试分配未拥有的系列
- **WHEN** 代理尝试分配自己未被分配的套餐系列
- **THEN** 系统返回错误 "您没有该套餐系列的分配权限"
#### Scenario: 尝试分配给非直属下级
- **WHEN** 代理尝试分配给非直属下级店铺
- **THEN** 系统返回错误 "只能为直属下级分配套餐"
#### Scenario: 重复分配同一系列
- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列
- **THEN** 系统返回错误 "该店铺已分配此套餐系列"
---
### Requirement: 更新套餐系列分配
系统 SHALL 允许代理更新分配的基础返佣配置、一次性佣金配置和强充配置。更新返佣配置时 MUST 创建新的配置版本。
#### Scenario: 更新基础返佣配置时创建新版本
- **WHEN** 代理将基础返佣从20%改为25%
- **THEN** 系统更新分配记录,并创建新配置版本
#### Scenario: 更新强充配置
- **WHEN** 代理将 enable_force_recharge 从 false 改为 true设置 force_recharge_amount = 10000
- **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求
#### Scenario: 禁用强充
- **WHEN** 代理将 enable_force_recharge 从 true 改为 false
- **THEN** 系统更新分配记录,后续下级客户可以自由充值
#### Scenario: 更新不存在的分配
- **WHEN** 代理更新不存在的分配 ID
- **THEN** 系统返回 "分配记录不存在" 错误
---
### Requirement: 平台分配套餐系列
平台管理员 SHALL 能够为一级代理分配套餐系列,可配置强充要求。平台的成本价基准为 Package.suggested_cost_price。
#### Scenario: 平台为一级代理分配
- **WHEN** 平台管理员为一级代理分配套餐系列
- **THEN** 系统创建分配记录
#### Scenario: 平台配置强充要求
- **WHEN** 平台为一级代理分配系列启用强充force_recharge_amount = 10000
- **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求

View File

@@ -0,0 +1,182 @@
## ADDED Requirements
### Requirement: 创建钱包充值订单
系统 SHALL 允许个人客户创建钱包充值订单。创建前 MUST 验证强充要求,强充场景下充值金额必须等于要求的强充金额。
#### Scenario: 无强充要求时自由充值
- **WHEN** 个人客户为卡/设备创建充值订单,该卡/设备无强充要求,充值金额 100 元
- **THEN** 系统创建充值订单,状态为待支付,金额 10000 分
#### Scenario: 首次充值强充
- **WHEN** 卡关联系列配置为首次充值触发,阈值 100 元,客户尝试充值 100 元
- **THEN** 系统验证通过,创建充值订单,金额 10000 分
#### Scenario: 首次充值金额不符
- **WHEN** 卡关联系列配置为首次充值触发,阈值 100 元,客户尝试充值 50 元
- **THEN** 系统返回错误 "必须充值100元"
#### Scenario: 累计充值启用强充
- **WHEN** 卡关联系列配置为累计充值触发,启用强充,强充金额 100 元,客户尝试充值 100 元
- **THEN** 系统验证通过,创建充值订单
#### Scenario: 累计充值强充金额不符
- **WHEN** 卡关联系列配置为累计充值触发,启用强充,强充金额 100 元,客户尝试充值 50 元
- **THEN** 系统返回错误 "必须充值100元"
#### Scenario: 累计充值未启用强充
- **WHEN** 卡关联系列配置为累计充值触发,未启用强充,客户充值任意金额
- **THEN** 系统创建充值订单
#### Scenario: 充值订单号唯一
- **WHEN** 创建充值订单
- **THEN** 系统生成唯一充值单号,格式为 RCH + 14位时间戳 + 6位随机数
---
### Requirement: 查询充值订单列表
系统 SHALL 提供充值订单列表查询,支持按状态筛选、时间范围筛选。
#### Scenario: 查询个人客户的充值订单
- **WHEN** 个人客户查询充值订单列表
- **THEN** 系统返回该客户的所有充值订单
#### Scenario: 按状态筛选
- **WHEN** 客户指定充值状态筛选(待支付/已支付/已完成)
- **THEN** 系统只返回匹配状态的充值订单
#### Scenario: 分页查询
- **WHEN** 查询充值订单列表
- **THEN** 系统使用分页返回,默认每页 20 条,最大 100 条
---
### Requirement: 查询充值订单详情
系统 SHALL 允许个人客户查询充值订单详情。
#### Scenario: 查询自己的充值订单
- **WHEN** 客户查询自己的充值订单详情
- **THEN** 系统返回订单信息(充值单号、金额、支付方式、状态、时间等)
#### Scenario: 查询他人充值订单
- **WHEN** 客户尝试查询不属于自己的充值订单
- **THEN** 系统返回 "充值订单不存在" 错误
---
### Requirement: 充值支付(微信/支付宝)
系统 SHALL 支持通过微信支付和支付宝支付完成充值。
#### Scenario: 微信 JSAPI 支付
- **WHEN** 客户在微信内选择充值,使用微信支付
- **THEN** 系统调用微信支付 JSAPI 接口,返回支付参数
#### Scenario: 微信 H5 支付
- **WHEN** 客户在浏览器内选择充值,使用微信支付
- **THEN** 系统调用微信支付 H5 接口,返回支付跳转 URL
#### Scenario: 支付宝支付
- **WHEN** 客户选择支付宝支付充值
- **THEN** 系统调用支付宝接口,返回支付参数
---
### Requirement: 充值支付回调处理
系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额。
#### Scenario: 微信支付回调成功
- **WHEN** 收到微信支付成功回调,验证签名通过
- **THEN** 系统更新充值订单状态为已支付
- **AND** 增加对应钱包余额
- **AND** 创建钱包交易记录
- **AND** 返回成功响应给微信
#### Scenario: 支付宝回调成功
- **WHEN** 收到支付宝支付成功回调,验证签名通过
- **THEN** 系统更新充值订单状态为已支付
- **AND** 增加对应钱包余额
- **AND** 创建钱包交易记录
#### Scenario: 签名验证失败
- **WHEN** 收到支付回调,签名验证失败
- **THEN** 系统记录错误日志,不处理订单,返回失败响应
#### Scenario: 重复回调幂等处理
- **WHEN** 收到同一充值订单的重复支付回调
- **THEN** 系统检查订单状态,如果已支付则直接返回成功,不重复处理
---
### Requirement: 充值成功更新累计充值金额
充值支付成功后系统 SHALL 更新卡/设备的累计充值金额AccumulatedRecharge
#### Scenario: 充值成功累加充值金额
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
- **THEN** 系统更新卡的累计充值为 300 元200 + 100
#### Scenario: 设备充值成功累加充值金额
- **WHEN** 设备钱包充值 200 元成功,当前累计充值 500 元
- **THEN** 系统更新设备的累计充值为 700 元500 + 200
#### Scenario: 使用原子操作更新
- **WHEN** 更新累计充值金额
- **THEN** 系统使用 SQL 原子操作或 GORM 乐观锁确保并发安全
---
### Requirement: 充值成功触发一次性佣金判断
充值支付成功后系统 SHALL 检查是否达到一次性佣金阈值,如果达到则触发佣金计算。
#### Scenario: 首次充值达到阈值
- **WHEN** 卡配置为首次充值触发,阈值 100 元,客户充值 100 元
- **THEN** 系统触发一次性佣金计算,发放佣金
#### Scenario: 累计充值达到阈值
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,累计充值已达到 1000 元
- **THEN** 系统触发一次性佣金计算,发放佣金
#### Scenario: 未达阈值不触发
- **WHEN** 充值后累计充值未达到阈值
- **THEN** 系统不触发一次性佣金计算
#### Scenario: 已发放过不重复触发
- **WHEN** 卡的一次性佣金已发放过first_commission_paid = true
- **THEN** 系统不触发一次性佣金计算
---
### Requirement: 充值订单状态流转
充值订单状态 SHALL 按以下流程流转:待支付 → 已支付 → 已完成。
#### Scenario: 正常流转
- **WHEN** 创建充值订单 → 支付成功 → 钱包余额增加完成
- **THEN** 订单状态依次为1待支付→ 2已支付→ 3已完成
#### Scenario: 超时未支付
- **WHEN** 充值订单创建 30 分钟后仍未支付
- **THEN** 系统标记订单为已关闭(状态 4
---
### Requirement: 充值金额限制
系统 SHALL 限制单次充值金额范围。
#### Scenario: 充值金额范围
- **WHEN** 创建充值订单
- **THEN** 充值金额必须在 1 元到 100000 元之间
#### Scenario: 充值金额过小
- **WHEN** 客户尝试充值 0.5 元
- **THEN** 系统返回错误 "充值金额不能小于1元"
#### Scenario: 充值金额过大
- **WHEN** 客户尝试充值 200000 元
- **THEN** 系统返回错误 "单次充值金额不能超过100000元"

View File

@@ -0,0 +1,227 @@
## 1. 数据库迁移
- [x] 1.1 创建迁移文件tb_order 表新增 is_purchase_on_behalf 字段
- [x] 1.2 创建迁移文件tb_shop_series_allocation 表新增强充配置字段enable_force_recharge, force_recharge_amount
- [x] 1.3 在测试环境执行迁移并验证字段添加成功
- [x] 1.4 验证迁移:检查字段默认值和数据类型是否正确
## 2. 常量定义
- [x] 2.1 在 pkg/constants/ 定义充值订单状态常量(待支付、已支付、已完成、已关闭)
- [x] 2.2 在 pkg/constants/ 定义充值订单号前缀常量RCH
- [x] 2.3 在 pkg/constants/ 定义线下支付方式常量offline
- [x] 2.4 在 pkg/constants/ 定义强充相关 Redis Key 生成函数(可选,如缓存系列配置)
- [x] 2.5 在 pkg/constants/ 定义充值金额限制常量最小1元最大100000元
## 3. 错误码定义
- [x] 3.1 在 pkg/errors/ 定义充值相关错误码(充值金额不符、充值订单不存在等)
- [x] 3.2 在 pkg/errors/ 定义代购相关错误码(只有平台可使用线下支付、只能为下级代理代购等)
- [x] 3.3 在 pkg/errors/ 定义强充验证错误码必须充值X元等
## 4. Model 层修改
- [x] 4.1 修改 internal/model/order.go新增 IsPurchaseOnBehalf 字段bool, default false
- [x] 4.2 修改 internal/model/shop_series_allocation.go新增 EnableForceRecharge 字段bool, default false
- [x] 4.3 修改 internal/model/shop_series_allocation.go新增 ForceRechargeAmount 字段int64, default 0
- [x] 4.4 验证 Model 修改:运行 lsp_diagnostics 检查类型错误
## 5. RechargeStore 数据访问层
- [x] 5.1 创建 internal/store/postgres/recharge_store.go
- [x] 5.2 实现 Create 方法:创建充值订单
- [x] 5.3 实现 GetByRechargeNo 方法:根据充值单号查询
- [x] 5.4 实现 GetByID 方法根据ID查询充值订单详情
- [x] 5.5 实现 List 方法:分页查询充值订单列表,支持状态筛选、时间范围筛选
- [x] 5.6 实现 UpdateStatus 方法:更新充值订单状态(支付成功回调时使用)
- [x] 5.7 实现 UpdatePaymentInfo 方法:更新支付方式和支付时间
- [x] 5.8 编写单元测试:测试覆盖率 ≥ 90%
- [x] 5.9 验证测试:运行测试并确保全部通过
## 6. RechargeService 业务逻辑层
- [x] 6.1 创建 internal/service/recharge/service.go
- [x] 6.2 实现 Create 方法:创建充值订单
- 验证资源存在(卡/设备)
- 验证充值金额范围1元~100000元
- 检查强充要求并验证金额
- 生成充值单号RCH + 时间戳 + 随机数)
- 创建充值订单记录
- [x] 6.3 实现 GetRechargeCheck 方法:充值预检
- 查询资源(卡/设备)
- 查询系列分配配置
- 判断是否需要强充
- 返回强充要求、允许金额范围、提示信息
- [x] 6.4 实现 GetByID 方法:查询充值订单详情(数据权限过滤)
- [x] 6.5 实现 List 方法:查询充值订单列表(分页、筛选、数据权限过滤)
- [x] 6.6 实现 HandlePaymentCallback 方法:处理支付回调
- 幂等性检查(检查订单状态)
- 使用数据库事务:更新订单状态 → 增加钱包余额 → 更新累计充值 → 触发佣金判断
- 钱包余额更新使用原子操作
- [x] 6.7 实现 updateAccumulatedRecharge 私有方法:更新卡/设备的累计充值金额
- [x] 6.8 实现 triggerOneTimeCommissionIfNeeded 私有方法:检查并触发一次性佣金
- [x] 6.9 实现 checkForceRechargeRequirement 私有方法:检查强充要求(供创建订单时使用)
- [x] 6.10 编写单元测试:测试覆盖率 ≥ 90%(包含各种充值场景、强充验证、支付回调幂等性)
- [x] 6.11 验证测试:运行测试并确保全部通过
## 7. OrderService 修改(代购订单和强充验证)
- [x] 7.1 修改 internal/service/order/service.goCreate 方法增加强充验证
- 调用预检逻辑获取强充要求
- 验证支付金额是否符合强充要求
- 不符合则返回错误
- [x] 7.2 新增 CreatePurchaseOnBehalf 方法:创建代购订单
- 验证权限(只有平台可以使用线下支付)
- 查询资源归属代理buyer_id
- 查询买家的成本价
- 创建订单is_purchase_on_behalf = true, payment_method = offline, payment_status = 2
- 自动激活套餐(创建 PackageUsage
- 触发佣金计算任务
- [x] 7.3 新增 GetPurchaseCheck 方法:套餐购买预检
- 查询套餐总价
- 检查强充要求
- 计算实际支付金额和钱包到账金额
- 返回预检信息(含提示消息)
- [x] 7.4 修改支付成功后的处理逻辑:增加 is_purchase_on_behalf 判断
- 如果是代购订单,跳过钱包扣款
- 如果是普通订单,正常扣款
- [x] 7.5 编写单元测试:测试代购订单创建、强充验证、预检逻辑
- [x] 7.6 验证测试:运行测试并确保全部通过
## 8. CommissionCalculationService 修改
- [x] 8.1 修改 internal/service/commission_calculation/service.goCalculateCommission 方法增加代购订单判断
- 差价佣金:所有订单都计算(包括代购)
- 累计充值更新仅非代购订单更新is_purchase_on_behalf = false
- 一次性佣金仅非代购订单触发is_purchase_on_behalf = false
- [x] 8.2 修改 updateAccumulatedRecharge 方法:增加代购订单检查
- 如果 is_purchase_on_behalf = true直接返回不更新累计充值
- 如果 is_purchase_on_behalf = false正常更新累计充值
- [x] 8.3 修改 triggerOneTimeCommission 方法:增加代购订单检查
- 如果 is_purchase_on_behalf = true直接返回不触发一次性佣金
- 如果 is_purchase_on_behalf = false正常检查阈值并触发佣金
- [x] 8.4 编写单元测试:使用 table-driven tests 测试各种场景(普通订单、代购订单、充值订单)
- [x] 8.5 验证测试:运行测试并确保全部通过
## 9. RechargeHandler HTTP 接口层
- [x] 9.1 创建 internal/handler/h5/recharge.go
- [x] 9.2 实现 POST /api/h5/wallets/recharge创建充值订单
- 参数验证resource_type, resource_id, amount
- 调用 Service 创建充值订单
- 返回充值订单信息
- [x] 9.3 实现 GET /api/h5/wallets/recharge-check充值预检
- 参数验证resource_type, resource_id
- 调用 Service 获取强充要求
- 返回预检信息
- [x] 9.4 实现 GET /api/h5/wallets/recharges查询充值订单列表
- 支持分页参数page, page_size
- 支持状态筛选status
- 支持时间范围筛选start_time, end_time
- [x] 9.5 实现 GET /api/h5/wallets/recharges/:id查询充值订单详情
- [x] 9.6 为所有接口添加中文注释(路由路径、参数说明、响应说明)
- [x] 9.7 验证接口:运行 lsp_diagnostics 检查类型错误
## 10. OrderHandler 修改(代购订单接口)
- [x] 10.1 修改 internal/handler/admin/order.goCreate 方法增加代购订单支持
- 检查 payment_method = offline 时,验证用户类型为 Platform
- 如果是平台账号且线下支付,调用 CreatePurchaseOnBehalf
- 否则调用正常的 Create 方法
- [x] 10.2 新增 POST /api/admin/orders/purchase-check套餐购买预检
- 参数验证order_type, resource_id, package_ids
- 调用 Service 获取预检信息
- 返回预检结果
- [x] 10.3 为新增接口添加中文注释
- [x] 10.4 验证接口:运行 lsp_diagnostics 检查类型错误
## 11. PaymentCallback 修改(充值订单回调)
- [x] 11.1 修改 internal/handler/callback/payment.goWechatPayCallback 方法增加充值订单判断
- 根据订单号前缀判断类型RCH 开头 → 充值订单)
- 如果是充值订单,调用 RechargeService.HandlePaymentCallback
- 如果是套餐订单,调用 OrderService.HandlePaymentCallback
- [x] 11.2 修改 AlipayCallback 方法:增加充值订单判断(同上)
- [x] 11.3 验证修改:运行 lsp_diagnostics 检查类型错误
## 12. Bootstrap 依赖注入
- [x] 12.1 修改 internal/bootstrap/stores.go注册 RechargeStore
- [x] 12.2 修改 internal/bootstrap/services.go注册 RechargeService
- [x] 12.3 修改 internal/bootstrap/handlers.go注册 RechargeHandlerH5
- [x] 12.4 验证依赖注入:确保所有依赖正确传递
## 13. 路由注册
- [x] 13.1 修改 internal/router/h5.go注册充值相关路由
- POST /api/h5/wallets/recharge
- GET /api/h5/wallets/recharge-check
- GET /api/h5/wallets/recharges
- GET /api/h5/wallets/recharges/:id
- [x] 13.2 修改 internal/router/admin.go注册代购预检路由
- POST /api/admin/orders/purchase-check
- [x] 13.3 验证路由:确保所有路由正确绑定到 Handler 方法
## 14. API 文档生成器更新
- [x] 14.1 修改 cmd/api/docs.go在 Handlers 初始化中添加 RechargeHandler
- [x] 14.2 修改 cmd/gendocs/main.go在 Handlers 初始化中添加 RechargeHandler
- [x] 14.3 运行文档生成命令go run cmd/gendocs/main.go
- [x] 14.4 验证生成的 OpenAPI 文档:检查充值和代购相关接口是否出现
## 15. 集成测试
- [x] 15.1 编写充值完整流程集成测试
- 创建充值订单(无强充)
- 创建充值订单(首次强充验证)
- 创建充值订单(累计强充验证)
- 模拟支付回调
- 验证钱包余额增加
- 验证累计充值更新
- 验证一次性佣金触发
- [x] 15.2 编写代购订单完整流程集成测试
- 平台创建代购订单
- 验证订单自动完成
- 验证套餐激活
- 验证差价佣金计算
- 验证一次性佣金不触发
- 验证累计充值不更新
- [x] 15.3 编写强充预检集成测试
- 充值预检(各种强充场景)
- 套餐购买预检(各种强充场景)
- [x] 15.4 验证测试:运行所有集成测试并确保通过
## 16. 功能手动验证(开发环境)
- [x] 16.1 验证充值预检接口:调用接口确认返回正确的强充要求
- [x] 16.2 验证购买预检接口:调用接口确认实际支付金额计算正确
- [x] 16.3 验证充值订单创建:创建订单并确认数据库记录正确
- [x] 16.4 验证代购订单创建:创建代购订单并确认套餐自动激活
## 17. 数据库验证(使用 PostgreSQL MCP
- [x] 17.1 验证 tb_order 表字段:检查 is_purchase_on_behalf 字段及默认值
- [x] 17.2 验证 tb_shop_series_allocation 表字段:检查 enable_force_recharge 和 force_recharge_amount 字段
- [x] 17.3 验证充值订单创建:执行创建后查询数据库确认记录正确
- [x] 17.4 验证代购订单创建:执行创建后查询订单表和套餐使用表
- [x] 17.5 验证累计充值更新:执行充值/购买后查询卡/设备的 accumulated_recharge 字段
- [x] 17.6 验证佣金计算:执行订单后查询佣金记录表,确认代购订单不触发一次性佣金
## 18. 文档更新
- [x] 18.1 在 docs/ 目录创建功能总结文档(中文,简要说明业务规则和 API 接口)
- [x] 18.2 更新 README.md添加强充系统和代购订单功能说明可选
## 19. 代码规范检查
- [x] 19.1 运行 lsp_diagnostics 检查所有修改的文件
- [x] 19.2 运行代码规范检查脚本(如有)
- [x] 19.3 确保所有注释使用中文
- [x] 19.4 确保所有常量定义在 pkg/constants/
- [x] 19.5 确保所有错误码定义在 pkg/errors/
## 20. 开发完成验证
- [x] 20.1 执行数据库迁移(开发环境)
- [x] 20.2 运行完整测试套件并确保全部通过
- [x] 20.3 本地启动服务验证功能可用性

View File

@@ -2,10 +2,14 @@
### Requirement: 订单支付后触发佣金计算
系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。
系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。代购订单和普通订单的佣金计算逻辑不同。
#### Scenario: 支付成功触发计算
- **WHEN** 订单支付状态变为已支付
#### Scenario: 普通订单支付成功触发计算
- **WHEN** 普通订单is_purchase_on_behalf = false支付状态变为已支付
- **THEN** 系统发送佣金计算异步任务
#### Scenario: 代购订单支付成功触发计算
- **WHEN** 代购订单is_purchase_on_behalf = true创建成功自动已支付
- **THEN** 系统发送佣金计算异步任务
#### Scenario: 重复支付不重复计算
@@ -48,35 +52,36 @@
### Requirement: 更新累计充值金额
订单支付成功后系统 SHALL 更新卡/设备的累计充值金额。
订单支付成功后系统 SHALL 更新卡/设备的累计充值金额,但代购订单除外
**关键修复**:每次支付成功都必须写回累计充值金额,确保累计值能正确用于一次性佣金的累计触发判断
**关键修复**:每次真实充值(个人客户充值或购买套餐)都必须写回累计充值金额,代购订单不更新
#### Scenario: 单卡订单更新累计充值
- **WHEN** 单卡订单支付成功,金额 100 元
#### Scenario: 普通单卡订单更新累计充值
- **WHEN** 普通单卡订单is_purchase_on_behalf = false支付成功金额 100 元
- **THEN** 系统读取 IotCard.accumulated_recharge 当前值
- **AND** 增加 10000 分100 元 = 10000 分)
- **AND** 将新值写回 IotCard.accumulated_recharge
- **AND** 使用更新后的累计值判断是否触发一次性佣金
#### Scenario: 设备订单更新累计充值
- **WHEN** 设备订单支付成功,金额 300 元
#### Scenario: 普通设备订单更新累计充值
- **WHEN** 普通设备订单is_purchase_on_behalf = false支付成功金额 300 元
- **THEN** 系统读取 Device.accumulated_recharge 当前值
- **AND** 增加 30000 分300 元 = 30000 分)
- **AND** 将新值写回 Device.accumulated_recharge
- **AND** 使用更新后的累计值判断是否触发一次性佣金
#### Scenario: 累计充值更新使用原子操作
#### Scenario: 代购订单不更新累计充值
- **WHEN** 代购订单is_purchase_on_behalf = true完成金额 100 元
- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段
- **AND** accumulated_recharge 保持原值
#### Scenario: 累计充值更新使用原子操作
- **WHEN** 更新累计充值金额
- **THEN** 系统使用 SQL 原子操作(如 `accumulated_recharge = accumulated_recharge + ?`
- **OR** 使用 GORM 乐观锁version 字段)
- **AND** 确保并发场景下累计值不会丢失
#### Scenario: 更新失败不影响佣金计算
- **WHEN** 累计充值金额更新失败(数据库错误、并发冲突等)
- **THEN** 系统记录错误日志
- **AND** 继续执行后续的佣金计算流程(成本价差、一次性佣金等)
@@ -140,3 +145,48 @@
- **WHEN** 累计充值金额更新失败
- **THEN** 系统在日志中记录:订单 ID、资源 ID、失败原因错误信息、重试次数如适用
---
### Requirement: 代购订单佣金计算规则
代购订单 SHALL 计算差价佣金,但不触发一次性佣金。
#### Scenario: 代购订单计算差价佣金
- **WHEN** 代购订单is_purchase_on_behalf = true完成买家有上级代理
- **THEN** 系统计算差价佣金(买家成本价 - 上级成本价),发放给上级代理链
#### Scenario: 代购订单不触发一次性佣金
- **WHEN** 代购订单完成,佣金计算时检查订单类型
- **THEN** 系统跳过一次性佣金判断逻辑,不发放一次性佣金
#### Scenario: 代购订单示例
- **WHEN** 平台为三级代理代购,订单金额 100 元(三级成本价),各级成本价:一级 60 → 二级 70 → 三级 80
- **THEN** 二级获得 10 元80 - 70差价佣金一级获得 10 元70 - 60差价佣金
- **AND** 三级、二级、一级都不获得一次性佣金
---
### Requirement: 钱包充值触发一次性佣金
钱包充值成功后 SHALL 更新累计充值,并检查是否触发一次性佣金。
#### Scenario: 充值成功更新累计充值
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
- **THEN** 系统更新卡的 accumulated_recharge 为 300 元
#### Scenario: 充值达到首次充值阈值
- **WHEN** 卡配置为首次充值触发,阈值 100 元,充值 100 元成功,未发放过佣金
- **THEN** 系统触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
#### Scenario: 充值达到累计充值阈值
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,充值后累计达到 1000 元,未发放过佣金
- **THEN** 系统触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
#### Scenario: 充值未达阈值不触发
- **WHEN** 充值后累计充值未达到阈值
- **THEN** 系统不触发一次性佣金计算
#### Scenario: 已发放过不重复触发
- **WHEN** 卡的一次性佣金已发放过first_commission_paid = true
- **THEN** 系统不触发一次性佣金计算

View File

@@ -0,0 +1,115 @@
# Capability: 强充预检
## Purpose
本 capability 定义强充预检接口,在充值或购买套餐前返回强充要求、允许的充值金额等信息,帮助前端正确引导用户完成支付。
## Requirements
### Requirement: 钱包充值预检
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。
#### Scenario: 无强充要求
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发且未启用强充
- **THEN** 系统返回 need_force_recharge = falsemin_amount = 1001元max_amount = null
#### Scenario: 首次充值强充
- **WHEN** 客户查询卡钱包充值预检,卡配置为首次充值触发,阈值 10000 分100元未发放佣金
- **THEN** 系统返回 need_force_recharge = trueforce_recharge_amount = 10000trigger_type = "single_recharge"message = "首次充值需充值100元"
#### Scenario: 累计充值启用强充
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发,启用强充,强充金额 10000 分100元
- **THEN** 系统返回 need_force_recharge = trueforce_recharge_amount = 10000trigger_type = "accumulated_recharge"message = "每次充值需充值100元"
#### Scenario: 一次性佣金已发放
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
- **THEN** 系统返回 need_force_recharge = false不再强充
#### Scenario: 未启用一次性佣金
- **WHEN** 客户查询卡钱包充值预检,卡关联系列未启用一次性佣金
- **THEN** 系统返回 need_force_recharge = false
---
### Requirement: 套餐购买预检
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。
#### Scenario: 无强充要求正常购买
- **WHEN** 客户购买 90 元套餐,无强充要求
- **THEN** 系统返回 total_package_amount = 9000need_force_recharge = falseactual_payment = 9000wallet_credit = 0
#### Scenario: 首次充值强充,套餐价低于阈值
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 9000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 10000wallet_credit = 1000message = "需充值100元购买套餐后余额10元"
#### Scenario: 首次充值强充,套餐价高于阈值
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 15000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 15000wallet_credit = 0message = "套餐总价150元无需额外充值"
#### Scenario: 首次充值强充,套餐价等于阈值
- **WHEN** 客户购买 100 元套餐,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 10000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 10000wallet_credit = 0
#### Scenario: 累计充值启用强充,套餐价低于强充金额
- **WHEN** 客户购买 50 元套餐,累计充值启用强充,强充金额 100 元
- **THEN** 系统返回 actual_payment = 10000wallet_credit = 5000message = "需充值100元购买套餐后余额50元"
#### Scenario: 累计充值启用强充,套餐价高于强充金额
- **WHEN** 客户购买 150 元套餐,累计充值启用强充,强充金额 100 元
- **THEN** 系统返回 actual_payment = 15000wallet_credit = 0message = "套餐总价150元无需额外充值"
#### Scenario: 购买多个套餐
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount = 12000actual_payment = 12000wallet_credit = 0
---
### Requirement: 预检接口响应格式
预检接口响应 SHALL 包含完整的充值/购买指引信息。
#### Scenario: 充值预检响应字段
- **WHEN** 调用钱包充值预检接口
- **THEN** 响应包含need_force_recharge, force_recharge_amount, trigger_type, min_amount, max_amount, current_accumulated, threshold, message
#### Scenario: 购买预检响应字段
- **WHEN** 调用套餐购买预检接口
- **THEN** 响应包含total_package_amount, need_force_recharge, force_recharge_amount, actual_payment, wallet_credit, message
---
### Requirement: 预检接口性能
预检接口响应时间 MUST 小于 100ms。
#### Scenario: 快速响应
- **WHEN** 调用预检接口
- **THEN** 系统在 100ms 内返回结果
#### Scenario: 缓存系列分配配置
- **WHEN** 频繁查询同一卡的预检信息
- **THEN** 系统可以缓存系列分配配置,减少数据库查询
---
### Requirement: 预检接口错误处理
预检接口 SHALL 正确处理异常情况。
#### Scenario: 卡不存在
- **WHEN** 查询不存在的卡的充值预检
- **THEN** 系统返回错误 "卡不存在"
#### Scenario: 卡未关联系列
- **WHEN** 查询未关联套餐系列的卡的充值预检
- **THEN** 系统返回 need_force_recharge = false无系列分配无强充要求
#### Scenario: 设备不存在
- **WHEN** 查询不存在的设备的充值预检
- **THEN** 系统返回错误 "设备不存在"
#### Scenario: 套餐不存在
- **WHEN** 套餐购买预检时,套餐 ID 不存在
- **THEN** 系统返回错误 "套餐不存在"

View File

@@ -0,0 +1,220 @@
# Gateway Client Specification
Gateway API 统一客户端,提供 14 个接口的类型安全封装。
## ADDED Requirements
### Requirement: Gateway 客户端结构
系统 SHALL 提供 `gateway.Client` 结构体,封装所有 Gateway API 调用。
客户端字段:
- `baseURL string` - Gateway API 基础 URL
- `appID string` - 应用 ID
- `appSecret string` - 应用密钥
- `httpClient *http.Client` - HTTP 客户端(支持连接复用)
- `timeout time.Duration` - 请求超时时间
#### Scenario: 创建 Gateway 客户端
- **WHEN** 调用 `gateway.NewClient(baseURL, appID, appSecret)`
- **THEN** 返回已初始化的 `Client` 实例
- **AND** HTTP 客户端配置正确(支持 Keep-Alive
#### Scenario: 配置超时时间
- **WHEN** 调用 `client.WithTimeout(30 * time.Second)`
- **THEN** 客户端的 `timeout` 字段更新为 30 秒
- **AND** 返回客户端自身(支持链式调用)
### Requirement: 统一请求方法
系统 SHALL 提供 `doRequest` 方法统一处理加密、签名、HTTP 请求和响应解析。
#### Scenario: 成功的 API 调用
- **WHEN** 调用 `doRequest(ctx, "/flow-card/status", businessData)`
- **THEN** 业务数据使用 AES-128-ECB 加密
- **AND** 请求使用 MD5 签名
- **AND** HTTP POST 发送到 `{baseURL}/flow-card/status`
- **AND** 响应中的 `data` 字段解密并返回
#### Scenario: 网络错误
- **WHEN** HTTP 请求失败网络中断、DNS 解析失败)
- **THEN** 返回 `CodeGatewayError` 错误
- **AND** 错误信息包含原始网络错误
#### Scenario: 请求超时
- **WHEN** HTTP 请求超过配置的超时时间
- **THEN** 返回 `CodeGatewayTimeout` 错误
- **AND** Context 超时错误被正确识别
#### Scenario: 响应格式错误
- **WHEN** Gateway 响应无法解析为 JSON
- **THEN** 返回 `CodeGatewayInvalidResp` 错误
- **AND** 错误信息包含原始响应内容(限制 200 字符)
#### Scenario: Gateway 业务错误
- **WHEN** Gateway 响应中 `code != 200`
- **THEN** 返回 `CodeGatewayError` 错误
- **AND** 错误信息包含 Gateway 的 code 和 msg
### Requirement: 流量卡 API 封装
系统 SHALL 提供 7 个流量卡相关的 API 方法。
#### Scenario: 查询流量卡状态
- **WHEN** 调用 `client.QueryCardStatus(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
- **THEN** 返回 `CardStatusResp` 包含 ICCID 和卡状态
- **AND** 卡状态为:"准备"、"正常" 或 "停机" 之一
#### Scenario: 查询流量使用
- **WHEN** 调用 `client.QueryFlow(ctx, &FlowQueryReq{CardNo: "898608070422D0010269"})`
- **THEN** 返回 `FlowUsageResp` 包含已用流量和单位
- **AND** 流量单位为 "MB"
#### Scenario: 查询实名认证状态
- **WHEN** 调用 `client.QueryRealnameStatus(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
- **THEN** 返回实名认证状态信息
#### Scenario: 流量卡停机
- **WHEN** 调用 `client.StopCard(ctx, &CardOperationReq{CardNo: "898608070422D0010269"})`
- **THEN** Gateway 执行停机操作
- **AND** 方法返回 nil成功或错误
#### Scenario: 流量卡复机
- **WHEN** 调用 `client.StartCard(ctx, &CardOperationReq{CardNo: "898608070422D0010269"})`
- **THEN** Gateway 执行复机操作
- **AND** 方法返回 nil成功或错误
#### Scenario: 获取实名认证链接
- **WHEN** 调用 `client.GetRealnameLink(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
- **THEN** 返回实名认证跳转链接
- **AND** 链接格式为有效的 HTTPS URL
#### Scenario: 广电国网扩展参数
- **WHEN** 停机/复机请求中 `Extend` 字段不为空
- **THEN** 请求包含 `extend` 参数
- **AND** Gateway 正确处理广电国网特殊逻辑
### Requirement: 设备 API 封装
系统 SHALL 提供 7 个设备相关的 API 方法。
#### Scenario: 查询设备信息
- **WHEN** 调用 `client.GetDeviceInfo(ctx, &DeviceInfoReq{CardNo: "898608070422D0010269"})`
- **THEN** 返回 `DeviceInfoResp` 包含设备详细信息
- **AND** 信息包括IMEI、在线状态、信号强度、WiFi 配置、速率等
#### Scenario: 通过设备 ID 查询
- **WHEN** 调用 `client.GetDeviceInfo(ctx, &DeviceInfoReq{DeviceID: "868123456789012"})`
- **THEN** 通过设备 IMEI 查询设备信息
- **AND** 返回结果与通过卡号查询一致
#### Scenario: 查询设备卡槽信息
- **WHEN** 调用 `client.GetSlotInfo(ctx, &DeviceInfoReq{CardNo: "898608070422D0010269"})`
- **THEN** 返回设备中已安装的物联网卡信息
#### Scenario: 设置设备限速
- **WHEN** 调用 `client.SetSpeedLimit(ctx, &SpeedLimitReq{DeviceID: "868123456789012", UploadSpeed: 1024, DownloadSpeed: 2048})`
- **THEN** 设备上下行速率设置为指定值KB/s
#### Scenario: 设置设备 WiFi
- **WHEN** 调用 `client.SetWiFi(ctx, &WiFiReq{DeviceID: "868123456789012", SSID: "MyWiFi", Password: "12345678", Enabled: true})`
- **THEN** 设备 WiFi 配置更新
- **AND** WiFi 名称、密码和启用状态正确设置
#### Scenario: 设备切换卡
- **WHEN** 调用 `client.SwitchCard(ctx, &SwitchCardReq{DeviceID: "868123456789012", TargetICCID: "898608070422D0010270"})`
- **THEN** 多卡设备切换到目标 ICCID
#### Scenario: 设备恢复出厂设置
- **WHEN** 调用 `client.ResetDevice(ctx, &DeviceOperationReq{DeviceID: "868123456789012"})`
- **THEN** 设备恢复为出厂状态
#### Scenario: 设备重启
- **WHEN** 调用 `client.RebootDevice(ctx, &DeviceOperationReq{DeviceID: "868123456789012"})`
- **THEN** 设备执行重启操作
### Requirement: 类型安全的 DTO
系统 SHALL 为所有请求和响应定义类型安全的结构体。
#### Scenario: 请求 DTO 包含验证标签
- **WHEN** 定义 `CardStatusReq` 结构体
- **THEN** `CardNo` 字段包含 `validate:"required"` 标签
- **AND** 可以使用 Validator 库进行验证
#### Scenario: 响应 DTO 正确解析
- **WHEN** Gateway 返回 JSON 响应
- **THEN** `CardStatusResp` 结构体正确解析 `iccid``cardStatus``extend` 字段
- **AND** 字段类型与 Gateway 文档一致
### Requirement: 并发安全
系统 SHALL 确保 `Client` 结构体可以安全地并发调用。
#### Scenario: 多个 Goroutine 并发调用
- **WHEN** 10 个 Goroutine 同时调用 `client.QueryCardStatus`
- **THEN** 所有请求都正确执行
- **AND** 不发生 race condition
#### Scenario: HTTP 连接复用
- **WHEN** 多次调用相同的 Gateway API
- **THEN** HTTP 客户端复用 TCP 连接
- **AND** 减少连接建立开销
### Requirement: 错误处理一致性
系统 SHALL 使用项目统一的错误码系统。
#### Scenario: Gateway 错误返回统一错误码
- **WHEN** Gateway API 调用失败
- **THEN** 返回 `errors.AppError` 类型
- **AND** 错误码为 `CodeGatewayError``CodeGatewayTimeout` 等之一
#### Scenario: 错误包含上下文信息
- **WHEN** 加密失败
- **THEN** 错误信息为 "数据加密失败"
- **AND** 包含底层错误的详细信息
### Requirement: Context 支持
系统 SHALL 支持通过 Context 控制请求超时和取消。
#### Scenario: 使用 Context 控制超时
- **WHEN** 调用 `client.QueryCardStatus(ctx, req)` 且 ctx 设置了 30 秒超时
- **THEN** 请求在 30 秒后自动超时
- **AND** 返回 `CodeGatewayTimeout` 错误
#### Scenario: 取消请求
- **WHEN** 调用 `client.QueryCardStatus(ctx, req)` 且 ctx 被取消
- **THEN** 请求立即停止
- **AND** 返回 context canceled 错误

View File

@@ -0,0 +1,175 @@
# Gateway Config Specification
Gateway API 的配置集成规范,定义配置结构和加载方式。
## ADDED Requirements
### Requirement: Gateway 配置结构
系统 SHALL 在 `pkg/config/config.go` 中添加 `GatewayConfig` 结构体。
配置字段:
- `BaseURL string` - Gateway API 基础 URL
- `AppID string` - 应用 ID
- `AppSecret string` - 应用密钥
- `Timeout int` - 请求超时时间(秒)
#### Scenario: 配置结构定义
- **WHEN** 定义 `GatewayConfig` 结构体
- **THEN** 包含 `mapstructure` 标签用于 Viper 解析
- **AND** 字段名使用 snake_case`base_url``app_id`
#### Scenario: 集成到主配置
- **WHEN** 在 `Config` 结构体中添加 `Gateway GatewayConfig` 字段
- **THEN** 使用 `mapstructure:"gateway"` 标签
- **AND** 配置可通过 `config.Get().Gateway` 访问
### Requirement: 默认配置嵌入
系统 SHALL 在 `pkg/config/defaults/config.yaml` 中添加 Gateway 默认配置。
#### Scenario: 嵌入默认配置
- **WHEN** 读取嵌入的默认配置文件
- **THEN** 包含 `gateway` 配置节
- **AND** 配置包含:
```yaml
gateway:
base_url: "https://lplan.whjhft.com/openapi"
app_id: "60bgt1X8i7AvXqkd"
app_secret: "BZeQttaZQt0i73moF"
timeout: 30
```
### Requirement: 环境变量覆盖
系统 SHALL 支持通过环境变量覆盖 Gateway 配置。
环境变量格式:`JUNHONG_GATEWAY_{KEY}`
#### Scenario: 覆盖 BaseURL
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_BASE_URL=https://test.example.com`
- **THEN** `config.Gateway.BaseURL` 的值为 "https://test.example.com"
- **AND** 覆盖嵌入配置中的默认值
#### Scenario: 覆盖 AppID
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_APP_ID=test_app_id`
- **THEN** `config.Gateway.AppID` 的值为 "test_app_id"
#### Scenario: 覆盖 AppSecret
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_APP_SECRET=test_secret`
- **THEN** `config.Gateway.AppSecret` 的值为 "test_secret"
#### Scenario: 覆盖 Timeout
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_TIMEOUT=60`
- **THEN** `config.Gateway.Timeout` 的值为 60
### Requirement: 配置验证
系统 SHALL 在配置加载后验证 Gateway 配置的有效性。
#### Scenario: 必填字段验证
- **WHEN** 配置加载完成
- **THEN** 验证 `BaseURL`、`AppID`、`AppSecret` 不为空
- **AND** 如果为空,返回明确的错误信息
#### Scenario: BaseURL 格式验证
- **WHEN** 验证 `BaseURL` 字段
- **THEN** 必须以 `http://` 或 `https://` 开头
- **AND** 不能以 `/` 结尾
#### Scenario: Timeout 范围验证
- **WHEN** 验证 `Timeout` 字段
- **THEN** 值必须在 5 到 300 秒之间
- **AND** 如果超出范围,返回验证错误
#### Scenario: AppID 格式验证
- **WHEN** 验证 `AppID` 字段
- **THEN** 长度必须 > 0
- **AND** 不包含特殊字符(仅允许字母、数字、下划线)
### Requirement: 敏感配置处理
系统 SHALL 确保 `AppSecret` 不记录到日志中。
#### Scenario: 配置日志脱敏
- **WHEN** 记录配置加载成功的日志
- **THEN** `AppSecret` 字段显示为 "***"
- **AND** 实际值不出现在日志中
#### Scenario: 错误日志脱敏
- **WHEN** 配置验证失败并记录错误日志
- **THEN** `AppSecret` 字段显示为 "***"
### Requirement: Gateway 客户端初始化
系统 SHALL 在 `internal/bootstrap/bootstrap.go` 中初始化 Gateway 客户端。
#### Scenario: Bootstrap 中初始化
- **WHEN** 调用 `bootstrap.Bootstrap(deps)`
- **THEN** 从 `deps.Config.Gateway` 读取配置
- **AND** 调用 `gateway.NewClient(baseURL, appID, appSecret).WithTimeout(...)`
- **AND** 将客户端赋值给 `deps.GatewayClient`
#### Scenario: 配置错误时启动失败
- **WHEN** Gateway 配置验证失败
- **THEN** `bootstrap.Bootstrap` 返回错误
- **AND** 应用启动失败
### Requirement: 多环境配置支持
系统 SHALL 支持通过环境变量切换不同环境的 Gateway 配置。
#### Scenario: 开发环境配置
- **WHEN** 使用默认嵌入配置(未设置环境变量)
- **THEN** 使用生产环境的 Gateway URL 和凭证
#### Scenario: 测试环境配置
- **WHEN** 设置环境变量指向测试 Gateway
- **AND** `JUNHONG_GATEWAY_BASE_URL=https://test-gateway.example.com`
- **AND** `JUNHONG_GATEWAY_APP_ID=test_app_id`
- **THEN** 客户端连接到测试环境
## MODIFIED Requirements
### Requirement: Config 结构体扩展
系统 SHALL 在现有的 `Config` 结构体中添加 `Gateway` 字段。
#### Scenario: 配置结构兼容性
- **WHEN** 添加 `Gateway GatewayConfig` 字段
- **THEN** 不影响现有配置字段的加载
- **AND** 现有配置Server、Database、Redis 等)继续正常工作
### Requirement: Dependencies 结构体扩展
系统 SHALL 在 `internal/bootstrap/bootstrap.go` 的 `Dependencies` 结构体中添加 `GatewayClient` 字段。
#### Scenario: 依赖注入扩展
- **WHEN** 在 `Dependencies` 中添加 `GatewayClient *gateway.Client` 字段
- **THEN** 不影响现有依赖的注入
- **AND** Gateway 客户端可以注入到需要的 Service
#### Scenario: Service 层使用
- **WHEN** Service 需要调用 Gateway API
- **THEN** 在 Service 构造函数中接收 `gatewayClient *gateway.Client` 参数
- **AND** 从 Bootstrap 中传递 `deps.GatewayClient`

View File

@@ -0,0 +1,155 @@
# Gateway Crypto Specification
Gateway API 的加密和签名工具函数,实现 AES-128-ECB 加密和 MD5 签名机制。
## ADDED Requirements
### Requirement: AES-128-ECB 加密
系统 SHALL 提供 `aesEncrypt` 函数,使用 AES-128-ECB 模式加密业务数据。
加密流程:
1. 密钥生成:`MD5(appSecret)` 的原始字节数组16字节
2. 加密算法AES-128-ECB
3. 填充方式PKCS5Padding
4. 编码输出Base64
#### Scenario: 加密业务数据
- **WHEN** 调用 `aesEncrypt(data, appSecret)`
- **AND** `data` 为业务数据的 JSON 字节数组
- **THEN** 返回 Base64 编码的加密字符串
- **AND** 密钥为 `MD5(appSecret)` 的 16 字节数组
#### Scenario: PKCS5 填充正确性
- **WHEN** 业务数据长度不是 AES 块大小16 字节)的整数倍
- **THEN** 使用 PKCS5Padding 进行填充
- **AND** 填充字节值等于填充长度
#### Scenario: 加密输出格式
- **WHEN** 加密成功
- **THEN** 输出为 Base64 字符串
- **AND** 字符串不包含换行符
#### Scenario: 加密失败
- **WHEN** AES 加密过程失败
- **THEN** 返回 `CodeGatewayEncryptError` 错误
- **AND** 错误信息包含原始错误
### Requirement: MD5 签名生成
系统 SHALL 提供 `generateSign` 函数,生成 MD5 签名。
签名流程:
1. 参数排序:`appId``data``timestamp` 按字母升序
2. 拼接字符串:`appId=xxx&data=xxx&timestamp=xxx&key=appSecret`
3. MD5 加密
4. 转大写十六进制
#### Scenario: 生成正确的签名
- **WHEN** 调用 `generateSign(appID, encryptedData, timestamp, appSecret)`
- **THEN** 参数按字母序拼接:`appId``data``timestamp`
- **AND** 追加 `&key=appSecret`
- **AND** MD5 加密后转大写十六进制
#### Scenario: 签名输出格式
- **WHEN** 签名生成成功
- **THEN** 输出为 32 位大写十六进制字符串
- **AND** 例如:"ABCDEF1234567890ABCDEF1234567890"
#### Scenario: 签名可重现
- **WHEN** 使用相同的 `appID``encryptedData``timestamp``appSecret`
- **THEN** 多次调用 `generateSign` 生成相同的签名
#### Scenario: 时间戳格式
- **WHEN** 签名中使用时间戳
- **THEN** 时间戳为 Unix 秒级时间戳10 位数字)
- **AND** 例如1704067200
### Requirement: 参数序列化
系统 SHALL 正确序列化请求参数,确保与 Gateway 期望格式一致。
#### Scenario: 业务数据序列化
- **WHEN** 业务数据为 Go 结构体
- **THEN** 使用 `sonic.Marshal` 序列化为 JSON 字符串
- **AND** JSON 格式与 Gateway 文档一致
#### Scenario: 空字段处理
- **WHEN** 请求结构体中某些字段为空omitempty
- **THEN** 序列化时忽略空字段
- **AND** 减少请求体大小
### Requirement: 加密/签名测试验证
系统 SHALL 提供加密和签名的单元测试,验证与 Gateway 文档一致性。
#### Scenario: 加密测试用例
- **WHEN** 使用已知的业务数据和 appSecret
- **THEN** 加密输出与 Gateway 文档示例一致
- **AND** 可以被 Gateway 正确解密
#### Scenario: 签名测试用例
- **WHEN** 使用已知的参数和 appSecret
- **THEN** 签名输出与 Gateway 文档示例一致
- **AND** Gateway 验证签名成功
#### Scenario: 端到端验证
- **WHEN** 运行集成测试,实际调用 Gateway API
- **THEN** 加密和签名被 Gateway 接受
- **AND** 响应状态码为 200
### Requirement: 性能要求
系统 SHALL 确保加密和签名操作的性能满足要求。
#### Scenario: 加密性能
- **WHEN** 加密 1KB 的业务数据
- **THEN** 加密时间 < 1ms
- **AND** 内存分配最小化
#### Scenario: 签名性能
- **WHEN** 生成签名
- **THEN** 签名时间 < 0.5ms
- **AND** 无不必要的内存分配
### Requirement: 安全性说明
系统 SHALL 在文档中说明 AES-ECB 模式的安全性限制。
#### Scenario: 安全性文档
- **WHEN** 查看加密函数的文档注释
- **THEN** 注释中说明 ECB 模式不推荐用于生产环境
- **AND** 说明这是 Gateway 强制要求,无法改变
- **AND** 建议使用 HTTPS 加密传输层
### Requirement: 字符编码一致性
系统 SHALL 确保所有字符串操作使用 UTF-8 编码。
#### Scenario: 字符串编码
- **WHEN** 序列化业务数据
- **THEN** 使用 UTF-8 编码
- **AND** 中文字符正确处理
#### Scenario: 签名字符串编码
- **WHEN** 生成签名的拼接字符串
- **THEN** 使用 UTF-8 编码
- **AND** 与 Gateway 期望的编码一致

View File

@@ -1,20 +1,46 @@
## ADDED Requirements
### Requirement: 订单类型标识
系统 SHALL 在订单模型中增加 is_purchase_on_behalf 字段,标识是否为代购订单。
#### Scenario: 普通订单创建
- **WHEN** 个人客户或代理为自己创建订单
- **THEN** 系统设置 is_purchase_on_behalf = false
#### Scenario: 代购订单创建
- **WHEN** 平台或代理为其他代理创建代购订单
- **THEN** 系统设置 is_purchase_on_behalf = true
#### Scenario: 查询订单列表返回订单类型
- **WHEN** 查询订单列表或详情
- **THEN** 响应包含 is_purchase_on_behalf 字段
---
### Requirement: 创建套餐购买订单
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限。
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求
#### Scenario: 个人客户创建单卡订单
- **WHEN** 个人客户为自己的卡创建订单,选择一个套餐
- **THEN** 系统创建订单,状态为待支付,返回订单信息
- **THEN** 系统创建订单,状态为待支付,is_purchase_on_behalf = false返回订单信息
#### Scenario: 个人客户创建设备订单
- **WHEN** 个人客户为自己的设备创建订单
- **THEN** 系统创建订单,订单类型为设备购买
- **THEN** 系统创建订单,订单类型为设备购买is_purchase_on_behalf = false
#### Scenario: 代理创建订单
- **WHEN** 代理为店铺关联的卡/设备创建订单
- **THEN** 系统创建订单买家类型为代理商买家ID为店铺ID
- **THEN** 系统创建订单买家类型为代理商买家ID为店铺IDis_purchase_on_behalf = false
#### Scenario: 平台创建代购订单
- **WHEN** 平台账号为代理的卡/设备创建订单,支付方式选择 offline
- **THEN** 系统创建订单is_purchase_on_behalf = truepayment_method = "offline"payment_status = 2已支付
#### Scenario: 套餐购买验证强充要求
- **WHEN** 个人客户创建订单,存在强充要求,订单金额低于强充金额
- **THEN** 系统返回错误 "支付金额不符合强充要求"
#### Scenario: 套餐不在可购买范围
- **WHEN** 买家尝试购买不在关联系列下的套餐
@@ -28,7 +54,7 @@
### Requirement: 查询订单列表
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、时间范围筛选。
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、是否代购筛选。
#### Scenario: 个人客户查询自己的订单
- **WHEN** 个人客户查询订单列表
@@ -36,7 +62,11 @@
#### Scenario: 代理查询店铺订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回该店铺及下级店铺的订单
- **THEN** 系统返回该店铺及下级店铺的订单(包含代购订单和普通订单)
#### Scenario: 按代购类型筛选
- **WHEN** 指定 is_purchase_on_behalf = true 筛选
- **THEN** 系统只返回代购订单
#### Scenario: 按支付状态筛选
- **WHEN** 指定支付状态筛选
@@ -60,16 +90,20 @@
### Requirement: 取消订单
系统 SHALL 允许买家取消未支付的订单。
系统 SHALL 允许买家取消未支付的订单,但代购订单不可取消
#### Scenario: 取消待支付订单
- **WHEN** 买家取消一个待支付的订单
#### Scenario: 取消待支付的普通订单
- **WHEN** 买家取消一个待支付的普通订单is_purchase_on_behalf = false
- **THEN** 系统更新订单状态为已取消
#### Scenario: 取消已支付订单
- **WHEN** 买家尝试取消已支付的订单
- **THEN** 系统返回错误 "已支付订单无法取消"
#### Scenario: 尝试取消代购订单
- **WHEN** 买家尝试取消代购订单is_purchase_on_behalf = true
- **THEN** 系统返回错误 "代购订单不可取消"
---
### Requirement: 订单号生成

View File

@@ -1,5 +1,27 @@
## ADDED Requirements
### Requirement: 线下支付方式
系统 SHALL 支持线下支付方式offline仅用于代购订单。线下支付的订单创建后直接标记为已支付跳过支付流程。
#### Scenario: 创建线下支付订单
- **WHEN** 平台账号创建订单时选择支付方式为 offline
- **THEN** 系统创建订单payment_status 直接设为 2已支付payment_method = "offline"
#### Scenario: 线下支付权限限制
- **WHEN** 非平台账号(代理/个人客户)尝试使用线下支付
- **THEN** 系统返回错误 "只有平台账号可以使用线下支付"
#### Scenario: 线下支付订单自动激活套餐
- **WHEN** 创建线下支付订单成功
- **THEN** 系统自动激活套餐,创建 PackageUsage 记录
#### Scenario: 线下支付不扣钱包
- **WHEN** 订单使用线下支付
- **THEN** 系统不扣减任何钱包余额
---
### Requirement: 钱包支付
系统 SHALL 支持使用钱包余额支付订单。支付成功后 MUST 扣减钱包余额并激活套餐。
@@ -24,16 +46,24 @@
### Requirement: 第三方支付回调
系统 SHALL 处理微信支付和支付宝的支付回调。回调处理 MUST 幂等。
系统 SHALL 处理微信支付和支付宝的支付回调,支持订单支付和钱包充值两种场景。回调处理 MUST 幂等。
#### Scenario: 微信支付成功回调
- **WHEN** 收到微信支付成功回调
#### Scenario: 微信支付成功回调(订单)
- **WHEN** 收到微信支付成功回调,订单号格式为 ORD 开头
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
#### Scenario: 支付成功回调
- **WHEN** 收到支付宝支付成功回调
#### Scenario: 微信支付成功回调(充值)
- **WHEN** 收到微信支付成功回调,订单号格式为 RCH 开头
- **THEN** 系统验证签名,更新充值订单状态,增加钱包余额,更新累计充值,触发佣金判断,返回成功响应
#### Scenario: 支付宝成功回调(订单)
- **WHEN** 收到支付宝支付成功回调,订单号格式为 ORD 开头
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
#### Scenario: 支付宝成功回调(充值)
- **WHEN** 收到支付宝支付成功回调,订单号格式为 RCH 开头
- **THEN** 系统验证签名,更新充值订单状态,增加钱包余额,更新累计充值,触发佣金判断,返回成功响应
#### Scenario: 重复回调
- **WHEN** 收到已处理订单的重复回调
- **THEN** 系统返回成功响应,不重复处理
@@ -46,7 +76,7 @@
### Requirement: 套餐激活
支付成功后系统 MUST 激活套餐,创建 PackageUsage 记录。
支付成功后系统 MUST 激活套餐,创建 PackageUsage 记录。代购订单也需激活套餐,但不更新累计充值。
#### Scenario: 单卡套餐激活
- **WHEN** 单卡订单支付成功
@@ -60,6 +90,10 @@
- **WHEN** 套餐激活
- **THEN** 有效期 = 激活时间 + 套餐时长(月)
#### Scenario: 代购订单激活套餐
- **WHEN** 代购订单is_purchase_on_behalf = true创建成功
- **THEN** 系统激活套餐,但不更新卡/设备的 accumulated_recharge
---
### Requirement: 支付事务保证

View File

@@ -0,0 +1,145 @@
# Capability: 代购订单
## Purpose
本 capability 定义代购订单功能,允许平台或代理为其他代理创建套餐购买订单,使用线下支付方式,订单创建后直接完成支付并激活套餐。
## Requirements
### Requirement: 平台创建代购订单
系统 SHALL 允许平台账号为代理创建代购订单,使用线下支付方式,订单创建后直接标记为已支付。
#### Scenario: 平台为一级代理代购
- **WHEN** 平台账号为一级代理的卡创建代购订单,选择套餐,支付方式为线下支付
- **THEN** 系统创建订单buyer_id = 一级代理店铺IDis_purchase_on_behalf = truepayment_method = "offline"payment_status = 2已支付
#### Scenario: 平台为二级代理代购
- **WHEN** 平台账号为二级代理的卡创建代购订单
- **THEN** 系统创建订单buyer_id = 二级代理店铺IDis_purchase_on_behalf = true
#### Scenario: 代购订单价格使用代理成本价
- **WHEN** 平台为代理创建代购订单,套餐价格 100 元,代理成本价 80 元
- **THEN** 订单金额为 80 元(代理成本价)
#### Scenario: 查询卡归属代理
- **WHEN** 平台选择卡创建代购订单
- **THEN** 系统查询卡的 shop_id作为订单的 buyer_id
#### Scenario: 查询设备归属代理
- **WHEN** 平台选择设备创建代购订单
- **THEN** 系统查询设备的 shop_id作为订单的 buyer_id
---
### Requirement: 代理创建代购订单
系统 SHALL 允许代理账号为其他代理(通常是下级代理)创建代购订单。
#### Scenario: 一级代理为二级代理代购
- **WHEN** 一级代理为二级代理的卡创建代购订单,选择套餐,支付方式为线下支付
- **THEN** 系统创建订单buyer_id = 二级代理店铺IDis_purchase_on_behalf = truepayment_method = "offline"
#### Scenario: 代购订单使用买家成本价
- **WHEN** 一级代理为二级代理代购,套餐价格 100 元,二级代理成本价 90 元
- **THEN** 订单金额为 90 元(买家成本价)
---
### Requirement: 代购订单自动完成
代购订单创建后 SHALL 自动完成支付流程,激活套餐,但不触发一次性佣金。
#### Scenario: 代购订单自动激活套餐
- **WHEN** 创建代购订单成功
- **THEN** 系统自动激活套餐(创建 PackageUsage 记录)
#### Scenario: 代购订单不扣钱包
- **WHEN** 创建代购订单
- **THEN** 系统不扣减任何钱包余额(线下已收款)
#### Scenario: 代购订单不更新累计充值
- **WHEN** 代购订单完成
- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段
#### Scenario: 代购订单计算差价佣金
- **WHEN** 代购订单完成,买家有上级代理
- **THEN** 系统计算差价佣金(买家成本价 - 上级成本价),发放给上级代理
#### Scenario: 代购订单不触发一次性佣金
- **WHEN** 代购订单完成
- **THEN** 系统不检查一次性佣金阈值,不发放一次性佣金
---
### Requirement: 代购订单查询
系统 SHALL 在订单列表中正确显示代购订单。
#### Scenario: 平台查询代购订单
- **WHEN** 平台账号查询订单列表
- **THEN** 系统返回所有代购订单is_purchase_on_behalf = true包含创建人信息
#### Scenario: 代理查询收到的代购订单
- **WHEN** 代理查询订单列表,包含别人为自己代购的订单
- **THEN** 系统返回买家为自己的代购订单
#### Scenario: 代购订单标识
- **WHEN** 查询订单详情
- **THEN** 订单响应包含 is_purchase_on_behalf 字段,前端可以显示"代购订单"标签
---
### Requirement: 代购订单权限控制
系统 SHALL 严格控制代购订单的创建权限。
#### Scenario: 只有平台账号可以使用线下支付
- **WHEN** 代理账号尝试创建订单时选择支付方式为 offline
- **THEN** 系统返回错误 "只有平台账号可以使用线下支付"
#### Scenario: 平台账号可以为任何代理代购
- **WHEN** 平台账号为任意层级代理创建代购订单
- **THEN** 系统允许创建
#### Scenario: 代理账号只能为下级代理代购
- **WHEN** 代理账号尝试为上级或平级代理创建代购订单
- **THEN** 系统返回错误 "只能为下级代理代购套餐"
---
### Requirement: 代购订单记录
系统 SHALL 完整记录代购订单的创建人和买家信息。
#### Scenario: 记录创建人
- **WHEN** 平台/代理创建代购订单
- **THEN** 订单的 creator 字段记录创建人账号ID
#### Scenario: 区分创建人和买家
- **WHEN** 查询代购订单详情
- **THEN** creator创建人!= buyer_id买家可以追溯是谁代购的
---
### Requirement: 代购订单不可取消
代购订单创建后 SHALL 不可取消,因为已自动完成。
#### Scenario: 尝试取消代购订单
- **WHEN** 尝试取消一个代购订单is_purchase_on_behalf = true
- **THEN** 系统返回错误 "代购订单不可取消"
---
### Requirement: 线下支付方式常量
系统 SHALL 定义线下支付方式常量。
#### Scenario: 支付方式枚举
- **WHEN** 创建订单时选择支付方式
- **THEN** payment_method 可选值包含wallet, wechat, alipay, offline
#### Scenario: 线下支付只用于代购
- **WHEN** payment_method = "offline"
- **THEN** 订单必须标记为 is_purchase_on_behalf = true

View File

@@ -6,14 +6,40 @@
## Requirements
### Requirement: 强充配置
系统 SHALL 在套餐系列分配中支持强充配置。仅累计充值触发时可选启用强充,首次充值触发时强充是必须的(无需配置)。
#### Scenario: 累计充值启用强充
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = trueforce_recharge_amount = 10000100元
- **THEN** 系统保存强充配置,下级客户每次充值/购买必须充值 100 元
#### Scenario: 累计充值不启用强充
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = false
- **THEN** 系统保存配置,下级客户可以自由充值任意金额
#### Scenario: 首次充值无需设置强充
- **WHEN** 创建系列分配,一次性佣金触发类型为首次充值,阈值 10000100元
- **THEN** 系统使用阈值作为强充金额,无需单独配置 force_recharge_amount
#### Scenario: 强充金额为0表示使用阈值
- **WHEN** 创建系列分配启用强充force_recharge_amount = 0
- **THEN** 系统使用一次性佣金阈值作为强充金额
---
### Requirement: 为下级店铺分配套餐系列
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置返佣模式和返佣值MAY 启用一次性佣金。分配者只能分配自己已被分配的套餐系列。
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置返佣模式和返佣值MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。
#### Scenario: 成功分配套餐系列
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列设置基础返佣为百分比20020%
- **THEN** 系统创建分配记录
#### Scenario: 分配时启用一次性佣金和强充
- **WHEN** 代理为下级分配系列,启用一次性佣金,触发类型为累计充值,阈值 1000001000元启用强充强充金额 10000100元
- **THEN** 系统保存配置enable_one_time_commission = truetrigger = "accumulated_recharge"threshold = 100000enable_force_recharge = trueforce_recharge_amount = 10000
#### Scenario: 尝试分配未拥有的系列
- **WHEN** 代理尝试分配自己未被分配的套餐系列
- **THEN** 系统返回错误 "您没有该套餐系列的分配权限"
@@ -44,12 +70,20 @@
### Requirement: 更新套餐系列分配
系统 SHALL 允许代理更新分配的基础返佣配置一次性佣金配置。更新返佣配置时 MUST 创建新的配置版本。
系统 SHALL 允许代理更新分配的基础返佣配置一次性佣金配置和强充配置。更新返佣配置时 MUST 创建新的配置版本。
#### Scenario: 更新基础返佣配置时创建新版本
- **WHEN** 代理将基础返佣从20%改为25%
- **THEN** 系统更新分配记录,并创建新配置版本
#### Scenario: 更新强充配置
- **WHEN** 代理将 enable_force_recharge 从 false 改为 true设置 force_recharge_amount = 10000
- **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求
#### Scenario: 禁用强充
- **WHEN** 代理将 enable_force_recharge 从 true 改为 false
- **THEN** 系统更新分配记录,后续下级客户可以自由充值
#### Scenario: 更新不存在的分配
- **WHEN** 代理更新不存在的分配 ID
- **THEN** 系统返回 "分配记录不存在" 错误
@@ -86,12 +120,16 @@
### Requirement: 平台分配套餐系列
平台管理员 SHALL 能够为一级代理分配套餐系列。平台的成本价基准为 Package.suggested_cost_price。
平台管理员 SHALL 能够为一级代理分配套餐系列,可配置强充要求。平台的成本价基准为 Package.suggested_cost_price。
#### Scenario: 平台为一级代理分配
- **WHEN** 平台管理员为一级代理分配套餐系列
- **THEN** 系统创建分配记录
#### Scenario: 平台配置强充要求
- **WHEN** 平台为一级代理分配系列启用强充force_recharge_amount = 10000
- **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求
---
## REMOVED Requirements

View File

@@ -0,0 +1,188 @@
# Capability: 钱包充值
## Purpose
本 capability 定义钱包充值功能,允许个人客户为卡/设备钱包充值,支持强充验证、第三方支付和充值后的累计充值更新与一次性佣金触发。
## Requirements
### Requirement: 创建钱包充值订单
系统 SHALL 允许个人客户创建钱包充值订单。创建前 MUST 验证强充要求,强充场景下充值金额必须等于要求的强充金额。
#### Scenario: 无强充要求时自由充值
- **WHEN** 个人客户为卡/设备创建充值订单,该卡/设备无强充要求,充值金额 100 元
- **THEN** 系统创建充值订单,状态为待支付,金额 10000 分
#### Scenario: 首次充值强充
- **WHEN** 卡关联系列配置为首次充值触发,阈值 100 元,客户尝试充值 100 元
- **THEN** 系统验证通过,创建充值订单,金额 10000 分
#### Scenario: 首次充值金额不符
- **WHEN** 卡关联系列配置为首次充值触发,阈值 100 元,客户尝试充值 50 元
- **THEN** 系统返回错误 "必须充值100元"
#### Scenario: 累计充值启用强充
- **WHEN** 卡关联系列配置为累计充值触发,启用强充,强充金额 100 元,客户尝试充值 100 元
- **THEN** 系统验证通过,创建充值订单
#### Scenario: 累计充值强充金额不符
- **WHEN** 卡关联系列配置为累计充值触发,启用强充,强充金额 100 元,客户尝试充值 50 元
- **THEN** 系统返回错误 "必须充值100元"
#### Scenario: 累计充值未启用强充
- **WHEN** 卡关联系列配置为累计充值触发,未启用强充,客户充值任意金额
- **THEN** 系统创建充值订单
#### Scenario: 充值订单号唯一
- **WHEN** 创建充值订单
- **THEN** 系统生成唯一充值单号,格式为 RCH + 14位时间戳 + 6位随机数
---
### Requirement: 查询充值订单列表
系统 SHALL 提供充值订单列表查询,支持按状态筛选、时间范围筛选。
#### Scenario: 查询个人客户的充值订单
- **WHEN** 个人客户查询充值订单列表
- **THEN** 系统返回该客户的所有充值订单
#### Scenario: 按状态筛选
- **WHEN** 客户指定充值状态筛选(待支付/已支付/已完成)
- **THEN** 系统只返回匹配状态的充值订单
#### Scenario: 分页查询
- **WHEN** 查询充值订单列表
- **THEN** 系统使用分页返回,默认每页 20 条,最大 100 条
---
### Requirement: 查询充值订单详情
系统 SHALL 允许个人客户查询充值订单详情。
#### Scenario: 查询自己的充值订单
- **WHEN** 客户查询自己的充值订单详情
- **THEN** 系统返回订单信息(充值单号、金额、支付方式、状态、时间等)
#### Scenario: 查询他人充值订单
- **WHEN** 客户尝试查询不属于自己的充值订单
- **THEN** 系统返回 "充值订单不存在" 错误
---
### Requirement: 充值支付(微信/支付宝)
系统 SHALL 支持通过微信支付和支付宝支付完成充值。
#### Scenario: 微信 JSAPI 支付
- **WHEN** 客户在微信内选择充值,使用微信支付
- **THEN** 系统调用微信支付 JSAPI 接口,返回支付参数
#### Scenario: 微信 H5 支付
- **WHEN** 客户在浏览器内选择充值,使用微信支付
- **THEN** 系统调用微信支付 H5 接口,返回支付跳转 URL
#### Scenario: 支付宝支付
- **WHEN** 客户选择支付宝支付充值
- **THEN** 系统调用支付宝接口,返回支付参数
---
### Requirement: 充值支付回调处理
系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额。
#### Scenario: 微信支付回调成功
- **WHEN** 收到微信支付成功回调,验证签名通过
- **THEN** 系统更新充值订单状态为已支付
- **AND** 增加对应钱包余额
- **AND** 创建钱包交易记录
- **AND** 返回成功响应给微信
#### Scenario: 支付宝回调成功
- **WHEN** 收到支付宝支付成功回调,验证签名通过
- **THEN** 系统更新充值订单状态为已支付
- **AND** 增加对应钱包余额
- **AND** 创建钱包交易记录
#### Scenario: 签名验证失败
- **WHEN** 收到支付回调,签名验证失败
- **THEN** 系统记录错误日志,不处理订单,返回失败响应
#### Scenario: 重复回调幂等处理
- **WHEN** 收到同一充值订单的重复支付回调
- **THEN** 系统检查订单状态,如果已支付则直接返回成功,不重复处理
---
### Requirement: 充值成功更新累计充值金额
充值支付成功后系统 SHALL 更新卡/设备的累计充值金额AccumulatedRecharge
#### Scenario: 充值成功累加充值金额
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
- **THEN** 系统更新卡的累计充值为 300 元200 + 100
#### Scenario: 设备充值成功累加充值金额
- **WHEN** 设备钱包充值 200 元成功,当前累计充值 500 元
- **THEN** 系统更新设备的累计充值为 700 元500 + 200
#### Scenario: 使用原子操作更新
- **WHEN** 更新累计充值金额
- **THEN** 系统使用 SQL 原子操作或 GORM 乐观锁确保并发安全
---
### Requirement: 充值成功触发一次性佣金判断
充值支付成功后系统 SHALL 检查是否达到一次性佣金阈值,如果达到则触发佣金计算。
#### Scenario: 首次充值达到阈值
- **WHEN** 卡配置为首次充值触发,阈值 100 元,客户充值 100 元
- **THEN** 系统触发一次性佣金计算,发放佣金
#### Scenario: 累计充值达到阈值
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,累计充值已达到 1000 元
- **THEN** 系统触发一次性佣金计算,发放佣金
#### Scenario: 未达阈值不触发
- **WHEN** 充值后累计充值未达到阈值
- **THEN** 系统不触发一次性佣金计算
#### Scenario: 已发放过不重复触发
- **WHEN** 卡的一次性佣金已发放过first_commission_paid = true
- **THEN** 系统不触发一次性佣金计算
---
### Requirement: 充值订单状态流转
充值订单状态 SHALL 按以下流程流转:待支付 → 已支付 → 已完成。
#### Scenario: 正常流转
- **WHEN** 创建充值订单 → 支付成功 → 钱包余额增加完成
- **THEN** 订单状态依次为1待支付→ 2已支付→ 3已完成
#### Scenario: 超时未支付
- **WHEN** 充值订单创建 30 分钟后仍未支付
- **THEN** 系统标记订单为已关闭(状态 4
---
### Requirement: 充值金额限制
系统 SHALL 限制单次充值金额范围。
#### Scenario: 充值金额范围
- **WHEN** 创建充值订单
- **THEN** 充值金额必须在 1 元到 100000 元之间
#### Scenario: 充值金额过小
- **WHEN** 客户尝试充值 0.5 元
- **THEN** 系统返回错误 "充值金额不能小于1元"
#### Scenario: 充值金额过大
- **WHEN** 客户尝试充值 200000 元
- **THEN** 系统返回错误 "单次充值金额不能超过100000元"

View File

@@ -64,3 +64,14 @@ const (
RechargeMethodBank = "bank" // 银行转账
RechargeMethodOffline = "offline" // 线下
)
// 充值订单号前缀
const (
RechargeOrderPrefix = "RCH" // 充值订单号前缀
)
// 充值金额限制(单位:分)
const (
RechargeMinAmount = 100 // 最小充值金额1元
RechargeMaxAmount = 10000000 // 最大充值金额100000元
)

View File

@@ -103,6 +103,19 @@ const (
CodeGatewayTimeout = 1113 // 请求超时
CodeGatewayInvalidResp = 1114 // 响应格式错误
// 充值相关错误 (1120-1129)
CodeRechargeAmountInvalid = 1120 // 充值金额不符合要求
CodeRechargeNotFound = 1121 // 充值订单不存在
CodeRechargeAlreadyPaid = 1122 // 充值订单已支付
// 代购相关错误 (1130-1139)
CodePurchaseOnBehalfForbidden = 1130 // 无权使用线下支付
CodePurchaseOnBehalfInvalidTarget = 1131 // 代购目标无效
// 强充验证相关错误 (1140-1149)
CodeForceRechargeRequired = 1140 // 必须充值指定金额
CodeForceRechargeAmountMismatch = 1141 // 强充金额不匹配
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
CodeInternalError = 2001 // 内部服务器错误
CodeDatabaseError = 2002 // 数据库错误
@@ -194,6 +207,13 @@ var allErrorCodes = []int{
CodeGatewaySignError,
CodeGatewayTimeout,
CodeGatewayInvalidResp,
CodeRechargeAmountInvalid,
CodeRechargeNotFound,
CodeRechargeAlreadyPaid,
CodePurchaseOnBehalfForbidden,
CodePurchaseOnBehalfInvalidTarget,
CodeForceRechargeRequired,
CodeForceRechargeAmountMismatch,
CodeInternalError,
CodeDatabaseError,
CodeRedisError,
@@ -283,6 +303,13 @@ var errorMessages = map[int]string{
CodeGatewaySignError: "签名生成失败",
CodeGatewayTimeout: "Gateway 请求超时",
CodeGatewayInvalidResp: "Gateway 响应格式错误",
CodeRechargeAmountInvalid: "充值金额不符合要求",
CodeRechargeNotFound: "充值订单不存在",
CodeRechargeAlreadyPaid: "充值订单已支付",
CodePurchaseOnBehalfForbidden: "无权使用线下支付",
CodePurchaseOnBehalfInvalidTarget: "代购目标无效",
CodeForceRechargeRequired: "必须充值指定金额",
CodeForceRechargeAmountMismatch: "强充金额不匹配",
CodeInvalidCredentials: "用户名或密码错误",
CodeAccountLocked: "账号已锁定",
CodePasswordExpired: "密码已过期",

View File

@@ -44,6 +44,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
AdminOrder: admin.NewOrderHandler(nil),
H5Order: h5.NewOrderHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil),
H5Recharge: h5.NewRechargeHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),
}
}