Compare commits
15 Commits
e461791a0e
...
62708892ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 62708892ec | |||
| b8dda7e62a | |||
| 5891e9db8d | |||
| 902ddb3687 | |||
| 760b3db1df | |||
| 001eb81e5e | |||
| 1ec7de4ec4 | |||
| 113b3edd69 | |||
| 22f19377a5 | |||
| c7bf43f306 | |||
| 1036b5979e | |||
| cb0835cd94 | |||
| 526d9c62b7 | |||
| 116355835a | |||
| f6a0f0f39c |
1715
.sisyphus/plans/add-force-recharge-system.md
Normal file
1715
.sisyphus/plans/add-force-recharge-system.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
395
docs/add-force-recharge-system/功能总结.md
Normal file
395
docs/add-force-recharge-system/功能总结.md
Normal 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`
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type Handlers struct {
|
||||
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
||||
AdminOrder *admin.OrderHandler
|
||||
H5Order *h5.OrderHandler
|
||||
H5Recharge *h5.RechargeHandler
|
||||
PaymentCallback *callback.PaymentHandler
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ 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"
|
||||
@@ -16,12 +19,14 @@ import (
|
||||
|
||||
type PaymentHandler struct {
|
||||
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,
|
||||
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,9 +78,20 @@ 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 {
|
||||
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")
|
||||
}
|
||||
|
||||
169
internal/handler/h5/recharge.go
Normal file
169
internal/handler/h5/recharge.go
Normal 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)
|
||||
}
|
||||
@@ -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列表"`
|
||||
}
|
||||
|
||||
112
internal/model/dto/recharge.go
Normal file
112
internal/model/dto/recharge.go
Normal 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"`
|
||||
}
|
||||
@@ -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 指定表名
|
||||
@@ -63,6 +66,7 @@ const (
|
||||
PaymentMethodWallet = "wallet" // 钱包支付
|
||||
PaymentMethodWechat = "wechat" // 微信支付
|
||||
PaymentMethodAlipay = "alipay" // 支付宝支付
|
||||
PaymentMethodOffline = "offline" // 线下支付(仅平台代购使用)
|
||||
)
|
||||
|
||||
// 支付状态常量
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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订单路由
|
||||
|
||||
44
internal/routes/recharge.go
Normal file
44
internal/routes/recharge.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -91,6 +91,8 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 代购订单不触发一次性佣金和累计充值更新
|
||||
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, "触发单卡一次性佣金失败")
|
||||
@@ -100,6 +102,7 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Update("commission_status", model.CommissionStatusCalculated).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, 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, "获取设备信息失败")
|
||||
|
||||
369
internal/service/commission_calculation/service_test.go
Normal file
369
internal/service/commission_calculation/service_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
724
internal/service/recharge/service.go
Normal file
724
internal/service/recharge/service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
1488
internal/service/recharge/service_test.go
Normal file
1488
internal/service/recharge/service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
166
internal/store/postgres/recharge_store.go
Normal file
166
internal/store/postgres/recharge_store.go
Normal 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
|
||||
}
|
||||
395
internal/store/postgres/recharge_store_test.go
Normal file
395
internal/store/postgres/recharge_store_test.go
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 回滚: 删除 tb_order 表的 is_purchase_on_behalf 字段
|
||||
|
||||
ALTER TABLE tb_order DROP COLUMN IF EXISTS is_purchase_on_behalf;
|
||||
6
migrations/000035_add_order_is_purchase_on_behalf.up.sql
Normal file
6
migrations/000035_add_order_is_purchase_on_behalf.up.sql
Normal 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 '是否为代购订单';
|
||||
@@ -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;
|
||||
@@ -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 '强制充值金额(分)';
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-31
|
||||
@@ -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: 充值订单是否需要支持优惠券/折扣?
|
||||
|
||||
**当前设计**:充值订单不支持任何优惠。
|
||||
|
||||
**待确认**:
|
||||
- 未来是否需要支持充值满减、折扣等营销活动?
|
||||
- 如果需要,是否在本次实现?
|
||||
|
||||
**影响**:如果需要支持,需要设计优惠券系统,超出本次范围。
|
||||
|
||||
**建议**:留待后续实现,本次保持简单。
|
||||
@@ -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(异步处理佣金计算)
|
||||
@@ -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** 系统不触发一次性佣金计算
|
||||
@@ -0,0 +1,109 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 钱包充值预检
|
||||
|
||||
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。
|
||||
|
||||
#### Scenario: 无强充要求
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发且未启用强充
|
||||
- **THEN** 系统返回 need_force_recharge = false,min_amount = 100(1元),max_amount = null
|
||||
|
||||
#### Scenario: 首次充值强充
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为首次充值触发,阈值 10000 分(100元),未发放佣金
|
||||
- **THEN** 系统返回 need_force_recharge = true,force_recharge_amount = 10000,trigger_type = "single_recharge",message = "首次充值需充值100元"
|
||||
|
||||
#### Scenario: 累计充值启用强充
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发,启用强充,强充金额 10000 分(100元)
|
||||
- **THEN** 系统返回 need_force_recharge = true,force_recharge_amount = 10000,trigger_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 = 9000,need_force_recharge = false,actual_payment = 9000,wallet_credit = 0
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价低于阈值
|
||||
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 9000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 10000,wallet_credit = 1000,message = "需充值100元,购买套餐后余额10元"
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价高于阈值
|
||||
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 15000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 15000,wallet_credit = 0,message = "套餐总价150元,无需额外充值"
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价等于阈值
|
||||
- **WHEN** 客户购买 100 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 10000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 10000,wallet_credit = 0
|
||||
|
||||
#### Scenario: 累计充值启用强充,套餐价低于强充金额
|
||||
- **WHEN** 客户购买 50 元套餐,累计充值启用强充,强充金额 100 元
|
||||
- **THEN** 系统返回 actual_payment = 10000,wallet_credit = 5000,message = "需充值100元,购买套餐后余额50元"
|
||||
|
||||
#### Scenario: 累计充值启用强充,套餐价高于强充金额
|
||||
- **WHEN** 客户购买 150 元套餐,累计充值启用强充,强充金额 100 元
|
||||
- **THEN** 系统返回 actual_payment = 15000,wallet_credit = 0,message = "套餐总价150元,无需额外充值"
|
||||
|
||||
#### Scenario: 购买多个套餐
|
||||
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 12000,actual_payment = 12000,wallet_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** 系统返回错误 "套餐不存在"
|
||||
@@ -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为店铺ID,is_purchase_on_behalf = false
|
||||
|
||||
#### Scenario: 平台创建代购订单
|
||||
- **WHEN** 平台账号为代理的卡/设备创建订单,支付方式选择 offline
|
||||
- **THEN** 系统创建订单,is_purchase_on_behalf = true,payment_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** 系统返回错误 "代购订单不可取消"
|
||||
@@ -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** 系统创建 PackageUsage,usage_type 为 single_card,关联 iot_card_id
|
||||
|
||||
#### Scenario: 设备套餐激活
|
||||
- **WHEN** 设备订单支付成功
|
||||
- **THEN** 系统创建 PackageUsage,usage_type 为 device,关联 device_id
|
||||
|
||||
#### Scenario: 套餐有效期计算
|
||||
- **WHEN** 套餐激活
|
||||
- **THEN** 有效期 = 激活时间 + 套餐时长(月)
|
||||
|
||||
#### Scenario: 代购订单激活套餐
|
||||
- **WHEN** 代购订单(is_purchase_on_behalf = true)创建成功
|
||||
- **THEN** 系统激活套餐,但不更新卡/设备的 accumulated_recharge
|
||||
@@ -0,0 +1,139 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 平台创建代购订单
|
||||
|
||||
系统 SHALL 允许平台账号为代理创建代购订单,使用线下支付方式,订单创建后直接标记为已支付。
|
||||
|
||||
#### Scenario: 平台为一级代理代购
|
||||
- **WHEN** 平台账号为一级代理的卡创建代购订单,选择套餐,支付方式为线下支付
|
||||
- **THEN** 系统创建订单,buyer_id = 一级代理店铺ID,is_purchase_on_behalf = true,payment_method = "offline",payment_status = 2(已支付)
|
||||
|
||||
#### Scenario: 平台为二级代理代购
|
||||
- **WHEN** 平台账号为二级代理的卡创建代购订单
|
||||
- **THEN** 系统创建订单,buyer_id = 二级代理店铺ID,is_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 = 二级代理店铺ID,is_purchase_on_behalf = true,payment_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
|
||||
@@ -0,0 +1,85 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 强充配置
|
||||
|
||||
系统 SHALL 在套餐系列分配中支持强充配置。仅累计充值触发时可选启用强充,首次充值触发时强充是必须的(无需配置)。
|
||||
|
||||
#### Scenario: 累计充值启用强充
|
||||
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = true,force_recharge_amount = 10000(100元)
|
||||
- **THEN** 系统保存强充配置,下级客户每次充值/购买必须充值 100 元
|
||||
|
||||
#### Scenario: 累计充值不启用强充
|
||||
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = false
|
||||
- **THEN** 系统保存配置,下级客户可以自由充值任意金额
|
||||
|
||||
#### Scenario: 首次充值无需设置强充
|
||||
- **WHEN** 创建系列分配,一次性佣金触发类型为首次充值,阈值 10000(100元)
|
||||
- **THEN** 系统使用阈值作为强充金额,无需单独配置 force_recharge_amount
|
||||
|
||||
#### Scenario: 强充金额为0表示使用阈值
|
||||
- **WHEN** 创建系列分配,启用强充,force_recharge_amount = 0
|
||||
- **THEN** 系统使用一次性佣金阈值作为强充金额
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 为下级店铺分配套餐系列
|
||||
|
||||
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置(返佣模式和返佣值),MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。
|
||||
|
||||
#### Scenario: 成功分配套餐系列
|
||||
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置基础返佣为百分比200(20%)
|
||||
- **THEN** 系统创建分配记录
|
||||
|
||||
#### Scenario: 分配时启用一次性佣金和强充
|
||||
- **WHEN** 代理为下级分配系列,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元)
|
||||
- **THEN** 系统保存配置:enable_one_time_commission = true,trigger = "accumulated_recharge",threshold = 100000,enable_force_recharge = true,force_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** 系统保存强充配置,一级代理的客户需遵守强充要求
|
||||
@@ -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元"
|
||||
@@ -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.go:Create 方法增加强充验证
|
||||
- 调用预检逻辑获取强充要求
|
||||
- 验证支付金额是否符合强充要求
|
||||
- 不符合则返回错误
|
||||
- [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.go:CalculateCommission 方法增加代购订单判断
|
||||
- 差价佣金:所有订单都计算(包括代购)
|
||||
- 累计充值更新:仅非代购订单更新(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.go:Create 方法增加代购订单支持
|
||||
- 检查 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.go:WechatPayCallback 方法增加充值订单判断
|
||||
- 根据订单号前缀判断类型(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:注册 RechargeHandler(H5)
|
||||
- [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 本地启动服务验证功能可用性
|
||||
@@ -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** 系统不触发一次性佣金计算
|
||||
|
||||
115
openspec/specs/force-recharge-check/spec.md
Normal file
115
openspec/specs/force-recharge-check/spec.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Capability: 强充预检
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义强充预检接口,在充值或购买套餐前返回强充要求、允许的充值金额等信息,帮助前端正确引导用户完成支付。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 钱包充值预检
|
||||
|
||||
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。
|
||||
|
||||
#### Scenario: 无强充要求
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发且未启用强充
|
||||
- **THEN** 系统返回 need_force_recharge = false,min_amount = 100(1元),max_amount = null
|
||||
|
||||
#### Scenario: 首次充值强充
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为首次充值触发,阈值 10000 分(100元),未发放佣金
|
||||
- **THEN** 系统返回 need_force_recharge = true,force_recharge_amount = 10000,trigger_type = "single_recharge",message = "首次充值需充值100元"
|
||||
|
||||
#### Scenario: 累计充值启用强充
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发,启用强充,强充金额 10000 分(100元)
|
||||
- **THEN** 系统返回 need_force_recharge = true,force_recharge_amount = 10000,trigger_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 = 9000,need_force_recharge = false,actual_payment = 9000,wallet_credit = 0
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价低于阈值
|
||||
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 9000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 10000,wallet_credit = 1000,message = "需充值100元,购买套餐后余额10元"
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价高于阈值
|
||||
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 15000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 15000,wallet_credit = 0,message = "套餐总价150元,无需额外充值"
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价等于阈值
|
||||
- **WHEN** 客户购买 100 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 10000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 10000,wallet_credit = 0
|
||||
|
||||
#### Scenario: 累计充值启用强充,套餐价低于强充金额
|
||||
- **WHEN** 客户购买 50 元套餐,累计充值启用强充,强充金额 100 元
|
||||
- **THEN** 系统返回 actual_payment = 10000,wallet_credit = 5000,message = "需充值100元,购买套餐后余额50元"
|
||||
|
||||
#### Scenario: 累计充值启用强充,套餐价高于强充金额
|
||||
- **WHEN** 客户购买 150 元套餐,累计充值启用强充,强充金额 100 元
|
||||
- **THEN** 系统返回 actual_payment = 15000,wallet_credit = 0,message = "套餐总价150元,无需额外充值"
|
||||
|
||||
#### Scenario: 购买多个套餐
|
||||
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 12000,actual_payment = 12000,wallet_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** 系统返回错误 "套餐不存在"
|
||||
220
openspec/specs/gateway-client/spec.md
Normal file
220
openspec/specs/gateway-client/spec.md
Normal 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 错误
|
||||
175
openspec/specs/gateway-config/spec.md
Normal file
175
openspec/specs/gateway-config/spec.md
Normal 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`
|
||||
155
openspec/specs/gateway-crypto/spec.md
Normal file
155
openspec/specs/gateway-crypto/spec.md
Normal 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×tamp=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 期望的编码一致
|
||||
@@ -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为店铺ID,is_purchase_on_behalf = false
|
||||
|
||||
#### Scenario: 平台创建代购订单
|
||||
- **WHEN** 平台账号为代理的卡/设备创建订单,支付方式选择 offline
|
||||
- **THEN** 系统创建订单,is_purchase_on_behalf = true,payment_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: 订单号生成
|
||||
|
||||
@@ -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: 支付事务保证
|
||||
|
||||
145
openspec/specs/purchase-on-behalf/spec.md
Normal file
145
openspec/specs/purchase-on-behalf/spec.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Capability: 代购订单
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义代购订单功能,允许平台或代理为其他代理创建套餐购买订单,使用线下支付方式,订单创建后直接完成支付并激活套餐。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 平台创建代购订单
|
||||
|
||||
系统 SHALL 允许平台账号为代理创建代购订单,使用线下支付方式,订单创建后直接标记为已支付。
|
||||
|
||||
#### Scenario: 平台为一级代理代购
|
||||
- **WHEN** 平台账号为一级代理的卡创建代购订单,选择套餐,支付方式为线下支付
|
||||
- **THEN** 系统创建订单,buyer_id = 一级代理店铺ID,is_purchase_on_behalf = true,payment_method = "offline",payment_status = 2(已支付)
|
||||
|
||||
#### Scenario: 平台为二级代理代购
|
||||
- **WHEN** 平台账号为二级代理的卡创建代购订单
|
||||
- **THEN** 系统创建订单,buyer_id = 二级代理店铺ID,is_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 = 二级代理店铺ID,is_purchase_on_behalf = true,payment_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
|
||||
@@ -6,14 +6,40 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 强充配置
|
||||
|
||||
系统 SHALL 在套餐系列分配中支持强充配置。仅累计充值触发时可选启用强充,首次充值触发时强充是必须的(无需配置)。
|
||||
|
||||
#### Scenario: 累计充值启用强充
|
||||
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = true,force_recharge_amount = 10000(100元)
|
||||
- **THEN** 系统保存强充配置,下级客户每次充值/购买必须充值 100 元
|
||||
|
||||
#### Scenario: 累计充值不启用强充
|
||||
- **WHEN** 创建系列分配,一次性佣金触发类型为累计充值,设置 enable_force_recharge = false
|
||||
- **THEN** 系统保存配置,下级客户可以自由充值任意金额
|
||||
|
||||
#### Scenario: 首次充值无需设置强充
|
||||
- **WHEN** 创建系列分配,一次性佣金触发类型为首次充值,阈值 10000(100元)
|
||||
- **THEN** 系统使用阈值作为强充金额,无需单独配置 force_recharge_amount
|
||||
|
||||
#### Scenario: 强充金额为0表示使用阈值
|
||||
- **WHEN** 创建系列分配,启用强充,force_recharge_amount = 0
|
||||
- **THEN** 系统使用一次性佣金阈值作为强充金额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 为下级店铺分配套餐系列
|
||||
|
||||
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置(返佣模式和返佣值),MAY 启用一次性佣金。分配者只能分配自己已被分配的套餐系列。
|
||||
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置(返佣模式和返佣值),MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。
|
||||
|
||||
#### Scenario: 成功分配套餐系列
|
||||
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置基础返佣为百分比200(20%)
|
||||
- **THEN** 系统创建分配记录
|
||||
|
||||
#### Scenario: 分配时启用一次性佣金和强充
|
||||
- **WHEN** 代理为下级分配系列,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元)
|
||||
- **THEN** 系统保存配置:enable_one_time_commission = true,trigger = "accumulated_recharge",threshold = 100000,enable_force_recharge = true,force_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
|
||||
|
||||
188
openspec/specs/wallet-recharge/spec.md
Normal file
188
openspec/specs/wallet-recharge/spec.md
Normal 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元"
|
||||
@@ -64,3 +64,14 @@ const (
|
||||
RechargeMethodBank = "bank" // 银行转账
|
||||
RechargeMethodOffline = "offline" // 线下
|
||||
)
|
||||
|
||||
// 充值订单号前缀
|
||||
const (
|
||||
RechargeOrderPrefix = "RCH" // 充值订单号前缀
|
||||
)
|
||||
|
||||
// 充值金额限制(单位:分)
|
||||
const (
|
||||
RechargeMinAmount = 100 // 最小充值金额(1元)
|
||||
RechargeMaxAmount = 10000000 // 最大充值金额(100000元)
|
||||
)
|
||||
|
||||
@@ -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: "密码已过期",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user