refactor: 一次性佣金配置从套餐级别提升到系列级别
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
主要变更: - 新增 tb_shop_series_allocation 表,存储系列级别的一次性佣金配置 - ShopPackageAllocation 移除 one_time_commission_amount 字段 - PackageSeries 新增 enable_one_time_commission 字段控制是否启用一次性佣金 - 新增 /api/admin/shop-series-allocations CRUD 接口 - 佣金计算逻辑改为从 ShopSeriesAllocation 获取一次性佣金金额 - 删除废弃的 ShopSeriesOneTimeCommissionTier 模型 - OpenAPI Tag '系列分配' 和 '单套餐分配' 合并为 '套餐分配' 迁移脚本: - 000042: 重构佣金套餐模型 - 000043: 简化佣金分配 - 000044: 一次性佣金分配重构 - 000045: PackageSeries 添加 enable_one_time_commission 字段 测试: - 新增验收测试 (shop_series_allocation, commission_calculation) - 新增流程测试 (one_time_commission_chain) - 删除过时的单元测试(已被验收测试覆盖)
This commit is contained in:
133
.opencode/command/opsx-gen-tests.md
Normal file
133
.opencode/command/opsx-gen-tests.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试
|
||||
---
|
||||
|
||||
从 Spec 文档自动生成两类测试:
|
||||
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||
|
||||
**Input**: 可选指定 change 名称(如 `/opsx:gen-tests add-auth`)。如果省略,从上下文推断或提示选择。
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **选择 change**
|
||||
|
||||
如果提供了名称,使用它。否则:
|
||||
- 从对话上下文推断
|
||||
- 如果只有一个活跃 change,自动选择
|
||||
- 如果模糊,运行 `openspec list --json` 让用户选择
|
||||
|
||||
2. **检查 change 状态**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
确认 specs artifact 已完成(`status: "done"`)
|
||||
|
||||
3. **读取 spec 文件**
|
||||
|
||||
读取 `openspec/changes/<change-name>/specs/*/spec.md` 下的所有 spec 文件。
|
||||
|
||||
4. **解析 Scenarios**
|
||||
|
||||
从每个 spec 文件中提取 `#### Scenario:` 块:
|
||||
```markdown
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 200 和套餐详情
|
||||
```
|
||||
|
||||
5. **解析 Business Flows**(如果存在)
|
||||
|
||||
从 spec 文件中提取 `### Flow:` 块,包含多步骤业务场景。
|
||||
|
||||
6. **生成验收测试**
|
||||
|
||||
输出路径:`tests/acceptance/<capability>_acceptance_test.go`
|
||||
|
||||
模板结构:
|
||||
```go
|
||||
func Test{Capability}_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
t.Run("Scenario_{name}", func(t *testing.T) {
|
||||
// GIVEN: ...
|
||||
// WHEN: ...
|
||||
// THEN: ...
|
||||
// 破坏点:...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
7. **生成流程测试**
|
||||
|
||||
输出路径:`tests/flows/<capability>_<flow>_flow_test.go`
|
||||
|
||||
模板结构:
|
||||
```go
|
||||
func TestFlow_{FlowName}(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var (
|
||||
// 流程级共享状态
|
||||
)
|
||||
|
||||
t.Run("Step1_{name}", func(t *testing.T) {
|
||||
// 依赖:...
|
||||
// 破坏点:...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
8. **运行测试验证**
|
||||
|
||||
```bash
|
||||
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||
```
|
||||
|
||||
**预期**:全部 FAIL(功能未实现,证明测试有效)
|
||||
|
||||
**如果测试 PASS**:说明测试写得太弱,需要加强
|
||||
|
||||
**Output**
|
||||
|
||||
```
|
||||
## 测试生成完成
|
||||
|
||||
**Change:** <change-name>
|
||||
**来源:** specs/<capability>/spec.md
|
||||
|
||||
### 生成的测试文件
|
||||
|
||||
**验收测试** (tests/acceptance/):
|
||||
- <capability>_acceptance_test.go
|
||||
- Scenario_xxx
|
||||
- Scenario_yyy
|
||||
|
||||
**流程测试** (tests/flows/):
|
||||
- <capability>_<flow>_flow_test.go
|
||||
- Step1_xxx
|
||||
- Step2_yyy
|
||||
|
||||
### 验证结果
|
||||
|
||||
$ source .env.local && go test -v ./tests/acceptance/... ./tests/flows/...
|
||||
|
||||
--- FAIL: TestXxx_Acceptance (0.00s)
|
||||
--- FAIL: TestXxx_Acceptance/Scenario_xxx (0.00s)
|
||||
xxx_acceptance_test.go:45: 404 != 200
|
||||
|
||||
✓ 所有测试预期 FAIL(功能未实现)
|
||||
✓ 测试生成完成
|
||||
|
||||
下一步: 开始实现 tasks,每完成一个功能单元运行相关测试验证
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- 每个 Scenario 必须生成一个测试用例(不要跳过)
|
||||
- 每个测试必须包含"破坏点"注释
|
||||
- 流程测试的 step 必须声明依赖
|
||||
- 使用 IntegrationTestEnv,不要 mock 依赖
|
||||
- 测试必须在功能缺失时 FAIL(不要写永远 PASS 的测试)
|
||||
- 详细模板参考:`.opencode/skills/openspec-generate-acceptance-tests/SKILL.md`
|
||||
@@ -102,7 +102,35 @@ Common artifact patterns:
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks, following TDD workflow structure:
|
||||
|
||||
**TDD Tasks Structure (MUST follow)**:
|
||||
```markdown
|
||||
## 0. 测试准备(实现前执行)
|
||||
- [ ] 0.1 生成验收测试和流程测试(/opsx:gen-tests)
|
||||
- [ ] 0.2 运行测试确认全部 FAIL(证明测试有效)
|
||||
|
||||
## 1. 基础设施(数据库 + Model)
|
||||
- [ ] 1.x 创建迁移、Model、DTO
|
||||
- [ ] 1.y 验证:编译通过
|
||||
|
||||
## 2. 功能单元 A(完整垂直切片)
|
||||
- [ ] 2.1 Store 层
|
||||
- [ ] 2.2 Service 层
|
||||
- [ ] 2.3 Handler 层 + 路由
|
||||
- [ ] 2.4 **验证:功能 A 相关验收测试 PASS**
|
||||
|
||||
## N. 最终验证
|
||||
- [ ] N.1 全部验收测试 PASS
|
||||
- [ ] N.2 全部流程测试 PASS
|
||||
- [ ] N.3 完整测试套件无回归
|
||||
```
|
||||
|
||||
**Key principles**:
|
||||
- Task group 0 MUST be test preparation (generate tests + confirm all FAIL)
|
||||
- Organize by functional units, NOT by technical layers (Store/Service/Handler)
|
||||
- Each functional unit MUST end with "verify related tests PASS"
|
||||
- Final validation MUST include all acceptance + flow tests passing
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
|
||||
@@ -252,11 +252,28 @@ You: That changes everything.
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Lock consensus first**: "讨论已经比较清晰了,要锁定共识吗?" → `/opsx:lock <name>`
|
||||
- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
### 推荐流程
|
||||
|
||||
当讨论涉及重要决策时,**建议先锁定共识再创建变更**:
|
||||
|
||||
```
|
||||
探索讨论 → /opsx:lock → /opsx:new 或 /opsx:ff
|
||||
```
|
||||
|
||||
锁定共识会生成 `consensus.md`,记录:
|
||||
- 要做什么
|
||||
- 不做什么
|
||||
- 关键约束
|
||||
- 验收标准
|
||||
|
||||
后续生成 proposal 时会自动验证是否符合共识。
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
@@ -269,6 +286,7 @@ When it feels like things are crystallizing, you might summarize:
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Lock consensus: /opsx:lock <name> (推荐先锁定)
|
||||
- Create a change: /opsx:new <name>
|
||||
- Fast-forward to tasks: /opsx:ff <name>
|
||||
- Keep exploring: just keep talking
|
||||
|
||||
442
.opencode/skills/openspec-generate-acceptance-tests/SKILL.md
Normal file
442
.opencode/skills/openspec-generate-acceptance-tests/SKILL.md
Normal file
@@ -0,0 +1,442 @@
|
||||
---
|
||||
name: openspec-generate-acceptance-tests
|
||||
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL,证明测试有效。
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: junhong
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 测试生成 Skill
|
||||
|
||||
从 Spec 文档自动生成两类测试:
|
||||
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||
|
||||
## 触发方式
|
||||
|
||||
```
|
||||
/opsx:gen-tests [change-name]
|
||||
```
|
||||
|
||||
如果不指定 change-name,自动检测当前活跃的 change。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. Change 必须存在且包含 spec 文件
|
||||
2. Spec 必须包含 `## Scenarios` 部分
|
||||
3. Spec 建议包含 `## Business Flows` 部分(如果有跨 API 场景)
|
||||
|
||||
检查命令:
|
||||
```bash
|
||||
openspec list --json
|
||||
# 确认 change 存在且有 specs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### Step 1: 读取 Spec 文件
|
||||
|
||||
```bash
|
||||
# 读取 change 的所有 spec 文件
|
||||
cat openspec/changes/<change-name>/specs/<capability>/spec.md
|
||||
```
|
||||
|
||||
### Step 2: 解析 Scenarios
|
||||
|
||||
从 Spec 中提取所有 Scenario:
|
||||
|
||||
```markdown
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 201 和套餐详情
|
||||
- **AND** 数据库中存在该套餐记录
|
||||
```
|
||||
|
||||
解析为结构:
|
||||
```json
|
||||
{
|
||||
"name": "成功创建套餐",
|
||||
"given": ["用户已登录且有创建权限"],
|
||||
"when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
|
||||
"then": ["返回 201 和套餐详情"],
|
||||
"and": ["数据库中存在该套餐记录"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 解析 Business Flows
|
||||
|
||||
从 Spec 中提取 Business Flow:
|
||||
|
||||
```markdown
|
||||
### Flow: 套餐完整生命周期
|
||||
|
||||
**参与者**: 平台管理员, 代理商
|
||||
|
||||
**流程步骤**:
|
||||
|
||||
1. **创建套餐**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/packages
|
||||
- 预期: 返回套餐 ID
|
||||
|
||||
2. **分配给代理商**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/shop-packages
|
||||
- 输入: 套餐 ID + 店铺 ID
|
||||
- 预期: 分配成功
|
||||
|
||||
3. **代理商查看可售套餐**
|
||||
- 角色: 代理商
|
||||
- 调用: GET /api/admin/shop-packages
|
||||
- 预期: 列表包含刚分配的套餐
|
||||
```
|
||||
|
||||
### Step 4: 生成验收测试
|
||||
|
||||
**输出路径**: `tests/acceptance/<capability>_acceptance_test.go`
|
||||
|
||||
```go
|
||||
package acceptance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 验收测试:套餐管理
|
||||
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||
// ============================================================
|
||||
|
||||
func TestPackage_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 成功创建套餐
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
// THEN: 返回 201 和套餐详情
|
||||
// AND: 数据库中存在该套餐记录
|
||||
//
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
body := map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"description": "测试描述",
|
||||
"price": 9900,
|
||||
"duration": 30,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 返回 201 和套餐详情
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, int(result["code"].(float64)))
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
packageID := uint(data["id"].(float64))
|
||||
assert.NotZero(t, packageID)
|
||||
|
||||
// AND: 数据库中存在该套餐记录
|
||||
// TODO: 实现后取消注释
|
||||
// pkg, err := env.DB().Package.FindByID(ctx, packageID)
|
||||
// require.NoError(t, err)
|
||||
// assert.Equal(t, "测试套餐", pkg.Name)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 创建套餐参数校验失败
|
||||
// GIVEN: 用户已登录
|
||||
// WHEN: POST /api/admin/packages with invalid data (name empty)
|
||||
// THEN: 返回 400 和错误信息
|
||||
//
|
||||
// 破坏点:如果删除 handler 中的参数校验,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_创建套餐参数校验失败", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with invalid data
|
||||
body := map[string]interface{}{
|
||||
"name": "", // 空名称
|
||||
"price": -1, // 负价格
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 返回 400 和错误信息
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: 生成流程测试
|
||||
|
||||
**输出路径**: `tests/flows/<capability>_<flow-name>_flow_test.go`
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:套餐完整生命周期
|
||||
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||
// 参与者:平台管理员, 代理商
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
packageID uint
|
||||
shopID uint = 1 // 测试店铺 ID
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 创建套餐
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/packages
|
||||
// 预期: 返回套餐 ID
|
||||
//
|
||||
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "流程测试套餐",
|
||||
"description": "用于流程测试",
|
||||
"price": 19900,
|
||||
"duration": 30,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
packageID = uint(data["id"].(float64))
|
||||
require.NotZero(t, packageID, "套餐 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 分配给代理商
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-packages
|
||||
// 输入: 套餐 ID + 店铺 ID
|
||||
// 预期: 分配成功
|
||||
//
|
||||
// 依赖: Step 1 的 packageID
|
||||
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_分配套餐给代理商", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"package_id": packageID,
|
||||
"shop_id": shopID,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/shop-packages", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 代理商查看可售套餐
|
||||
// 角色: 代理商
|
||||
// 调用: GET /api/admin/shop-packages
|
||||
// 预期: 列表包含刚分配的套餐
|
||||
//
|
||||
// 依赖: Step 2 的分配操作
|
||||
// 破坏点:如果查询不按店铺过滤,代理商会看到其他店铺的套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
|
||||
// 以代理商身份请求
|
||||
client := env.AsShopAgent(shopID)
|
||||
|
||||
resp, err := client.Request("GET", "/api/admin/shop-packages", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证列表包含刚分配的套餐
|
||||
data := result["data"].(map[string]interface{})
|
||||
list := data["list"].([]interface{})
|
||||
|
||||
found := false
|
||||
for _, item := range list {
|
||||
pkg := item.(map[string]interface{})
|
||||
if uint(pkg["package_id"].(float64)) == packageID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "代理商应该能看到刚分配的套餐")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: 运行测试验证
|
||||
|
||||
生成测试后,立即运行验证:
|
||||
|
||||
```bash
|
||||
# 预期全部 FAIL(因为功能尚未实现)
|
||||
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||
```
|
||||
|
||||
**如果测试 PASS**:
|
||||
- 说明测试写得太弱,没有真正验证功能
|
||||
- 需要加强测试或检查是否功能已存在
|
||||
|
||||
---
|
||||
|
||||
## 测试模板规范
|
||||
|
||||
### 验收测试必须包含
|
||||
|
||||
1. **来源注释**:标明从哪个 spec 文件生成
|
||||
2. **Scenario 注释**:完整的 GIVEN/WHEN/THEN/AND
|
||||
3. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||
4. **清晰的结构**:GIVEN → WHEN → THEN → AND 分块
|
||||
|
||||
### 流程测试必须包含
|
||||
|
||||
1. **来源注释**:标明从哪个 spec 文件生成
|
||||
2. **参与者注释**:涉及哪些角色
|
||||
3. **共享状态声明**:流程中需要传递的数据
|
||||
4. **依赖声明**:每个 step 依赖哪些前置 step
|
||||
5. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||
|
||||
### 破坏点注释示例
|
||||
|
||||
```go
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
|
||||
// 破坏点:如果查询不按 shop_id 过滤,此测试将失败(会返回其他店铺数据)
|
||||
// 破坏点:如果状态机允许 "已使用" → "未分配" 的转换,此测试将失败
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 输出文件
|
||||
|
||||
| 来源 | 输出 |
|
||||
|------|------|
|
||||
| Spec Scenarios | `tests/acceptance/<capability>_acceptance_test.go` |
|
||||
| Spec Business Flows | `tests/flows/<capability>_<flow>_flow_test.go` |
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **不要跳过 Scenario** - 每个 Scenario 必须对应一个测试用例
|
||||
- **不要省略破坏点** - 每个测试必须有破坏点注释
|
||||
- **不要硬编码 ID** - 使用共享变量传递流程中的 ID
|
||||
- **不要忽略依赖** - 流程测试的 step 必须声明依赖
|
||||
- **不要写永远 PASS 的测试** - 测试必须在功能缺失时 FAIL
|
||||
|
||||
---
|
||||
|
||||
## 与其他 Skills 的关系
|
||||
|
||||
| Skill | 关系 |
|
||||
|-------|------|
|
||||
| `openspec-continue-change` | 生成 spec 后可触发 gen-tests |
|
||||
| `openspec-apply-change` | 实现时参考生成的测试 |
|
||||
| `openspec-verify-change` | 验证时运行生成的测试 |
|
||||
|
||||
---
|
||||
|
||||
## 示例交互
|
||||
|
||||
```
|
||||
User: /opsx:gen-tests package-management
|
||||
|
||||
AI: 正在分析 change: package-management
|
||||
|
||||
找到 spec 文件: openspec/changes/package-management/specs/package/spec.md
|
||||
|
||||
解析结果:
|
||||
- Scenarios: 5 个
|
||||
- Business Flows: 2 个
|
||||
|
||||
生成测试文件:
|
||||
|
||||
1. tests/acceptance/package_acceptance_test.go
|
||||
- Scenario_成功创建套餐
|
||||
- Scenario_创建套餐参数校验失败
|
||||
- Scenario_成功更新套餐
|
||||
- Scenario_成功删除套餐
|
||||
- Scenario_删除已分配套餐失败
|
||||
|
||||
2. tests/flows/package_lifecycle_flow_test.go
|
||||
- Step1_平台管理员创建套餐
|
||||
- Step2_分配套餐给代理商
|
||||
- Step3_代理商查看可售套餐
|
||||
- Step4_代理商创建订单
|
||||
- Step5_套餐使用统计更新
|
||||
|
||||
3. tests/flows/package_allocation_flow_test.go
|
||||
- Step1_批量分配套餐
|
||||
- Step2_验证分配结果
|
||||
- Step3_撤销部分分配
|
||||
|
||||
运行验证:
|
||||
$ source .env.local && go test -v ./tests/acceptance/package_... ./tests/flows/package_...
|
||||
|
||||
--- FAIL: TestPackage_Acceptance (0.00s)
|
||||
--- FAIL: TestPackage_Acceptance/Scenario_成功创建套餐 (0.00s)
|
||||
package_acceptance_test.go:45: 404 != 201
|
||||
--- FAIL: TestFlow_PackageLifecycle (0.00s)
|
||||
--- FAIL: TestFlow_PackageLifecycle/Step1_平台管理员创建套餐 (0.00s)
|
||||
package_lifecycle_flow_test.go:38: 404 != 201
|
||||
|
||||
✓ 所有测试预期 FAIL(功能未实现)
|
||||
✓ 测试生成完成
|
||||
|
||||
下一步: /opsx:continue 生成 design 和 tasks
|
||||
```
|
||||
281
.opencode/skills/openspec-lock-consensus/SKILL.md
Normal file
281
.opencode/skills/openspec-lock-consensus/SKILL.md
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
name: openspec-lock-consensus
|
||||
description: 锁定共识 - 在探索讨论后,将讨论结果锁定为正式共识文档。防止后续提案偏离讨论内容。
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: junhong
|
||||
version: "1.1"
|
||||
---
|
||||
|
||||
# 共识锁定 Skill
|
||||
|
||||
在 `/opsx:explore` 讨论后,使用此 skill 将讨论结果锁定为正式共识。共识文档是后续所有 artifact 的基础约束。
|
||||
|
||||
## 触发方式
|
||||
|
||||
```
|
||||
/opsx:lock <change-name>
|
||||
```
|
||||
|
||||
或在探索结束后,AI 主动提议:
|
||||
> "讨论已经比较清晰了,要锁定共识吗?"
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### Step 1: 整理讨论要点
|
||||
|
||||
从对话中提取以下四个维度的共识:
|
||||
|
||||
| 维度 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **要做什么** | 明确的功能范围 | "支持批量导入 IoT 卡" |
|
||||
| **不做什么** | 明确排除的内容 | "不支持实时同步,仅定时批量" |
|
||||
| **关键约束** | 技术/业务限制 | "必须使用 Asynq 异步任务" |
|
||||
| **验收标准** | 如何判断完成 | "导入 1000 张卡 < 30s" |
|
||||
|
||||
### Step 2: 使用 Question_tool 逐维度确认
|
||||
|
||||
**必须使用 Question_tool 进行结构化确认**,每个维度一个问题:
|
||||
|
||||
```typescript
|
||||
// 示例:确认"要做什么"
|
||||
Question_tool({
|
||||
questions: [{
|
||||
header: "确认:要做什么",
|
||||
question: "以下是整理的功能范围,请确认:\n\n" +
|
||||
"1. 功能点 A\n" +
|
||||
"2. 功能点 B\n" +
|
||||
"3. 功能点 C\n\n" +
|
||||
"是否准确完整?",
|
||||
options: [
|
||||
{ label: "确认无误", description: "以上内容准确完整" },
|
||||
{ label: "需要补充", description: "有遗漏的功能点" },
|
||||
{ label: "需要删减", description: "有不应该包含的内容" }
|
||||
],
|
||||
multiple: false
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
**如果用户选择"需要补充"或"需要删减"**:
|
||||
- 用户会通过自定义输入提供修改意见
|
||||
- 根据反馈更新列表,再次使用 Question_tool 确认
|
||||
|
||||
**确认流程**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Question_tool: 确认"要做什么" │
|
||||
│ ├── 用户选择"确认无误" → 进入下一维度 │
|
||||
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Question_tool: 确认"不做什么" │
|
||||
│ ├── 用户选择"确认无误" → 进入下一维度 │
|
||||
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Question_tool: 确认"关键约束" │
|
||||
│ ├── 用户选择"确认无误" → 进入下一维度 │
|
||||
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Question_tool: 确认"验收标准" │
|
||||
│ ├── 用户选择"确认无误" → 生成 consensus.md │
|
||||
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 3: 生成 consensus.md
|
||||
|
||||
所有维度确认后,创建文件:
|
||||
|
||||
```bash
|
||||
# 检查 change 是否存在
|
||||
openspec list --json
|
||||
|
||||
# 如果 change 不存在,先创建
|
||||
# openspec new <change-name>
|
||||
|
||||
# 写入 consensus.md
|
||||
```
|
||||
|
||||
**文件路径**: `openspec/changes/<change-name>/consensus.md`
|
||||
|
||||
---
|
||||
|
||||
## Question_tool 使用规范
|
||||
|
||||
### 每个维度的问题模板
|
||||
|
||||
**1. 要做什么**
|
||||
```typescript
|
||||
{
|
||||
header: "确认:要做什么",
|
||||
question: "以下是整理的【功能范围】:\n\n" +
|
||||
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||
"\n\n请确认是否准确完整?",
|
||||
options: [
|
||||
{ label: "确认无误", description: "功能范围准确完整" },
|
||||
{ label: "需要补充", description: "有遗漏的功能点" },
|
||||
{ label: "需要删减", description: "有不应该包含的内容" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**2. 不做什么**
|
||||
```typescript
|
||||
{
|
||||
header: "确认:不做什么",
|
||||
question: "以下是明确【排除的内容】:\n\n" +
|
||||
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||
"\n\n请确认是否正确?",
|
||||
options: [
|
||||
{ label: "确认无误", description: "排除范围正确" },
|
||||
{ label: "需要补充", description: "还有其他需要排除的" },
|
||||
{ label: "需要删减", description: "有些不应该排除" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**3. 关键约束**
|
||||
```typescript
|
||||
{
|
||||
header: "确认:关键约束",
|
||||
question: "以下是【关键约束】:\n\n" +
|
||||
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||
"\n\n请确认是否正确?",
|
||||
options: [
|
||||
{ label: "确认无误", description: "约束条件正确" },
|
||||
{ label: "需要补充", description: "还有其他约束" },
|
||||
{ label: "需要修改", description: "约束描述不准确" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**4. 验收标准**
|
||||
```typescript
|
||||
{
|
||||
header: "确认:验收标准",
|
||||
question: "以下是【验收标准】(必须可测量):\n\n" +
|
||||
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||
"\n\n请确认是否正确?",
|
||||
options: [
|
||||
{ label: "确认无误", description: "验收标准清晰可测量" },
|
||||
{ label: "需要补充", description: "还有其他验收标准" },
|
||||
{ label: "需要修改", description: "标准不够清晰或无法测量" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 处理用户反馈
|
||||
|
||||
当用户选择非"确认无误"选项或提供自定义输入时:
|
||||
|
||||
1. 解析用户的修改意见
|
||||
2. 更新对应维度的内容
|
||||
3. 再次使用 Question_tool 确认更新后的内容
|
||||
4. 重复直到用户选择"确认无误"
|
||||
|
||||
---
|
||||
|
||||
## consensus.md 模板
|
||||
|
||||
```markdown
|
||||
# 共识文档
|
||||
|
||||
**Change**: <change-name>
|
||||
**确认时间**: <timestamp>
|
||||
**确认人**: 用户
|
||||
|
||||
---
|
||||
|
||||
## 1. 要做什么
|
||||
|
||||
- [x] 功能点 A(已确认)
|
||||
- [x] 功能点 B(已确认)
|
||||
- [x] 功能点 C(已确认)
|
||||
|
||||
## 2. 不做什么
|
||||
|
||||
- [x] 排除项 A(已确认)
|
||||
- [x] 排除项 B(已确认)
|
||||
|
||||
## 3. 关键约束
|
||||
|
||||
- [x] 技术约束 A(已确认)
|
||||
- [x] 业务约束 B(已确认)
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [x] 验收标准 A(已确认)
|
||||
- [x] 验收标准 B(已确认)
|
||||
|
||||
---
|
||||
|
||||
## 讨论背景
|
||||
|
||||
<简要总结讨论的核心问题和解决方向>
|
||||
|
||||
## 关键决策记录
|
||||
|
||||
| 决策点 | 选择 | 原因 |
|
||||
|--------|------|------|
|
||||
| 决策 1 | 选项 A | 理由... |
|
||||
| 决策 2 | 选项 B | 理由... |
|
||||
|
||||
---
|
||||
|
||||
**签字确认**: 用户已通过 Question_tool 逐条确认以上内容
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后续流程绑定
|
||||
|
||||
### Proposal 生成时
|
||||
|
||||
`/opsx:continue` 生成 proposal 时,**必须**:
|
||||
|
||||
1. 读取 `consensus.md`
|
||||
2. 确保 proposal 的 Capabilities 覆盖"要做什么"中的每一项
|
||||
3. 确保 proposal 不包含"不做什么"中的内容
|
||||
4. 确保 proposal 遵守"关键约束"
|
||||
|
||||
### 验证机制
|
||||
|
||||
如果 proposal 与 consensus 不一致,输出警告:
|
||||
|
||||
```
|
||||
⚠️ Proposal 验证警告:
|
||||
|
||||
共识中"要做什么"但 Proposal 未提及:
|
||||
- 功能点 C
|
||||
|
||||
共识中"不做什么"但 Proposal 包含:
|
||||
- 排除项 A
|
||||
|
||||
建议修正 Proposal 或更新共识。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **必须使用 Question_tool** - 不要用纯文本确认
|
||||
- **逐维度确认** - 四个维度分开确认,不要合并
|
||||
- **不要跳过确认** - 每个维度都必须让用户明确确认
|
||||
- **不要自作主张** - 只整理讨论中明确提到的内容
|
||||
- **避免模糊表述** - "尽量"、"可能"、"考虑"等词汇需要明确化
|
||||
- **验收标准必须可测量** - 避免"性能要好"这类无法验证的标准
|
||||
|
||||
---
|
||||
|
||||
## 与其他 Skills 的关系
|
||||
|
||||
| Skill | 关系 |
|
||||
|-------|------|
|
||||
| `openspec-explore` | 探索结束后触发 lock |
|
||||
| `openspec-new-change` | lock 后触发 new(如果 change 不存在)|
|
||||
| `openspec-continue-change` | 生成 proposal 时读取 consensus 验证 |
|
||||
| `openspec-generate-acceptance-tests` | 从 consensus 的验收标准生成测试骨架 |
|
||||
65
AGENTS.md
65
AGENTS.md
@@ -134,10 +134,67 @@ Handler → Service → Store → Model
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 核心业务逻辑(Service 层)测试覆盖率 ≥ 90%
|
||||
- 所有 API 端点必须有集成测试
|
||||
- 使用 table-driven tests
|
||||
- 单元测试 < 100ms,集成测试 < 1s
|
||||
### 测试金字塔(新)
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ E2E 测试 │ ← 手动/自动化 UI(很少)
|
||||
─┴─────────────┴─
|
||||
┌─────────────────┐
|
||||
│ 业务流程测试 │ ← 15%:多 API 组合验证
|
||||
│ tests/flows/ │ 来源:Spec Business Flow
|
||||
─┴─────────────────┴─
|
||||
┌─────────────────────┐
|
||||
│ 验收测试 │ ← 30%:单 API 契约验证
|
||||
│ tests/acceptance/ │ 来源:Spec Scenario
|
||||
─┴─────────────────────┴─
|
||||
┌───────────────────────────┐
|
||||
│ 集成测试 │ ← 25%:组件集成
|
||||
─┴───────────────────────────┴─
|
||||
┌─────────────────────────────────┐
|
||||
│ 单元测试(精简) │ ← 30%:仅复杂逻辑
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 三层测试体系
|
||||
|
||||
| 层级 | 测试类型 | 来源 | 验证什么 | 位置 |
|
||||
|------|---------|------|---------|------|
|
||||
| **L1** | 验收测试 | Spec Scenario | 单 API 契约 | `tests/acceptance/` |
|
||||
| **L2** | 流程测试 | Spec Business Flow | 业务场景完整性 | `tests/flows/` |
|
||||
| **L3** | 单元测试 | 复杂逻辑 | 算法/规则正确性 | 模块内 `*_test.go` |
|
||||
|
||||
### 验收测试规范
|
||||
|
||||
- **来源于 Spec**:每个 Scenario 对应一个测试用例
|
||||
- **测试先于实现**:在功能实现前生成,预期全部 FAIL
|
||||
- **必须有破坏点**:每个测试注释说明什么代码变更会导致失败
|
||||
- **使用 IntegrationTestEnv**:不要 mock 依赖
|
||||
|
||||
详见:[tests/acceptance/README.md](tests/acceptance/README.md)
|
||||
|
||||
### 流程测试规范
|
||||
|
||||
- **来源于 Spec Business Flow**:每个 Flow 对应一个测试
|
||||
- **跨 API 验证**:多个 API 调用的组合行为
|
||||
- **状态共享**:流程中的数据在 steps 之间传递
|
||||
- **依赖声明**:每个 step 声明依赖哪些前置 step
|
||||
|
||||
详见:[tests/flows/README.md](tests/flows/README.md)
|
||||
|
||||
### 单元测试精简规则
|
||||
|
||||
**保留**:
|
||||
- ✅ 纯函数(计费计算、分佣算法)
|
||||
- ✅ 状态机(订单状态流转)
|
||||
- ✅ 复杂业务规则(层级校验、权限计算)
|
||||
- ✅ 边界条件(时间、金额、精度)
|
||||
|
||||
**删除/不再写**:
|
||||
- ❌ 简单 CRUD(已被验收测试覆盖)
|
||||
- ❌ DTO 转换
|
||||
- ❌ 配置读取
|
||||
- ❌ 重复测试同一逻辑
|
||||
|
||||
### ⚠️ 测试真实性原则(严格遵守)
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ default:
|
||||
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
|
||||
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
|
||||
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
||||
- **代理商体系**:层级管理和分佣结算
|
||||
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
|
||||
- **批量同步**:卡状态、实名状态、流量使用情况
|
||||
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
||||
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
||||
|
||||
@@ -74,6 +74,9 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
enterprise_name:
|
||||
description: 企业名称
|
||||
type: string
|
||||
id:
|
||||
description: 账号ID
|
||||
minimum: 0
|
||||
@@ -86,6 +89,9 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
shop_name:
|
||||
description: 店铺名称
|
||||
type: string
|
||||
status:
|
||||
description: 状态 (0:禁用, 1:启用)
|
||||
type: integer
|
||||
@@ -632,23 +638,13 @@ components:
|
||||
description: 设备号
|
||||
type: string
|
||||
type: object
|
||||
DtoBaseCommissionConfig:
|
||||
properties:
|
||||
mode:
|
||||
description: 返佣模式 (fixed:固定金额, percent:百分比)
|
||||
type: string
|
||||
value:
|
||||
description: 返佣值(分或千分比,如200=20%)
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- mode
|
||||
- value
|
||||
type: object
|
||||
DtoBatchAllocatePackagesRequest:
|
||||
properties:
|
||||
base_commission:
|
||||
$ref: '#/components/schemas/DtoBaseCommissionConfig'
|
||||
one_time_commission_amount:
|
||||
description: 该代理能拿到的一次性佣金(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
price_adjustment:
|
||||
$ref: '#/components/schemas/DtoPriceAdjustment'
|
||||
series_id:
|
||||
@@ -662,7 +658,6 @@ components:
|
||||
required:
|
||||
- shop_id
|
||||
- series_id
|
||||
- base_commission
|
||||
type: object
|
||||
DtoBatchSetCardSeriesBindngRequest:
|
||||
properties:
|
||||
@@ -1119,20 +1114,18 @@ components:
|
||||
type: object
|
||||
DtoCreatePackageRequest:
|
||||
properties:
|
||||
data_amount_mb:
|
||||
description: 总流量额度(MB)
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
data_type:
|
||||
description: 流量类型 (real:真流量, virtual:虚流量)
|
||||
nullable: true
|
||||
type: string
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
maximum: 120
|
||||
minimum: 1
|
||||
type: integer
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
package_code:
|
||||
description: 套餐编码
|
||||
maxLength: 100
|
||||
@@ -1146,10 +1139,6 @@ components:
|
||||
package_type:
|
||||
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||
type: string
|
||||
price:
|
||||
description: 套餐价格(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
real_data_mb:
|
||||
description: 真流量额度(MB)
|
||||
minimum: 0
|
||||
@@ -1160,11 +1149,6 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
suggested_cost_price:
|
||||
description: 建议成本价(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
suggested_retail_price:
|
||||
description: 建议售价(分)
|
||||
minimum: 0
|
||||
@@ -1180,7 +1164,7 @@ components:
|
||||
- package_name
|
||||
- package_type
|
||||
- duration_months
|
||||
- price
|
||||
- cost_price
|
||||
type: object
|
||||
DtoCreatePackageSeriesRequest:
|
||||
properties:
|
||||
@@ -1188,6 +1172,8 @@ components:
|
||||
description: 描述
|
||||
maxLength: 500
|
||||
type: string
|
||||
one_time_commission_config:
|
||||
$ref: '#/components/schemas/DtoSeriesOneTimeCommissionConfigDTO'
|
||||
series_code:
|
||||
description: 系列编码
|
||||
maxLength: 100
|
||||
@@ -1279,7 +1265,7 @@ components:
|
||||
DtoCreateShopPackageAllocationRequest:
|
||||
properties:
|
||||
cost_price:
|
||||
description: 覆盖的成本价(分)
|
||||
description: 该代理的成本价(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
package_id:
|
||||
@@ -1361,25 +1347,35 @@ components:
|
||||
type: object
|
||||
DtoCreateShopSeriesAllocationRequest:
|
||||
properties:
|
||||
base_commission:
|
||||
$ref: '#/components/schemas/DtoBaseCommissionConfig'
|
||||
enable_force_recharge:
|
||||
description: 是否启用强充(累计充值强充)
|
||||
description: 是否启用强制充值
|
||||
nullable: true
|
||||
type: boolean
|
||||
enable_one_time_commission:
|
||||
description: 是否启用一次性佣金
|
||||
nullable: true
|
||||
type: boolean
|
||||
force_recharge_amount:
|
||||
description: 强充金额(分,0表示使用阈值金额)
|
||||
description: 强制充值金额(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
force_recharge_trigger_type:
|
||||
description: 强充触发类型(1:单次充值, 2:累计充值)
|
||||
description: 强充触发类型 (1:单次充值, 2:累计充值)
|
||||
nullable: true
|
||||
type: integer
|
||||
one_time_commission_config:
|
||||
$ref: '#/components/schemas/DtoOneTimeCommissionConfig'
|
||||
one_time_commission_amount:
|
||||
description: 该代理能拿的一次性佣金金额上限(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
one_time_commission_threshold:
|
||||
description: 一次性佣金触发阈值(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
one_time_commission_trigger:
|
||||
description: 一次性佣金触发类型 (first_recharge:首次充值, accumulated_recharge:累计充值)
|
||||
type: string
|
||||
series_id:
|
||||
description: 套餐系列ID
|
||||
minimum: 0
|
||||
@@ -1391,7 +1387,7 @@ components:
|
||||
required:
|
||||
- shop_id
|
||||
- series_id
|
||||
- base_commission
|
||||
- one_time_commission_amount
|
||||
type: object
|
||||
DtoCreateWithdrawalSettingReq:
|
||||
properties:
|
||||
@@ -2205,9 +2201,6 @@ components:
|
||||
card_category:
|
||||
description: 卡业务类型 (normal:普通卡, industry:行业卡)
|
||||
type: string
|
||||
card_type:
|
||||
description: 卡类型
|
||||
type: string
|
||||
carrier_id:
|
||||
description: 运营商ID
|
||||
minimum: 0
|
||||
@@ -2530,57 +2523,26 @@ components:
|
||||
description: 已提现佣金(分)
|
||||
type: integer
|
||||
type: object
|
||||
DtoOneTimeCommissionConfig:
|
||||
DtoOneTimeCommissionTierDTO:
|
||||
properties:
|
||||
mode:
|
||||
description: 返佣模式 (fixed:固定金额, percent:百分比) - 固定类型时必填
|
||||
amount:
|
||||
description: 佣金金额(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
dimension:
|
||||
description: 统计维度 (sales_count:销量, sales_amount:销售额)
|
||||
type: string
|
||||
stat_scope:
|
||||
description: 统计范围 (self:仅自己, self_and_sub:自己+下级)
|
||||
type: string
|
||||
threshold:
|
||||
description: 最低阈值(分)
|
||||
minimum: 1
|
||||
type: integer
|
||||
tiers:
|
||||
description: 梯度档位列表 - 梯度类型时必填
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoOneTimeCommissionTierEntry'
|
||||
nullable: true
|
||||
type: array
|
||||
trigger:
|
||||
description: 触发条件 (single_recharge:单次充值, accumulated_recharge:累计充值)
|
||||
type: string
|
||||
type:
|
||||
description: 一次性佣金类型 (fixed:固定, tiered:梯度)
|
||||
type: string
|
||||
value:
|
||||
description: 佣金金额(分)或比例(千分比)- 固定类型时必填
|
||||
minimum: 1
|
||||
description: 达标阈值
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- type
|
||||
- trigger
|
||||
- dimension
|
||||
- threshold
|
||||
type: object
|
||||
DtoOneTimeCommissionTierEntry:
|
||||
properties:
|
||||
mode:
|
||||
description: 返佣模式 (fixed:固定金额, percent:百分比)
|
||||
type: string
|
||||
threshold:
|
||||
description: 梯度阈值(销量或销售额分)
|
||||
minimum: 1
|
||||
type: integer
|
||||
tier_type:
|
||||
description: 梯度类型 (sales_count:销量, sales_amount:销售额)
|
||||
type: string
|
||||
value:
|
||||
description: 返佣值(分或千分比)
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- tier_type
|
||||
- threshold
|
||||
- mode
|
||||
- value
|
||||
- amount
|
||||
type: object
|
||||
DtoOrderItemResponse:
|
||||
properties:
|
||||
@@ -2720,8 +2682,7 @@ components:
|
||||
DtoPackageResponse:
|
||||
properties:
|
||||
cost_price:
|
||||
description: 成本价(分,仅代理用户可见)
|
||||
nullable: true
|
||||
description: 成本价(分)
|
||||
type: integer
|
||||
created_at:
|
||||
description: 创建时间
|
||||
@@ -2729,19 +2690,20 @@ components:
|
||||
current_commission_rate:
|
||||
description: 当前返佣比例(仅代理用户可见)
|
||||
type: string
|
||||
data_amount_mb:
|
||||
description: 总流量额度(MB)
|
||||
type: integer
|
||||
data_type:
|
||||
description: 流量类型 (real:真流量, virtual:虚流量)
|
||||
type: string
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
type: integer
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
id:
|
||||
description: 套餐ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
one_time_commission_amount:
|
||||
description: 一次性佣金金额(分,代理视角)
|
||||
nullable: true
|
||||
type: integer
|
||||
package_code:
|
||||
description: 套餐编码
|
||||
type: string
|
||||
@@ -2751,9 +2713,6 @@ components:
|
||||
package_type:
|
||||
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||
type: string
|
||||
price:
|
||||
description: 套餐价格(分)
|
||||
type: integer
|
||||
profit_margin:
|
||||
description: 利润空间(分,仅代理用户可见)
|
||||
nullable: true
|
||||
@@ -2776,9 +2735,6 @@ components:
|
||||
status:
|
||||
description: 状态 (1:启用, 2:禁用)
|
||||
type: integer
|
||||
suggested_cost_price:
|
||||
description: 建议成本价(分)
|
||||
type: integer
|
||||
suggested_retail_price:
|
||||
description: 建议售价(分)
|
||||
type: integer
|
||||
@@ -2820,10 +2776,15 @@ components:
|
||||
description:
|
||||
description: 描述
|
||||
type: string
|
||||
enable_one_time_commission:
|
||||
description: 是否启用一次性佣金
|
||||
type: boolean
|
||||
id:
|
||||
description: 系列ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
one_time_commission_config:
|
||||
$ref: '#/components/schemas/DtoSeriesOneTimeCommissionConfigDTO'
|
||||
series_code:
|
||||
description: 系列编码
|
||||
type: string
|
||||
@@ -3380,6 +3341,48 @@ components:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
DtoSeriesOneTimeCommissionConfigDTO:
|
||||
properties:
|
||||
commission_amount:
|
||||
description: 固定佣金金额(分),commission_type=fixed时使用
|
||||
minimum: 0
|
||||
type: integer
|
||||
commission_type:
|
||||
description: 佣金类型 (fixed:固定, tiered:梯度)
|
||||
type: string
|
||||
enable:
|
||||
description: 是否启用一次性佣金
|
||||
type: boolean
|
||||
enable_force_recharge:
|
||||
description: 是否启用强充
|
||||
type: boolean
|
||||
force_amount:
|
||||
description: 强充金额(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
force_calc_type:
|
||||
description: 强充计算类型 (fixed:固定, dynamic:动态)
|
||||
type: string
|
||||
threshold:
|
||||
description: 触发阈值(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
tiers:
|
||||
description: 梯度配置列表,commission_type=tiered时使用
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoOneTimeCommissionTierDTO'
|
||||
nullable: true
|
||||
type: array
|
||||
trigger_type:
|
||||
description: 触发类型 (first_recharge:首充, accumulated_recharge:累计充值)
|
||||
type: string
|
||||
validity_type:
|
||||
description: 时效类型 (permanent:永久, fixed_date:固定日期, relative:相对时长)
|
||||
type: string
|
||||
validity_value:
|
||||
description: 时效值(日期或月数)
|
||||
type: string
|
||||
type: object
|
||||
DtoSetSpeedLimitRequest:
|
||||
properties:
|
||||
download_speed:
|
||||
@@ -3554,15 +3557,15 @@ components:
|
||||
type: object
|
||||
DtoShopPackageAllocationResponse:
|
||||
properties:
|
||||
allocation_id:
|
||||
description: 关联的系列分配ID
|
||||
allocator_shop_id:
|
||||
description: 分配者店铺ID,0表示平台分配
|
||||
minimum: 0
|
||||
type: integer
|
||||
calculated_cost_price:
|
||||
description: 原计算成本价(分),供参考
|
||||
type: integer
|
||||
allocator_shop_name:
|
||||
description: 分配者店铺名称
|
||||
type: string
|
||||
cost_price:
|
||||
description: 覆盖的成本价(分)
|
||||
description: 该代理的成本价(分)
|
||||
type: integer
|
||||
created_at:
|
||||
description: 创建时间
|
||||
@@ -3581,6 +3584,18 @@ components:
|
||||
package_name:
|
||||
description: 套餐名称
|
||||
type: string
|
||||
series_allocation_id:
|
||||
description: 关联的系列分配ID
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
series_id:
|
||||
description: 套餐系列ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
series_name:
|
||||
description: 套餐系列名称
|
||||
type: string
|
||||
shop_id:
|
||||
description: 被分配的店铺ID
|
||||
minimum: 0
|
||||
@@ -3718,35 +3733,43 @@ components:
|
||||
DtoShopSeriesAllocationResponse:
|
||||
properties:
|
||||
allocator_shop_id:
|
||||
description: 分配者店铺ID
|
||||
description: 分配者店铺ID,0表示平台分配
|
||||
minimum: 0
|
||||
type: integer
|
||||
allocator_shop_name:
|
||||
description: 分配者店铺名称
|
||||
type: string
|
||||
base_commission:
|
||||
$ref: '#/components/schemas/DtoBaseCommissionConfig'
|
||||
created_at:
|
||||
description: 创建时间
|
||||
type: string
|
||||
enable_force_recharge:
|
||||
description: 是否启用强充
|
||||
description: 是否启用强制充值
|
||||
type: boolean
|
||||
enable_one_time_commission:
|
||||
description: 是否启用一次性佣金
|
||||
type: boolean
|
||||
force_recharge_amount:
|
||||
description: 强充金额(分)
|
||||
description: 强制充值金额(分)
|
||||
type: integer
|
||||
force_recharge_trigger_type:
|
||||
description: 强充触发类型(1:单次充值, 2:累计充值)
|
||||
description: 强充触发类型 (1:单次充值, 2:累计充值)
|
||||
type: integer
|
||||
id:
|
||||
description: 分配ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
one_time_commission_config:
|
||||
$ref: '#/components/schemas/DtoOneTimeCommissionConfig'
|
||||
one_time_commission_amount:
|
||||
description: 该代理能拿的一次性佣金金额上限(分)
|
||||
type: integer
|
||||
one_time_commission_threshold:
|
||||
description: 一次性佣金触发阈值(分)
|
||||
type: integer
|
||||
one_time_commission_trigger:
|
||||
description: 一次性佣金触发类型
|
||||
type: string
|
||||
series_code:
|
||||
description: 套餐系列编码
|
||||
type: string
|
||||
series_id:
|
||||
description: 套餐系列ID
|
||||
minimum: 0
|
||||
@@ -3888,9 +3911,6 @@ components:
|
||||
card_category:
|
||||
description: 卡业务类型 (normal:普通卡, industry:行业卡)
|
||||
type: string
|
||||
card_type:
|
||||
description: 卡类型
|
||||
type: string
|
||||
carrier_id:
|
||||
description: 运营商ID
|
||||
minimum: 0
|
||||
@@ -4110,21 +4130,21 @@ components:
|
||||
type: object
|
||||
DtoUpdatePackageParams:
|
||||
properties:
|
||||
data_amount_mb:
|
||||
description: 总流量额度(MB)
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
data_type:
|
||||
description: 流量类型 (real:真流量, virtual:虚流量)
|
||||
nullable: true
|
||||
type: string
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
maximum: 120
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
nullable: true
|
||||
type: boolean
|
||||
package_name:
|
||||
description: 套餐名称
|
||||
maxLength: 255
|
||||
@@ -4135,11 +4155,6 @@ components:
|
||||
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||
nullable: true
|
||||
type: string
|
||||
price:
|
||||
description: 套餐价格(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
real_data_mb:
|
||||
description: 真流量额度(MB)
|
||||
minimum: 0
|
||||
@@ -4150,11 +4165,6 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
suggested_cost_price:
|
||||
description: 建议成本价(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
suggested_retail_price:
|
||||
description: 建议售价(分)
|
||||
minimum: 0
|
||||
@@ -4173,6 +4183,8 @@ components:
|
||||
maxLength: 500
|
||||
nullable: true
|
||||
type: string
|
||||
one_time_commission_config:
|
||||
$ref: '#/components/schemas/DtoSeriesOneTimeCommissionConfigDTO'
|
||||
series_name:
|
||||
description: 系列名称
|
||||
maxLength: 255
|
||||
@@ -4278,16 +4290,13 @@ components:
|
||||
properties:
|
||||
status:
|
||||
description: 状态 (0:禁用, 1:启用)
|
||||
maximum: 1
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
DtoUpdateShopPackageAllocationParams:
|
||||
properties:
|
||||
cost_price:
|
||||
description: 覆盖的成本价(分)
|
||||
description: 该代理的成本价(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
@@ -4333,10 +4342,8 @@ components:
|
||||
type: object
|
||||
DtoUpdateShopSeriesAllocationParams:
|
||||
properties:
|
||||
base_commission:
|
||||
$ref: '#/components/schemas/DtoBaseCommissionConfig'
|
||||
enable_force_recharge:
|
||||
description: 是否启用强充(累计充值强充)
|
||||
description: 是否启用强制充值
|
||||
nullable: true
|
||||
type: boolean
|
||||
enable_one_time_commission:
|
||||
@@ -4344,15 +4351,32 @@ components:
|
||||
nullable: true
|
||||
type: boolean
|
||||
force_recharge_amount:
|
||||
description: 强充金额(分,0表示使用阈值金额)
|
||||
description: 强制充值金额(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
force_recharge_trigger_type:
|
||||
description: 强充触发类型(1:单次充值, 2:累计充值)
|
||||
description: 强充触发类型 (1:单次充值, 2:累计充值)
|
||||
nullable: true
|
||||
type: integer
|
||||
one_time_commission_amount:
|
||||
description: 该代理能拿的一次性佣金金额上限(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
one_time_commission_threshold:
|
||||
description: 一次性佣金触发阈值(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
one_time_commission_trigger:
|
||||
description: 一次性佣金触发类型
|
||||
nullable: true
|
||||
type: string
|
||||
status:
|
||||
description: 状态 (1:启用, 2:禁用)
|
||||
nullable: true
|
||||
type: integer
|
||||
one_time_commission_config:
|
||||
$ref: '#/components/schemas/DtoOneTimeCommissionConfig'
|
||||
type: object
|
||||
DtoUpdateStatusParams:
|
||||
properties:
|
||||
@@ -4853,6 +4877,22 @@ paths:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 店铺ID筛选
|
||||
in: query
|
||||
name: shop_id
|
||||
schema:
|
||||
description: 店铺ID筛选
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 企业ID筛选
|
||||
in: query
|
||||
name: enterprise_id
|
||||
schema:
|
||||
description: 企业ID筛选
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -11008,6 +11048,13 @@ paths:
|
||||
description: 状态 (1:启用, 2:禁用)
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 是否启用一次性佣金
|
||||
in: query
|
||||
name: enable_one_time_commission
|
||||
schema:
|
||||
description: 是否启用一次性佣金
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -12823,6 +12870,22 @@ paths:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 系列分配ID
|
||||
in: query
|
||||
name: series_allocation_id
|
||||
schema:
|
||||
description: 系列分配ID
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 分配者店铺ID
|
||||
in: query
|
||||
name: allocator_shop_id
|
||||
schema:
|
||||
description: 分配者店铺ID
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 状态 (1:启用, 2:禁用)
|
||||
in: query
|
||||
name: status
|
||||
@@ -12885,7 +12948,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 单套餐分配列表
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
@@ -12947,7 +13010,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 创建单套餐分配
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
/api/admin/shop-package-allocations/{id}:
|
||||
delete:
|
||||
parameters:
|
||||
@@ -12988,7 +13051,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 删除单套餐分配
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
get:
|
||||
parameters:
|
||||
- description: ID
|
||||
@@ -13054,7 +13117,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 获取单套餐分配详情
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
put:
|
||||
parameters:
|
||||
- description: ID
|
||||
@@ -13125,7 +13188,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 更新单套餐分配
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
/api/admin/shop-package-allocations/{id}/cost-price:
|
||||
put:
|
||||
parameters:
|
||||
@@ -13192,7 +13255,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 更新单套餐分配成本价
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
/api/admin/shop-package-allocations/{id}/status:
|
||||
put:
|
||||
parameters:
|
||||
@@ -13238,7 +13301,7 @@ paths:
|
||||
- BearerAuth: []
|
||||
summary: 更新单套餐分配状态
|
||||
tags:
|
||||
- 单套餐分配
|
||||
- 套餐分配
|
||||
/api/admin/shop-package-batch-allocations:
|
||||
post:
|
||||
requestBody:
|
||||
@@ -13373,6 +13436,14 @@ paths:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 分配者店铺ID
|
||||
in: query
|
||||
name: allocator_shop_id
|
||||
schema:
|
||||
description: 分配者店铺ID
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 状态 (1:启用, 2:禁用)
|
||||
in: query
|
||||
name: status
|
||||
@@ -13433,9 +13504,9 @@ paths:
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 套餐系列分配列表
|
||||
summary: 系列分配列表
|
||||
tags:
|
||||
- 套餐系列分配
|
||||
- 套餐分配
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
@@ -13495,9 +13566,9 @@ paths:
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 创建套餐系列分配
|
||||
summary: 创建系列分配
|
||||
tags:
|
||||
- 套餐系列分配
|
||||
- 套餐分配
|
||||
/api/admin/shop-series-allocations/{id}:
|
||||
delete:
|
||||
parameters:
|
||||
@@ -13536,9 +13607,9 @@ paths:
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 删除套餐系列分配
|
||||
summary: 删除系列分配
|
||||
tags:
|
||||
- 套餐系列分配
|
||||
- 套餐分配
|
||||
get:
|
||||
parameters:
|
||||
- description: ID
|
||||
@@ -13602,9 +13673,9 @@ paths:
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 获取套餐系列分配详情
|
||||
summary: 获取系列分配详情
|
||||
tags:
|
||||
- 套餐系列分配
|
||||
- 套餐分配
|
||||
put:
|
||||
parameters:
|
||||
- description: ID
|
||||
@@ -13673,55 +13744,9 @@ paths:
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 更新套餐系列分配
|
||||
summary: 更新系列分配
|
||||
tags:
|
||||
- 套餐系列分配
|
||||
/api/admin/shop-series-allocations/{id}/status:
|
||||
put:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoUpdateStatusParams'
|
||||
responses:
|
||||
"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/shops:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
351
docs/commission-package-model.md
Normal file
351
docs/commission-package-model.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# 套餐与佣金业务模型
|
||||
|
||||
本文档定义了套餐、套餐系列、佣金的完整业务模型,作为系统改造的规范参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心概念
|
||||
|
||||
### 1.1 两种佣金类型
|
||||
|
||||
系统只有两种佣金类型:
|
||||
|
||||
| 佣金类型 | 触发时机 | 触发次数 | 计算方式 |
|
||||
|---------|---------|---------|---------|
|
||||
| **差价佣金** | 每笔订单 | 每单都触发 | 下级成本价 - 自己成本价 |
|
||||
| **一次性佣金** | 首充/累计充值达标 | 每张卡/设备只触发一次 | 上级给的 - 给下级的 |
|
||||
|
||||
### 1.2 实体关系
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 套餐系列 │
|
||||
│ PackageSeries │
|
||||
├─────────────────┤
|
||||
│ • 系列名称 │
|
||||
│ • 一次性佣金规则 │ ← 可选配置
|
||||
└────────┬────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ 套餐 │ │ 卡/设备 │
|
||||
│ Package │ │ IoT/Device │
|
||||
├─────────────────┤ ├─────────────────┤
|
||||
│ • 成本价 │ │ • 绑定系列ID │
|
||||
│ • 建议售价 │ │ • 累计充值金额 │ ← 按系列累计
|
||||
│ • 真流量(必填) │ │ • 是否已首充 │ ← 按系列记录
|
||||
│ • 虚流量(可选) │ └────────┬────────┘
|
||||
│ • 虚流量开关 │ │
|
||||
└────────┬────────┘ │ 分配
|
||||
│ ▼
|
||||
│ 分配 ┌─────────────────┐
|
||||
▼ │ 店铺 │
|
||||
┌─────────────────┐ │ Shop │
|
||||
│ 套餐分配 │◀─────────┤ • 代理层级 │
|
||||
│ PkgAllocation │ │ • 上级店铺ID │
|
||||
├─────────────────┤ └─────────────────┘
|
||||
│ • 店铺ID │
|
||||
│ • 套餐ID │
|
||||
│ • 成本价(加价后)│
|
||||
│ • 一次性佣金额 │ ← 给该代理的金额
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、套餐模型
|
||||
|
||||
### 2.1 字段定义
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `cost_price` | int64 | 是 | 成本价(平台设置的基础成本价,分) |
|
||||
| `suggested_price` | int64 | 是 | 建议售价(给代理参考,分) |
|
||||
| `real_data_mb` | int64 | 是 | 真实流量额度(MB) |
|
||||
| `enable_virtual_data` | bool | 否 | 是否启用虚流量 |
|
||||
| `virtual_data_mb` | int64 | 否 | 虚流量额度(启用时必填,≤ 真实流量,MB) |
|
||||
|
||||
### 2.2 流量停机判断
|
||||
|
||||
```
|
||||
停机目标值 = enable_virtual_data ? virtual_data_mb : real_data_mb
|
||||
```
|
||||
|
||||
### 2.3 不同用户视角
|
||||
|
||||
| 用户类型 | 看到的成本价 | 看到的一次性佣金 |
|
||||
|---------|-------------|-----------------|
|
||||
| 平台 | 基础成本价 | 完整规则 |
|
||||
| 代理A | A的成本价(已加价) | A能拿到的金额 |
|
||||
| 代理A1 | A1的成本价(再加价) | A1能拿到的金额 |
|
||||
|
||||
---
|
||||
|
||||
## 三、差价佣金
|
||||
|
||||
### 3.1 计算规则
|
||||
|
||||
```
|
||||
平台设置基础成本价: 100
|
||||
│
|
||||
│ 分配给代理A,设置成本价: 120
|
||||
▼
|
||||
代理A成本价: 120
|
||||
│
|
||||
│ 分配给代理A1,设置成本价: 130
|
||||
▼
|
||||
代理A1成本价: 130
|
||||
│
|
||||
│ A1销售给客户,售价: 200
|
||||
▼
|
||||
|
||||
结果:
|
||||
• A1 收入 = 200 - 130 = 70元(销售利润,不是佣金)
|
||||
• A 佣金 = 130 - 120 = 10元(差价佣金)
|
||||
• 平台收入 = 120元
|
||||
```
|
||||
|
||||
### 3.2 关键区分
|
||||
|
||||
- **收入/利润**:末端代理的 `售价 - 自己成本价`
|
||||
- **差价佣金**:上级代理的 `下级成本价 - 自己成本价`
|
||||
- **平台收入**:一级代理的成本价
|
||||
|
||||
---
|
||||
|
||||
## 四、一次性佣金
|
||||
|
||||
### 4.1 触发条件
|
||||
|
||||
| 条件类型 | 说明 | 强充要求 |
|
||||
|---------|------|---------|
|
||||
| `first_recharge` | 首充:该卡/设备在该系列下的第一次充值 | 必须强充 |
|
||||
| `accumulated_recharge` | 累计充值:累计充值金额达到阈值 | 可选强充 |
|
||||
|
||||
### 4.2 规则配置(套餐系列层面)
|
||||
|
||||
| 配置项 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| `enable` | bool | 是否启用一次性佣金 |
|
||||
| `trigger_type` | string | 触发类型:`first_recharge` / `accumulated_recharge` |
|
||||
| `threshold` | int64 | 触发阈值(分):首充要求金额 或 累计要求金额 |
|
||||
| `commission_type` | string | 返佣类型:`fixed`(固定) / `tiered`(梯度) |
|
||||
| `commission_amount` | int64 | 固定返佣金额(fixed类型时) |
|
||||
| `tiers` | array | 梯度配置(tiered类型时) |
|
||||
| `validity_type` | string | 时效类型:`permanent` / `fixed_date` / `relative` |
|
||||
| `validity_value` | string | 时效值(到期日期 或 月数) |
|
||||
| `enable_force_recharge` | bool | 是否启用强充 |
|
||||
| `force_calc_type` | string | 强充金额计算:`fixed`(固定) / `dynamic`(动态差额) |
|
||||
| `force_amount` | int64 | 强充金额(fixed类型时) |
|
||||
|
||||
### 4.3 链式分配
|
||||
|
||||
一次性佣金在整条代理链上按约定分配:
|
||||
|
||||
```
|
||||
系列规则:首充100返20
|
||||
|
||||
分配配置:
|
||||
平台给A:20元
|
||||
A给A1:8元
|
||||
A1给A2:5元
|
||||
|
||||
触发首充时:
|
||||
A2 获得:5元
|
||||
A1 获得:8 - 5 = 3元
|
||||
A 获得:20 - 8 = 12元
|
||||
─────────────────────
|
||||
合计:20元 ✓
|
||||
```
|
||||
|
||||
### 4.4 首充流程
|
||||
|
||||
```
|
||||
客户购买套餐
|
||||
│
|
||||
▼
|
||||
预检:系列是否启用一次性佣金且为首充?
|
||||
│
|
||||
否 ───────────────────▶ 正常购买流程
|
||||
│
|
||||
是
|
||||
│
|
||||
▼
|
||||
该卡/设备在该系列下是否已首充过?
|
||||
│
|
||||
是 ───────────────────▶ 正常购买流程(不再返佣)
|
||||
│
|
||||
否
|
||||
│
|
||||
▼
|
||||
计算强充金额 = max(首充要求, 套餐售价)
|
||||
│
|
||||
▼
|
||||
返回提示:"需要充值 xxx 元"
|
||||
│
|
||||
▼
|
||||
用户确认 → 创建充值订单(金额=强充金额)
|
||||
│
|
||||
▼
|
||||
用户支付
|
||||
│
|
||||
▼
|
||||
支付成功:
|
||||
1. 钱进入钱包
|
||||
2. 标记该卡/设备已首充
|
||||
3. 自动创建套餐购买订单并完成
|
||||
4. 扣款(套餐售价)
|
||||
5. 触发一次性佣金,链式分配
|
||||
```
|
||||
|
||||
### 4.5 累计充值流程
|
||||
|
||||
```
|
||||
客户充值(直接充值到钱包)
|
||||
│
|
||||
▼
|
||||
累计充值金额 += 本次充值金额
|
||||
│
|
||||
▼
|
||||
该卡/设备是否已触发过累计充值返佣?
|
||||
│
|
||||
是 ───────────────────▶ 结束(不再返佣)
|
||||
│
|
||||
否
|
||||
│
|
||||
▼
|
||||
累计金额 >= 累计要求?
|
||||
│
|
||||
否 ───────────────────▶ 结束(继续累计)
|
||||
│
|
||||
是
|
||||
│
|
||||
▼
|
||||
触发一次性佣金,链式分配
|
||||
标记该卡/设备已触发累计充值返佣
|
||||
```
|
||||
|
||||
**累计规则**:
|
||||
|
||||
| 操作类型 | 是否累计 |
|
||||
|---------|---------|
|
||||
| 直接充值到钱包 | ✅ 累计 |
|
||||
| 直接购买套餐(不经过钱包) | ❌ 不累计 |
|
||||
| 强充购买套餐(先充值再扣款) | ✅ 累计(充值部分) |
|
||||
|
||||
---
|
||||
|
||||
## 五、梯度佣金
|
||||
|
||||
梯度佣金是一次性佣金的进阶版,根据代理销量/销售额动态调整返佣金额。
|
||||
|
||||
### 5.1 配置项
|
||||
|
||||
| 配置项 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| `tier_dimension` | string | 梯度维度:`sales_count`(销量) / `sales_amount`(销售额) |
|
||||
| `stat_scope` | string | 统计范围:`self`(仅自己) / `self_and_sub`(自己+下级) |
|
||||
| `tiers` | array | 梯度档位列表 |
|
||||
| `tiers[].threshold` | int64 | 阈值(销量或销售额) |
|
||||
| `tiers[].amount` | int64 | 返佣金额(分) |
|
||||
|
||||
### 5.2 示例
|
||||
|
||||
```
|
||||
梯度规则(销量维度):
|
||||
┌────────────────┬────────────────────────┐
|
||||
│ 销量区间 │ 首充100返佣金额 │
|
||||
├────────────────┼────────────────────────┤
|
||||
│ >= 0 │ 5元 │
|
||||
├────────────────┼────────────────────────┤
|
||||
│ >= 100 │ 10元 │
|
||||
├────────────────┼────────────────────────┤
|
||||
│ >= 200 │ 20元 │
|
||||
└────────────────┴────────────────────────┘
|
||||
|
||||
代理A当前销量150单 → 落在 [100, 200) 区间 → 首充返10元
|
||||
```
|
||||
|
||||
### 5.3 梯度升级
|
||||
|
||||
```
|
||||
初始状态:
|
||||
代理A 销量150(适用10元档),给A1设置5元
|
||||
|
||||
触发时:A1得5元,A得10-5=5元
|
||||
|
||||
升级后(A销量达到210):
|
||||
A 适用20元档,A1配置仍为5元
|
||||
|
||||
触发时:A1得5元(不变),A得20-5=15元(增量归上级)
|
||||
```
|
||||
|
||||
### 5.4 统计周期
|
||||
|
||||
- 统计周期与一次性佣金时效一致
|
||||
- 只统计该套餐系列下的销量/销售额
|
||||
|
||||
---
|
||||
|
||||
## 六、约束规则
|
||||
|
||||
### 6.1 套餐分配
|
||||
|
||||
1. 下级成本价 >= 自己成本价(不能亏本卖)
|
||||
2. 只能分配自己有权限的套餐给下级
|
||||
3. 只能分配给直属下级(不能跨级)
|
||||
|
||||
### 6.2 一次性佣金分配
|
||||
|
||||
4. 给下级的金额 <= 自己能拿到的金额
|
||||
5. 给下级的金额 >= 0(可以设为0,独吞全部)
|
||||
|
||||
### 6.3 流量
|
||||
|
||||
6. 虚流量 <= 真实流量
|
||||
|
||||
### 6.4 配置修改
|
||||
|
||||
7. 修改配置只影响之后的新订单
|
||||
8. 代理只能修改"给下级多少钱",不能修改触发规则
|
||||
9. 平台修改系列规则不影响已分配的代理,需收回重新分配
|
||||
|
||||
### 6.5 触发限制
|
||||
|
||||
10. 一次性佣金每张卡/设备只触发一次
|
||||
11. "首充"指该卡/设备在该系列下的第一次充值
|
||||
12. 累计充值只统计"充值"操作,不统计"直接购买"
|
||||
|
||||
---
|
||||
|
||||
## 七、操作流程
|
||||
|
||||
### 7.1 理想的线性流程
|
||||
|
||||
```
|
||||
1. 创建套餐系列
|
||||
└─▶ 可选:配置一次性佣金规则
|
||||
|
||||
2. 创建套餐
|
||||
└─▶ 归属到系列
|
||||
└─▶ 设置成本价、建议售价
|
||||
└─▶ 设置真流量(必填)、虚流量(可选)
|
||||
|
||||
3. 分配套餐给代理
|
||||
└─▶ 设置代理成本价(加价)
|
||||
└─▶ 如果系列启用一次性佣金:设置给代理的一次性佣金额度
|
||||
|
||||
4. 分配资产(卡/设备)给代理
|
||||
└─▶ 资产绑定的套餐系列自动跟着走
|
||||
|
||||
5. 代理销售
|
||||
└─▶ 客户购买套餐
|
||||
└─▶ 差价佣金自动计算并入账给上级
|
||||
└─▶ 满足一次性佣金条件时,按链式分配入账
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、与现有代码的差异
|
||||
|
||||
详见改造提案:[refactor-commission-package-model](../openspec/changes/refactor-commission-package-model/)
|
||||
395
docs/refactor-commission-package-model/前端接口迁移指南.md
Normal file
395
docs/refactor-commission-package-model/前端接口迁移指南.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# 套餐与佣金模型重构 - 前端接口迁移指南
|
||||
|
||||
> 版本: v1.1
|
||||
> 更新日期: 2026-02-03
|
||||
> 影响范围: 套餐管理、系列管理、分配管理相关接口
|
||||
|
||||
---
|
||||
|
||||
## 一、变更概述
|
||||
|
||||
本次重构主要目标:
|
||||
1. 简化套餐价格字段(移除语义不清的字段)
|
||||
2. 支持真流量/虚流量共存机制
|
||||
3. 实现一次性佣金链式分配(上级给下级设置金额)
|
||||
4. 统一分配模型
|
||||
|
||||
### ⚠️ 重要:废弃内容汇总
|
||||
|
||||
**请确保前端代码中不再使用以下内容:**
|
||||
|
||||
#### 已废弃的枚举值
|
||||
|
||||
| 旧值 | 新值 | 说明 |
|
||||
|------|------|------|
|
||||
| `single_recharge` | `first_recharge` | 触发类型:单次充值 → 首充 |
|
||||
|
||||
#### 已废弃的请求字段(系列分配接口)
|
||||
|
||||
以下字段在系列分配接口中**已完全移除**,前端不应再传递:
|
||||
|
||||
```json
|
||||
// ❌ 以下字段已废弃,请勿使用
|
||||
{
|
||||
"enable_one_time_commission": true, // 已废弃
|
||||
"one_time_commission_type": "fixed", // 已废弃
|
||||
"one_time_commission_trigger": "...", // 已废弃
|
||||
"one_time_commission_threshold": 10000, // 已废弃
|
||||
"one_time_commission_mode": "fixed", // 已废弃
|
||||
"one_time_commission_value": 5000, // 已废弃
|
||||
"enable_force_recharge": false, // 已废弃
|
||||
"force_recharge_amount": 0 // 已废弃
|
||||
}
|
||||
```
|
||||
|
||||
**替代方案**:一次性佣金规则现在在**套餐系列**中配置,系列分配只需设置 `one_time_commission_amount`。
|
||||
|
||||
#### 已废弃的响应字段
|
||||
|
||||
系列分配响应中不再返回以下字段:
|
||||
- `one_time_commission_type`
|
||||
- `one_time_commission_trigger`
|
||||
- `one_time_commission_threshold`
|
||||
- `one_time_commission_mode`
|
||||
- `one_time_commission_value`
|
||||
- `enable_force_recharge`
|
||||
- `force_recharge_amount`
|
||||
- `force_recharge_trigger_type`
|
||||
- `one_time_commission_tiers`(完整梯度配置)
|
||||
|
||||
---
|
||||
|
||||
## 二、套餐接口变更
|
||||
|
||||
### 2.1 创建套餐 `POST /api/admin/packages`
|
||||
|
||||
**❌ 移除字段**:
|
||||
```json
|
||||
{
|
||||
"price": 9900, // 已移除
|
||||
"data_type": "real", // 已移除
|
||||
"data_amount_mb": 1024 // 已移除
|
||||
}
|
||||
```
|
||||
|
||||
**✅ 新增字段**:
|
||||
```json
|
||||
{
|
||||
"enable_virtual_data": true, // 是否启用虚流量
|
||||
"real_data_mb": 1024, // 真流量额度(MB) - 必填
|
||||
"virtual_data_mb": 512, // 虚流量额度(MB) - 启用虚流量时必填
|
||||
"cost_price": 5000 // 成本价(分) - 必填
|
||||
}
|
||||
```
|
||||
|
||||
**完整请求示例**:
|
||||
```json
|
||||
{
|
||||
"package_code": "PKG_001",
|
||||
"package_name": "月度套餐",
|
||||
"series_id": 1,
|
||||
"package_type": "formal",
|
||||
"duration_months": 1,
|
||||
"real_data_mb": 1024,
|
||||
"virtual_data_mb": 512,
|
||||
"enable_virtual_data": true,
|
||||
"cost_price": 5000,
|
||||
"suggested_retail_price": 9900
|
||||
}
|
||||
```
|
||||
|
||||
**校验规则**:
|
||||
- 启用虚流量时 (`enable_virtual_data: true`):
|
||||
- `virtual_data_mb` 必须 > 0
|
||||
- `virtual_data_mb` 必须 ≤ `real_data_mb`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 更新套餐 `PUT /api/admin/packages/:id`
|
||||
|
||||
字段变更同上,所有字段均为可选。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 套餐列表/详情响应变更
|
||||
|
||||
**✅ 新增字段**(代理用户可见):
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"package_code": "PKG_001",
|
||||
"package_name": "月度套餐",
|
||||
"real_data_mb": 1024,
|
||||
"virtual_data_mb": 512,
|
||||
"enable_virtual_data": true,
|
||||
"cost_price": 5000,
|
||||
"suggested_retail_price": 9900,
|
||||
|
||||
// 以下字段仅代理用户可见
|
||||
"one_time_commission_amount": 1000, // 该代理能拿到的一次性佣金(分)
|
||||
"profit_margin": 4900, // 利润空间(分)
|
||||
"current_commission_rate": "5.00元/单",
|
||||
"tier_info": {
|
||||
"current_rate": "5.00元/单",
|
||||
"next_threshold": 100,
|
||||
"next_rate": "8.00元/单"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `cost_price`: 对于平台/平台用户是基础成本价,对于代理用户是该代理的成本价(从分配关系中获取)
|
||||
- `one_time_commission_amount`: 该代理能拿到的一次性佣金金额
|
||||
|
||||
---
|
||||
|
||||
## 三、套餐系列接口变更
|
||||
|
||||
### 3.1 创建/更新套餐系列
|
||||
|
||||
**✅ 新增嵌套结构 `one_time_commission_config`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"series_code": "SERIES_001",
|
||||
"series_name": "标准套餐系列",
|
||||
"description": "包含所有标准流量套餐",
|
||||
"one_time_commission_config": {
|
||||
"enable": true,
|
||||
"trigger_type": "first_recharge",
|
||||
"threshold": 10000,
|
||||
"commission_type": "fixed",
|
||||
"commission_amount": 5000,
|
||||
"validity_type": "permanent",
|
||||
"validity_value": "",
|
||||
"enable_force_recharge": false,
|
||||
"force_calc_type": "fixed",
|
||||
"force_amount": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `enable` | boolean | 是否启用一次性佣金 |
|
||||
| `trigger_type` | string | 触发类型: `first_recharge`(首充) / `accumulated_recharge`(累计充值) |
|
||||
| `threshold` | int64 | 触发阈值(分) |
|
||||
| `commission_type` | string | 佣金类型: `fixed`(固定) / `tiered`(梯度) |
|
||||
| `commission_amount` | int64 | 固定佣金金额(分),`commission_type=fixed` 时使用 |
|
||||
| `validity_type` | string | 时效类型: `permanent`(永久) / `fixed_date`(固定日期) / `relative`(相对时长) |
|
||||
| `validity_value` | string | 时效值: 日期(2026-12-31) 或 月数(12) |
|
||||
| `enable_force_recharge` | boolean | 是否启用强充 |
|
||||
| `force_calc_type` | string | 强充计算类型: `fixed`(固定) / `dynamic`(动态) |
|
||||
| `force_amount` | int64 | 强充金额(分),`force_calc_type=fixed` 时使用 |
|
||||
|
||||
---
|
||||
|
||||
## 四、系列分配接口变更
|
||||
|
||||
### 4.1 创建系列分配 `POST /api/admin/shop-series-allocations`
|
||||
|
||||
**❌ 移除字段**(旧接口中的一次性佣金完整配置):
|
||||
```json
|
||||
{
|
||||
"enable_one_time_commission": true,
|
||||
"one_time_commission_type": "fixed",
|
||||
"one_time_commission_trigger": "single_recharge",
|
||||
"one_time_commission_threshold": 10000,
|
||||
"one_time_commission_mode": "fixed",
|
||||
"one_time_commission_value": 5000,
|
||||
"enable_force_recharge": false,
|
||||
"force_recharge_amount": 0
|
||||
}
|
||||
```
|
||||
|
||||
**✅ 新增字段**:
|
||||
```json
|
||||
{
|
||||
"shop_id": 10,
|
||||
"series_id": 1,
|
||||
"base_commission": {
|
||||
"mode": "fixed",
|
||||
"value": 500
|
||||
},
|
||||
"one_time_commission_amount": 5000 // 给被分配店铺的一次性佣金金额(分)
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 一次性佣金的规则(触发条件、阈值、时效等)现在在**套餐系列**中统一配置
|
||||
- 系列分配只需要设置**给下级的金额**
|
||||
|
||||
---
|
||||
|
||||
### 4.2 系列分配响应
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"shop_id": 10,
|
||||
"shop_name": "测试店铺",
|
||||
"series_id": 1,
|
||||
"series_name": "标准套餐系列",
|
||||
"allocator_shop_id": 5,
|
||||
"allocator_shop_name": "上级店铺",
|
||||
"base_commission": {
|
||||
"mode": "fixed",
|
||||
"value": 500
|
||||
},
|
||||
"one_time_commission_amount": 5000,
|
||||
"status": 1,
|
||||
"created_at": "2026-02-03T10:00:00Z",
|
||||
"updated_at": "2026-02-03T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、套餐分配接口变更
|
||||
|
||||
### 5.1 创建/更新套餐分配
|
||||
|
||||
**✅ 新增字段**:
|
||||
```json
|
||||
{
|
||||
"shop_id": 10,
|
||||
"package_id": 1,
|
||||
"cost_price": 6000,
|
||||
"one_time_commission_amount": 3000 // 给下级的一次性佣金金额(分)
|
||||
}
|
||||
```
|
||||
|
||||
**校验规则**:
|
||||
- `one_time_commission_amount` 必须 ≥ 0
|
||||
- `one_time_commission_amount` 不能超过上级能拿到的金额
|
||||
- 平台用户不受金额限制
|
||||
|
||||
---
|
||||
|
||||
### 5.2 套餐分配响应
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"shop_id": 10,
|
||||
"shop_name": "下级店铺",
|
||||
"package_id": 1,
|
||||
"package_name": "月度套餐",
|
||||
"package_code": "PKG_001",
|
||||
"allocation_id": 5,
|
||||
"cost_price": 6000,
|
||||
"calculated_cost_price": 5500,
|
||||
"one_time_commission_amount": 3000,
|
||||
"status": 1,
|
||||
"created_at": "2026-02-03T10:00:00Z",
|
||||
"updated_at": "2026-02-03T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、一次性佣金链式分配说明
|
||||
|
||||
### 6.1 概念
|
||||
|
||||
```
|
||||
平台设置系列一次性佣金规则:首充 100 元返 50 元
|
||||
|
||||
平台 → 一级代理 A(给 A 设置 40 元)
|
||||
↓
|
||||
一级代理 A → 二级代理 B(给 B 设置 25 元)
|
||||
↓
|
||||
二级代理 B → 三级代理 C(给 C 设置 10 元)
|
||||
```
|
||||
|
||||
当三级代理 C 的客户首充 100 元时:
|
||||
- 三级代理 C 获得: 10 元
|
||||
- 二级代理 B 获得: 25 - 10 = 15 元
|
||||
- 一级代理 A 获得: 40 - 25 = 15 元
|
||||
- 平台获得: 50 - 40 = 10 元
|
||||
|
||||
### 6.2 前端展示建议
|
||||
|
||||
在分配界面展示:
|
||||
- "上级能拿到的一次性佣金: 40 元"
|
||||
- "给下级设置的一次性佣金: [输入框,最大 40 元]"
|
||||
- "自己实际获得: [自动计算] 元"
|
||||
|
||||
---
|
||||
|
||||
## 七、枚举值参考
|
||||
|
||||
### 触发类型 (trigger_type)
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `first_recharge` | 首充触发 |
|
||||
| `accumulated_recharge` | 累计充值触发 |
|
||||
|
||||
### 佣金类型 (commission_type)
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `fixed` | 固定金额 |
|
||||
| `tiered` | 梯度(根据销量/销售额) |
|
||||
|
||||
### 时效类型 (validity_type)
|
||||
| 值 | 说明 | validity_value 格式 |
|
||||
|----|------|---------------------|
|
||||
| `permanent` | 永久有效 | 空 |
|
||||
| `fixed_date` | 固定到期日 | `2026-12-31` |
|
||||
| `relative` | 相对时长(激活后N月) | `12` |
|
||||
|
||||
### 强充计算类型 (force_calc_type)
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `fixed` | 固定金额 |
|
||||
| `dynamic` | 动态计算(max(首充要求, 套餐售价)) |
|
||||
|
||||
---
|
||||
|
||||
## 八、迁移检查清单
|
||||
|
||||
### 🔴 必须删除的代码
|
||||
|
||||
**请搜索并删除以下内容:**
|
||||
|
||||
```bash
|
||||
# 搜索废弃的枚举值
|
||||
grep -r "single_recharge" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.vue"
|
||||
|
||||
# 搜索废弃的字段名
|
||||
grep -r "one_time_commission_type\|one_time_commission_trigger\|one_time_commission_threshold\|one_time_commission_mode\|one_time_commission_value\|enable_one_time_commission\|force_recharge_amount\|enable_force_recharge" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.vue"
|
||||
```
|
||||
|
||||
### 套餐管理页面
|
||||
- [ ] 移除 `price`、`data_type`、`data_amount_mb` 字段
|
||||
- [ ] 新增 `enable_virtual_data` 开关
|
||||
- [ ] 新增虚流量校验逻辑(≤ 真流量)
|
||||
- [ ] 代理视角显示 `one_time_commission_amount`
|
||||
|
||||
### 套餐系列管理页面
|
||||
- [ ] 新增一次性佣金规则配置表单
|
||||
- [ ] 支持时效类型选择和值输入
|
||||
- [ ] 触发类型使用 `first_recharge`(不是旧的 `single_recharge`)
|
||||
|
||||
### 系列分配页面
|
||||
- [ ] **删除**旧的一次性佣金完整配置表单(8个字段)
|
||||
- [ ] **删除**梯度配置表单
|
||||
- [ ] 新增 `one_time_commission_amount` 输入(金额字段)
|
||||
- [ ] 显示上级能拿到的最大金额作为输入上限
|
||||
|
||||
### 套餐分配页面
|
||||
- [ ] 新增 `one_time_commission_amount` 输入
|
||||
- [ ] 显示校验错误(超过上级金额限制)
|
||||
|
||||
### 全局检查
|
||||
- [ ] 将所有 `single_recharge` 替换为 `first_recharge`
|
||||
- [ ] 移除系列分配相关的废弃字段引用
|
||||
- [ ] 更新 TypeScript 类型定义
|
||||
|
||||
---
|
||||
|
||||
## 九、联系方式
|
||||
|
||||
如有疑问,请联系后端开发团队。
|
||||
386
docs/workflow-optimization/方案总览.md
Normal file
386
docs/workflow-optimization/方案总览.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# 工作流优化方案
|
||||
|
||||
## 一、背景与问题
|
||||
|
||||
### 1.1 当前痛点
|
||||
|
||||
| 痛点 | 根因 | 影响 |
|
||||
|------|------|------|
|
||||
| 讨论 → 提案不一致 | 共识没有被"锁定" | AI 理解偏差,提案与讨论方案不同 |
|
||||
| 提案 → 实现不一致 | 约束没有被"强制执行" | 实现细节偏离设计 |
|
||||
| 后置测试浪费时间 | 测试从实现反推 | 测试乱写、调试时间长 |
|
||||
| 单测意义不大 | 测试实现细节而非行为 | 重构就挂,维护成本高 |
|
||||
| 频繁重构 | 问题发现太晚 | 大量返工(17次/100提交) |
|
||||
|
||||
### 1.2 数据支撑
|
||||
|
||||
- **重构提交**: 17 次(近期约 100 次提交中)
|
||||
- **典型完成率**: 75%(Shop Package Allocation: 91/121 tasks)
|
||||
- **未完成原因**: 测试("低优先级,需要运行环境")
|
||||
- **TODO 残留**: 10+ 个(代码中待完成的功能)
|
||||
|
||||
---
|
||||
|
||||
## 二、解决方案概览
|
||||
|
||||
### 2.1 核心理念变化
|
||||
|
||||
```
|
||||
旧工作流:
|
||||
discuss → proposal → design → tasks → implement → test → verify
|
||||
↑ 测试后置
|
||||
问题发现太晚
|
||||
|
||||
新工作流:
|
||||
discuss → 锁定共识 → proposal → 验证 → design → 验证 →
|
||||
生成验收测试 → 实现(测试驱动)→ 验证 → 归档
|
||||
↑ ↑
|
||||
测试从 spec 生成 实现时对照测试
|
||||
```
|
||||
|
||||
### 2.2 新增机制
|
||||
|
||||
| 机制 | 解决的问题 | 实现方式 |
|
||||
|------|-----------|---------|
|
||||
| **共识锁定** | 讨论→提案不一致 | `consensus.md` + 用户确认 |
|
||||
| **验收测试先行** | 测试后置浪费时间 | 从 Spec 生成测试,实现前运行 |
|
||||
| **业务流程测试** | 跨 API 场景验证 | 从 Business Flow 生成测试 |
|
||||
| **中间验证** | 问题发现太晚 | 每个 artifact 后自动验证 |
|
||||
| **约束检查** | 实现偏离设计 | 实现时对照约束清单 |
|
||||
|
||||
---
|
||||
|
||||
## 三、新工作流详解
|
||||
|
||||
### 3.1 完整流程图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 1: 探索 & 锁定共识 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ /opsx:explore │ ──▶ │ 讨论并确认共识 │ ──▶ │ consensus.md │ │
|
||||
│ └──────────────┘ │ AI 输出共识摘要 │ │ 用户确认后锁定 │ │
|
||||
│ │ 用户逐条确认 ✓ │ └─────────────────┘ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ 输出: openspec/changes/<name>/consensus.md (用户签字确认版) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 2: 生成提案 & 验证 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 读取 consensus │ ──▶ │ 生成 proposal.md │ ──▶ │ 自动验证 │ │
|
||||
│ └──────────────┘ │ 必须覆盖共识要点 │ │ proposal 与 │ │
|
||||
│ └──────────────────────┘ │ consensus 对齐 │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ 验证: 共识中的每个"要做什么"都在 proposal 的 Capabilities 中出现 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 3: 生成 Spec │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 生成 spec.md │ ──▶ │ 包含两部分: │ │
|
||||
│ │ │ │ 1. Scenarios │ │
|
||||
│ │ │ │ 2. Business Flows │ │
|
||||
│ └──────────────┘ └──────────────────────┘ │
|
||||
│ │
|
||||
│ Scenario: 单 API 的输入输出契约 │
|
||||
│ Business Flow: 多 API 组合的业务场景 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 4: 生成测试(关键变化!) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ /opsx:gen-tests │ ──▶ │ 生成两类测试: │ ──▶ │ 运行测试 │ │
|
||||
│ └──────────────┘ │ 1. 验收测试 │ │ 预期全部 FAIL │ │
|
||||
│ │ 2. 流程测试 │ │ ← 证明测试有效 │ │
|
||||
│ └──────────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ 输出: │
|
||||
│ - tests/acceptance/{capability}_acceptance_test.go │
|
||||
│ - tests/flows/{capability}_{flow}_flow_test.go │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 5: 设计 & 实现(测试驱动) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 生成 design │ ──▶ │ 生成 tasks.md │ ──▶ │ 实现每个 task │ │
|
||||
│ │ + 约束清单 │ │ 每个 task 关联测试 │ │ 运行对应测试 │ │
|
||||
│ └──────────────┘ └──────────────────────┘ │ 测试通过才继续 │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ 实现循环: │
|
||||
│ for each task: │
|
||||
│ 1. 运行关联的测试 (预期 FAIL) │
|
||||
│ 2. 实现代码 │
|
||||
│ 3. 运行测试 (预期 PASS) │
|
||||
│ 4. 测试通过 → 标记 task 完成 │
|
||||
│ 5. 测试失败 → 修复代码,重复步骤 3 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 6: 最终验证 & 归档 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 运行全部测试 │ ──▶ │ 生成完成报告 │ ──▶ │ 归档 change │ │
|
||||
│ │ 必须 100% PASS │ │ 包含测试覆盖证据 │ └─────────────────┘ │
|
||||
│ └──────────────┘ └──────────────────────┘ │
|
||||
│ │
|
||||
│ 完成报告必须包含: │
|
||||
│ - 验收测试通过截图/日志 │
|
||||
│ - 流程测试通过截图/日志 │
|
||||
│ - 每个 Scenario/Flow 的测试对应关系 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 命令对照表
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `/opsx:explore` | 探索讨论 |
|
||||
| 2 | `/opsx:lock <name>` | **新** 锁定共识 |
|
||||
| 3 | `/opsx:new <name>` | 创建 change(自动读取 consensus) |
|
||||
| 4 | `/opsx:continue` | 生成 proposal |
|
||||
| 5 | `/opsx:continue` | 生成 spec |
|
||||
| 6 | `/opsx:gen-tests` | **新** 生成验收测试和流程测试 |
|
||||
| 7 | `/opsx:continue` | 生成 design |
|
||||
| 8 | `/opsx:continue` | 生成 tasks |
|
||||
| 9 | `/opsx:apply` | 测试驱动实现 |
|
||||
| 10 | `/opsx:verify` | 验证 |
|
||||
| 11 | `/opsx:archive` | 归档 |
|
||||
|
||||
---
|
||||
|
||||
## 四、测试体系重设计
|
||||
|
||||
### 4.1 新测试金字塔
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ E2E 测试 │ ← 手动/自动化 UI(很少)
|
||||
│ │
|
||||
─┴─────────────┴─
|
||||
┌─────────────────┐
|
||||
│ 业务流程测试 │ ← 新增!多 API 组合
|
||||
│ tests/flows/ │ 验证业务场景完整性
|
||||
─┴─────────────────┴─
|
||||
┌─────────────────────┐
|
||||
│ 验收测试 │ ← 新增!从 Spec Scenario 生成
|
||||
│ tests/acceptance/ │ 单 API 契约验证
|
||||
─┴─────────────────────┴─
|
||||
┌───────────────────────────┐
|
||||
│ 集成冒烟测试 │ ← 保留
|
||||
│ tests/integration/ │
|
||||
─┴───────────────────────────┴─
|
||||
┌─────────────────────────────────┐
|
||||
│ 单元测试 (精简!) │ ← 大幅减少
|
||||
│ tests/unit/ │ 仅复杂逻辑
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 三层测试体系
|
||||
|
||||
| 层级 | 测试类型 | 来源 | 验证什么 | 位置 |
|
||||
|------|---------|------|---------|------|
|
||||
| **L1** | 验收测试 | Spec Scenario | 单 API 契约 | `tests/acceptance/` |
|
||||
| **L2** | 流程测试 | Spec Business Flow | 业务场景完整性 | `tests/flows/` |
|
||||
| **L3** | 单元测试 | 复杂逻辑 | 算法/规则正确性 | `tests/unit/` |
|
||||
|
||||
### 4.3 测试比例调整
|
||||
|
||||
| 测试类型 | 旧占比 | 新占比 | 变化 |
|
||||
|---------|-------|-------|------|
|
||||
| 验收测试 | 0% | **30%** | 新增 |
|
||||
| 流程测试 | 0% | **15%** | 新增 |
|
||||
| 集成测试 | 28% | 25% | 略减 |
|
||||
| 单元测试 | 72% | **30%** | 大幅减少 |
|
||||
|
||||
### 4.4 单元测试精简规则
|
||||
|
||||
**保留**:
|
||||
- ✅ 纯函数(计费计算、分佣算法)
|
||||
- ✅ 状态机(订单状态流转)
|
||||
- ✅ 复杂业务规则(层级校验、权限计算)
|
||||
- ✅ 边界条件(时间、金额、精度)
|
||||
|
||||
**删除/不再写**:
|
||||
- ❌ 简单 CRUD(已被验收测试覆盖)
|
||||
- ❌ DTO 转换
|
||||
- ❌ 配置读取
|
||||
- ❌ 重复测试同一逻辑
|
||||
|
||||
---
|
||||
|
||||
## 五、Spec 模板更新
|
||||
|
||||
### 5.1 新 Spec 结构
|
||||
|
||||
```markdown
|
||||
# {capability} Specification
|
||||
|
||||
## Purpose
|
||||
{简要描述这个能力的目的}
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: {requirement-name}
|
||||
{详细描述}
|
||||
|
||||
#### Scenario: {scenario-name}
|
||||
- **GIVEN** {前置条件}
|
||||
- **WHEN** {触发动作}
|
||||
- **THEN** {预期结果}
|
||||
- **AND** {额外验证}
|
||||
|
||||
---
|
||||
|
||||
## Business Flows(新增必填部分)
|
||||
|
||||
### Flow: {flow-name}
|
||||
|
||||
**参与者**: {角色1}, {角色2}, ...
|
||||
|
||||
**前置条件**:
|
||||
- {条件1}
|
||||
- {条件2}
|
||||
|
||||
**流程步骤**:
|
||||
|
||||
1. **{步骤名称}**
|
||||
- 角色: {执行角色}
|
||||
- 调用: {HTTP Method} {Path}
|
||||
- 输入: {关键参数}
|
||||
- 预期: {预期结果}
|
||||
- 验证: {数据库/缓存状态变化}
|
||||
|
||||
2. **{下一步骤}**
|
||||
...
|
||||
|
||||
**流程图**:
|
||||
```
|
||||
[角色A] ──创建──▶ [资源] ──分配──▶ [角色B可见] ──使用──▶ [状态变更]
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- [ ] {验证点1}
|
||||
- [ ] {验证点2}
|
||||
- [ ] 数据一致性: {描述}
|
||||
|
||||
**异常流程**:
|
||||
- 如果 {条件}: 预期 {结果}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、文件结构
|
||||
|
||||
### 6.1 测试目录
|
||||
|
||||
```
|
||||
tests/
|
||||
├── acceptance/ # 验收测试(单 API)
|
||||
│ ├── account_acceptance_test.go
|
||||
│ ├── package_acceptance_test.go
|
||||
│ ├── iot_card_acceptance_test.go
|
||||
│ └── README.md
|
||||
├── flows/ # 业务流程测试(多 API)
|
||||
│ ├── package_lifecycle_flow_test.go
|
||||
│ ├── order_purchase_flow_test.go
|
||||
│ ├── commission_settlement_flow_test.go
|
||||
│ └── README.md
|
||||
├── integration/ # 集成测试(保留)
|
||||
│ └── ...
|
||||
├── unit/ # 单元测试(精简)
|
||||
│ └── ...
|
||||
└── testutils/
|
||||
└── integ/
|
||||
└── integration.go
|
||||
```
|
||||
|
||||
### 6.2 OpenSpec 目录
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── config.yaml # 更新:增加测试规则
|
||||
├── changes/
|
||||
│ └── <change-name>/
|
||||
│ ├── consensus.md # 新增:共识确认单
|
||||
│ ├── proposal.md
|
||||
│ ├── design.md
|
||||
│ ├── tasks.md
|
||||
│ └── specs/
|
||||
│ └── <capability>/
|
||||
│ └── spec.md # 更新:包含 Business Flows
|
||||
└── specs/
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实施计划
|
||||
|
||||
### Phase 1: 基础设施(2-3 天)
|
||||
|
||||
1. 创建目录结构
|
||||
2. 更新 `openspec/config.yaml`
|
||||
3. 创建 `openspec-lock-consensus` skill
|
||||
4. 创建 `openspec-generate-acceptance-tests` skill
|
||||
5. 更新 `AGENTS.md` 测试规范
|
||||
6. 创建 `tests/acceptance/README.md`
|
||||
7. 创建 `tests/flows/README.md`
|
||||
|
||||
### Phase 2: 试点(1 周)
|
||||
|
||||
选择一个新 feature 完整走一遍新流程:
|
||||
1. 验证共识锁定机制
|
||||
2. 验证测试生成
|
||||
3. 验证测试驱动实现
|
||||
4. 收集反馈,调整流程
|
||||
|
||||
### Phase 3: 推广(持续)
|
||||
|
||||
1. 新 feature 强制使用新流程
|
||||
2. 现有高价值测试迁移为验收测试
|
||||
3. 清理低价值单元测试
|
||||
4. 建立测试覆盖率追踪
|
||||
|
||||
---
|
||||
|
||||
## 八、预期收益
|
||||
|
||||
| 指标 | 当前 | 预期 |
|
||||
|------|------|------|
|
||||
| 讨论→提案一致率 | ~60% | >95% |
|
||||
| 提案→实现一致率 | ~70% | >95% |
|
||||
| 测试编写时间 | 实现后补,耗时长 | 实现前生成,自动化 |
|
||||
| 测试有效性 | 很多无效测试 | 每个测试有破坏点 |
|
||||
| 重构频率 | 高(17次/100提交) | 低(问题早发现) |
|
||||
| 单测维护成本 | 高(重构就挂) | 低(只测行为) |
|
||||
| 业务流程正确性 | 无保证 | 流程测试覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 九、相关文档
|
||||
|
||||
- [验收测试说明](../../tests/acceptance/README.md)
|
||||
- [流程测试说明](../../tests/flows/README.md)
|
||||
- [共识锁定 Skill](.opencode/skills/openspec-lock-consensus/SKILL.md)
|
||||
- [测试生成 Skill](.opencode/skills/openspec-generate-acceptance-tests/SKILL.md)
|
||||
- [AGENTS.md 测试规范](../../AGENTS.md#测试要求)
|
||||
@@ -72,7 +72,7 @@ type services struct {
|
||||
}
|
||||
|
||||
func initServices(s *stores, deps *Dependencies) *services {
|
||||
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopSeriesAllocation)
|
||||
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package)
|
||||
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
|
||||
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
|
||||
|
||||
@@ -91,8 +91,9 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.DB,
|
||||
s.CommissionRecord,
|
||||
s.Shop,
|
||||
s.ShopPackageAllocation,
|
||||
s.ShopSeriesAllocation,
|
||||
s.ShopSeriesOneTimeCommissionTier,
|
||||
s.PackageSeries,
|
||||
s.IotCard,
|
||||
s.Device,
|
||||
s.Wallet,
|
||||
@@ -100,6 +101,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.Order,
|
||||
s.OrderItem,
|
||||
s.Package,
|
||||
s.ShopSeriesCommissionStats,
|
||||
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||
deps.Logger,
|
||||
),
|
||||
@@ -108,21 +110,21 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
|
||||
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
|
||||
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
|
||||
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger),
|
||||
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger),
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
||||
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation, s.PackageSeries),
|
||||
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries),
|
||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||
Carrier: carrierSvc.New(s.Carrier),
|
||||
PackageSeries: packageSeriesSvc.New(s.PackageSeries),
|
||||
Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation),
|
||||
ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesOneTimeCommissionTier, s.Shop, s.PackageSeries, s.Package),
|
||||
ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionStats, s.Shop),
|
||||
Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation),
|
||||
ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopPackageAllocation, s.Shop, s.PackageSeries),
|
||||
ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop),
|
||||
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, 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),
|
||||
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
||||
Recharge: rechargeSvc.New(deps.DB, s.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ type stores struct {
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Package *postgres.PackageStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
ShopSeriesOneTimeCommissionTier *postgres.ShopSeriesOneTimeCommissionTierStore
|
||||
ShopSeriesAllocationConfig *postgres.ShopSeriesAllocationConfigStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
ShopPackageAllocationPriceHistory *postgres.ShopPackageAllocationPriceHistoryStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
@@ -73,8 +71,6 @@ func initStores(deps *Dependencies) *stores {
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
ShopSeriesOneTimeCommissionTier: postgres.NewShopSeriesOneTimeCommissionTierStore(deps.DB),
|
||||
ShopSeriesAllocationConfig: postgres.NewShopSeriesAllocationConfigStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
ShopPackageAllocationPriceHistory: postgres.NewShopPackageAllocationPriceHistoryStore(deps.DB),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
|
||||
@@ -36,7 +36,7 @@ func (h *ShopSeriesAllocationHandler) Create(c *fiber.Ctx) error {
|
||||
func (h *ShopSeriesAllocationHandler) Get(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的店铺系列分配 ID")
|
||||
return errors.New(errors.CodeInvalidParam, "无效的系列分配 ID")
|
||||
}
|
||||
|
||||
allocation, err := h.service.Get(c.UserContext(), uint(id))
|
||||
@@ -50,7 +50,7 @@ func (h *ShopSeriesAllocationHandler) Get(c *fiber.Ctx) error {
|
||||
func (h *ShopSeriesAllocationHandler) Update(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的店铺系列分配 ID")
|
||||
return errors.New(errors.CodeInvalidParam, "无效的系列分配 ID")
|
||||
}
|
||||
|
||||
var req dto.UpdateShopSeriesAllocationRequest
|
||||
@@ -69,7 +69,7 @@ func (h *ShopSeriesAllocationHandler) Update(c *fiber.Ctx) error {
|
||||
func (h *ShopSeriesAllocationHandler) Delete(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的店铺系列分配 ID")
|
||||
return errors.New(errors.CodeInvalidParam, "无效的系列分配 ID")
|
||||
}
|
||||
|
||||
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
|
||||
@@ -92,21 +92,3 @@ func (h *ShopSeriesAllocationHandler) List(c *fiber.Ctx) error {
|
||||
|
||||
return response.SuccessWithPagination(c, allocations, total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
func (h *ShopSeriesAllocationHandler) UpdateStatus(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的店铺系列分配 ID")
|
||||
}
|
||||
|
||||
var req dto.UpdateStatusRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
if err := h.service.UpdateStatus(c.UserContext(), uint(id), req.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -11,26 +13,126 @@ import (
|
||||
// 通过 shop_id 区分所有权:NULL=平台库存,有值=店铺所有
|
||||
type Device struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceNo string `gorm:"column:device_no;type:varchar(100);uniqueIndex:idx_device_no,where:deleted_at IS NULL;not null;comment:设备编号(唯一标识)" json:"device_no"`
|
||||
DeviceName string `gorm:"column:device_name;type:varchar(255);comment:设备名称" json:"device_name"`
|
||||
DeviceModel string `gorm:"column:device_model;type:varchar(100);comment:设备型号" json:"device_model"`
|
||||
DeviceType string `gorm:"column:device_type;type:varchar(50);comment:设备类型" json:"device_type"`
|
||||
MaxSimSlots int `gorm:"column:max_sim_slots;type:int;default:4;comment:最大插槽数量(默认4)" json:"max_sim_slots"`
|
||||
Manufacturer string `gorm:"column:manufacturer;type:varchar(255);comment:制造商" json:"manufacturer"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台库存,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
DeviceUsername string `gorm:"column:device_username;type:varchar(100);comment:设备登录用户名" json:"device_username"`
|
||||
DevicePasswordEncrypted string `gorm:"column:device_password_encrypted;type:varchar(255);comment:设备登录密码(加密)" json:"device_password_encrypted"`
|
||||
DeviceAPIEndpoint string `gorm:"column:device_api_endpoint;type:varchar(500);comment:设备API端点" json:"device_api_endpoint"`
|
||||
SeriesID *uint `gorm:"column:series_id;index;comment:套餐系列ID(关联PackageSeries)" json:"series_id,omitempty"`
|
||||
FirstCommissionPaid bool `gorm:"column:first_commission_paid;type:boolean;default:false;comment:一次性佣金是否已发放" json:"first_commission_paid"`
|
||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分)" json:"accumulated_recharge"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceNo string `gorm:"column:device_no;type:varchar(100);uniqueIndex:idx_device_no,where:deleted_at IS NULL;not null;comment:设备编号(唯一标识)" json:"device_no"`
|
||||
DeviceName string `gorm:"column:device_name;type:varchar(255);comment:设备名称" json:"device_name"`
|
||||
DeviceModel string `gorm:"column:device_model;type:varchar(100);comment:设备型号" json:"device_model"`
|
||||
DeviceType string `gorm:"column:device_type;type:varchar(50);comment:设备类型" json:"device_type"`
|
||||
MaxSimSlots int `gorm:"column:max_sim_slots;type:int;default:4;comment:最大插槽数量(默认4)" json:"max_sim_slots"`
|
||||
Manufacturer string `gorm:"column:manufacturer;type:varchar(255);comment:制造商" json:"manufacturer"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台库存,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
DeviceUsername string `gorm:"column:device_username;type:varchar(100);comment:设备登录用户名" json:"device_username"`
|
||||
DevicePasswordEncrypted string `gorm:"column:device_password_encrypted;type:varchar(255);comment:设备登录密码(加密)" json:"device_password_encrypted"`
|
||||
DeviceAPIEndpoint string `gorm:"column:device_api_endpoint;type:varchar(500);comment:设备API端点" json:"device_api_endpoint"`
|
||||
SeriesID *uint `gorm:"column:series_id;index;comment:套餐系列ID(关联PackageSeries)" json:"series_id,omitempty"`
|
||||
FirstCommissionPaid bool `gorm:"column:first_commission_paid;type:boolean;default:false;comment:一次性佣金是否已发放(废弃,使用按系列追踪)" json:"first_commission_paid"`
|
||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
|
||||
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
|
||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Device) TableName() string {
|
||||
return "tb_device"
|
||||
}
|
||||
|
||||
func (d *Device) GetAccumulatedRechargeBySeriesMap() (map[uint]int64, error) {
|
||||
result := make(map[uint]int64)
|
||||
if d.AccumulatedRechargeBySeriesJSON == "" || d.AccumulatedRechargeBySeriesJSON == "{}" {
|
||||
return result, nil
|
||||
}
|
||||
var raw map[string]int64
|
||||
if err := json.Unmarshal([]byte(d.AccumulatedRechargeBySeriesJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range raw {
|
||||
id, err := strconv.ParseUint(k, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[uint(id)] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Device) SetAccumulatedRechargeBySeriesMap(m map[uint]int64) error {
|
||||
raw := make(map[string]int64)
|
||||
for k, v := range m {
|
||||
raw[strconv.FormatUint(uint64(k), 10)] = v
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.AccumulatedRechargeBySeriesJSON = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Device) GetAccumulatedRechargeBySeries(seriesID uint) int64 {
|
||||
m, err := d.GetAccumulatedRechargeBySeriesMap()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return m[seriesID]
|
||||
}
|
||||
|
||||
func (d *Device) AddAccumulatedRechargeBySeries(seriesID uint, amount int64) error {
|
||||
m, err := d.GetAccumulatedRechargeBySeriesMap()
|
||||
if err != nil {
|
||||
m = make(map[uint]int64)
|
||||
}
|
||||
m[seriesID] += amount
|
||||
return d.SetAccumulatedRechargeBySeriesMap(m)
|
||||
}
|
||||
|
||||
func (d *Device) GetFirstRechargeTriggeredBySeriesMap() (map[uint]bool, error) {
|
||||
result := make(map[uint]bool)
|
||||
if d.FirstRechargeTriggeredBySeriesJSON == "" || d.FirstRechargeTriggeredBySeriesJSON == "{}" {
|
||||
return result, nil
|
||||
}
|
||||
var raw map[string]bool
|
||||
if err := json.Unmarshal([]byte(d.FirstRechargeTriggeredBySeriesJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range raw {
|
||||
id, err := strconv.ParseUint(k, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[uint(id)] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Device) SetFirstRechargeTriggeredBySeriesMap(m map[uint]bool) error {
|
||||
raw := make(map[string]bool)
|
||||
for k, v := range m {
|
||||
raw[strconv.FormatUint(uint64(k), 10)] = v
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.FirstRechargeTriggeredBySeriesJSON = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Device) IsFirstRechargeTriggeredBySeries(seriesID uint) bool {
|
||||
m, err := d.GetFirstRechargeTriggeredBySeriesMap()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m[seriesID]
|
||||
}
|
||||
|
||||
func (d *Device) SetFirstRechargeTriggeredBySeries(seriesID uint, triggered bool) error {
|
||||
m, err := d.GetFirstRechargeTriggeredBySeriesMap()
|
||||
if err != nil {
|
||||
m = make(map[uint]bool)
|
||||
}
|
||||
m[seriesID] = triggered
|
||||
return d.SetFirstRechargeTriggeredBySeriesMap(m)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@ package dto
|
||||
|
||||
// CreatePackageRequest 创建套餐请求
|
||||
type CreatePackageRequest struct {
|
||||
PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
DataType *string `json:"data_type" validate:"omitempty,oneof=real virtual" description:"流量类型 (real:真流量, virtual:虚流量)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
DataAmountMB *int64 `json:"data_amount_mb" validate:"omitempty,min=0" minimum:"0" description:"总流量额度(MB)"`
|
||||
Price int64 `json:"price" validate:"required,min=0" required:"true" minimum:"0" description:"套餐价格(分)"`
|
||||
SuggestedCostPrice *int64 `json:"suggested_cost_price" validate:"omitempty,min=0" minimum:"0" description:"建议成本价(分)"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"成本价(分)"`
|
||||
}
|
||||
|
||||
// UpdatePackageRequest 更新套餐请求
|
||||
@@ -22,13 +20,11 @@ type UpdatePackageRequest struct {
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
DataType *string `json:"data_type" validate:"omitempty,oneof=real virtual" description:"流量类型 (real:真流量, virtual:虚流量)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
DataAmountMB *int64 `json:"data_amount_mb" validate:"omitempty,min=0" minimum:"0" description:"总流量额度(MB)"`
|
||||
Price *int64 `json:"price" validate:"omitempty,min=0" minimum:"0" description:"套餐价格(分)"`
|
||||
SuggestedCostPrice *int64 `json:"suggested_cost_price" validate:"omitempty,min=0" minimum:"0" description:"建议成本价(分)"`
|
||||
EnableVirtualData *bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"成本价(分)"`
|
||||
}
|
||||
|
||||
// PackageListRequest 套餐列表请求
|
||||
@@ -61,28 +57,26 @@ type CommissionTierInfo struct {
|
||||
|
||||
// PackageResponse 套餐响应
|
||||
type PackageResponse struct {
|
||||
ID uint `json:"id" description:"套餐ID"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName *string `json:"series_name" description:"套餐系列名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" description:"套餐时长(月数)"`
|
||||
DataType string `json:"data_type" description:"流量类型 (real:真流量, virtual:虚流量)"`
|
||||
RealDataMB int64 `json:"real_data_mb" description:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `json:"virtual_data_mb" description:"虚流量额度(MB)"`
|
||||
DataAmountMB int64 `json:"data_amount_mb" description:"总流量额度(MB)"`
|
||||
Price int64 `json:"price" description:"套餐价格(分)"`
|
||||
SuggestedCostPrice int64 `json:"suggested_cost_price" description:"建议成本价(分)"`
|
||||
SuggestedRetailPrice int64 `json:"suggested_retail_price" description:"建议售价(分)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
CostPrice *int64 `json:"cost_price,omitempty" description:"成本价(分,仅代理用户可见)"`
|
||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||
ID uint `json:"id" description:"套餐ID"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName *string `json:"series_name" description:"套餐系列名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" description:"套餐时长(月数)"`
|
||||
RealDataMB int64 `json:"real_data_mb" description:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `json:"virtual_data_mb" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice int64 `json:"suggested_retail_price" description:"建议售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"一次性佣金金额(分,代理视角)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||
}
|
||||
|
||||
// UpdatePackageParams 更新套餐聚合参数
|
||||
|
||||
@@ -1,24 +1,50 @@
|
||||
package dto
|
||||
|
||||
// OneTimeCommissionTierDTO 一次性佣金梯度配置
|
||||
type OneTimeCommissionTierDTO struct {
|
||||
Dimension string `json:"dimension" validate:"required,oneof=sales_count sales_amount" required:"true" description:"统计维度 (sales_count:销量, sales_amount:销售额)"`
|
||||
StatScope string `json:"stat_scope" validate:"omitempty,oneof=self self_and_sub" description:"统计范围 (self:仅自己, self_and_sub:自己+下级)"`
|
||||
Threshold int64 `json:"threshold" validate:"required,min=0" required:"true" minimum:"0" description:"达标阈值"`
|
||||
Amount int64 `json:"amount" validate:"required,min=0" required:"true" minimum:"0" description:"佣金金额(分)"`
|
||||
}
|
||||
|
||||
// SeriesOneTimeCommissionConfigDTO 一次性佣金规则配置
|
||||
type SeriesOneTimeCommissionConfigDTO struct {
|
||||
Enable bool `json:"enable" description:"是否启用一次性佣金"`
|
||||
TriggerType string `json:"trigger_type" validate:"omitempty,oneof=first_recharge accumulated_recharge" description:"触发类型 (first_recharge:首充, accumulated_recharge:累计充值)"`
|
||||
Threshold int64 `json:"threshold" validate:"omitempty,min=0" minimum:"0" description:"触发阈值(分)"`
|
||||
CommissionType string `json:"commission_type" validate:"omitempty,oneof=fixed tiered" description:"佣金类型 (fixed:固定, tiered:梯度)"`
|
||||
CommissionAmount int64 `json:"commission_amount" validate:"omitempty,min=0" minimum:"0" description:"固定佣金金额(分),commission_type=fixed时使用"`
|
||||
Tiers []OneTimeCommissionTierDTO `json:"tiers" validate:"omitempty,dive" description:"梯度配置列表,commission_type=tiered时使用"`
|
||||
ValidityType string `json:"validity_type" validate:"omitempty,oneof=permanent fixed_date relative" description:"时效类型 (permanent:永久, fixed_date:固定日期, relative:相对时长)"`
|
||||
ValidityValue string `json:"validity_value" validate:"omitempty" description:"时效值(日期或月数)"`
|
||||
EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强充"`
|
||||
ForceCalcType string `json:"force_calc_type" validate:"omitempty,oneof=fixed dynamic" description:"强充计算类型 (fixed:固定, dynamic:动态)"`
|
||||
ForceAmount int64 `json:"force_amount" validate:"omitempty,min=0" minimum:"0" description:"强充金额(分)"`
|
||||
}
|
||||
|
||||
// CreatePackageSeriesRequest 创建套餐系列请求
|
||||
type CreatePackageSeriesRequest struct {
|
||||
SeriesCode string `json:"series_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"系列编码"`
|
||||
SeriesName string `json:"series_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"系列名称"`
|
||||
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"描述"`
|
||||
SeriesCode string `json:"series_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"系列编码"`
|
||||
SeriesName string `json:"series_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"系列名称"`
|
||||
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"描述"`
|
||||
OneTimeCommissionConfig *SeriesOneTimeCommissionConfigDTO `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金规则配置"`
|
||||
}
|
||||
|
||||
// UpdatePackageSeriesRequest 更新套餐系列请求
|
||||
type UpdatePackageSeriesRequest struct {
|
||||
SeriesName *string `json:"series_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"系列名称"`
|
||||
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"描述"`
|
||||
SeriesName *string `json:"series_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"系列名称"`
|
||||
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"描述"`
|
||||
OneTimeCommissionConfig *SeriesOneTimeCommissionConfigDTO `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金规则配置"`
|
||||
}
|
||||
|
||||
// PackageSeriesListRequest 套餐系列列表请求
|
||||
type PackageSeriesListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
SeriesName *string `json:"series_name" query:"series_name" validate:"omitempty,max=255" maxLength:"255" description:"系列名称(模糊搜索)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
SeriesName *string `json:"series_name" query:"series_name" validate:"omitempty,max=255" maxLength:"255" description:"系列名称(模糊搜索)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
EnableOneTimeCommission *bool `json:"enable_one_time_commission" query:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
}
|
||||
|
||||
// UpdatePackageSeriesStatusRequest 更新套餐系列状态请求
|
||||
@@ -28,13 +54,15 @@ type UpdatePackageSeriesStatusRequest struct {
|
||||
|
||||
// PackageSeriesResponse 套餐系列响应
|
||||
type PackageSeriesResponse struct {
|
||||
ID uint `json:"id" description:"系列ID"`
|
||||
SeriesCode string `json:"series_code" description:"系列编码"`
|
||||
SeriesName string `json:"series_name" description:"系列名称"`
|
||||
Description string `json:"description" description:"描述"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ID uint `json:"id" description:"系列ID"`
|
||||
SeriesCode string `json:"series_code" description:"系列编码"`
|
||||
SeriesName string `json:"series_name" description:"系列名称"`
|
||||
Description string `json:"description" description:"描述"`
|
||||
EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionConfig *SeriesOneTimeCommissionConfigDTO `json:"one_time_commission_config,omitempty" description:"一次性佣金规则配置"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// UpdatePackageSeriesParams 更新套餐系列聚合参数
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
package dto
|
||||
|
||||
// CreateShopPackageAllocationRequest 创建单套餐分配请求
|
||||
type CreateShopPackageAllocationRequest struct {
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"`
|
||||
PackageID uint `json:"package_id" validate:"required" required:"true" description:"套餐ID"`
|
||||
CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"覆盖的成本价(分)"`
|
||||
CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"该代理的成本价(分)"`
|
||||
}
|
||||
|
||||
// UpdateShopPackageAllocationRequest 更新单套餐分配请求
|
||||
type UpdateShopPackageAllocationRequest struct {
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"覆盖的成本价(分)"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"该代理的成本价(分)"`
|
||||
}
|
||||
|
||||
// ShopPackageAllocationListRequest 单套餐分配列表请求
|
||||
type ShopPackageAllocationListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"`
|
||||
PackageID *uint `json:"package_id" query:"package_id" validate:"omitempty" description:"套餐ID"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"`
|
||||
PackageID *uint `json:"package_id" query:"package_id" validate:"omitempty" description:"套餐ID"`
|
||||
SeriesAllocationID *uint `json:"series_allocation_id" query:"series_allocation_id" validate:"omitempty" description:"系列分配ID"`
|
||||
AllocatorShopID *uint `json:"allocator_shop_id" query:"allocator_shop_id" validate:"omitempty" description:"分配者店铺ID"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
}
|
||||
|
||||
// UpdateShopPackageAllocationStatusRequest 更新单套餐分配状态请求
|
||||
@@ -26,23 +25,25 @@ type UpdateShopPackageAllocationStatusRequest struct {
|
||||
Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"`
|
||||
}
|
||||
|
||||
// ShopPackageAllocationResponse 单套餐分配响应
|
||||
type ShopPackageAllocationResponse struct {
|
||||
ID uint `json:"id" description:"分配ID"`
|
||||
ShopID uint `json:"shop_id" description:"被分配的店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"被分配的店铺名称"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
AllocationID uint `json:"allocation_id" description:"关联的系列分配ID"`
|
||||
CostPrice int64 `json:"cost_price" description:"覆盖的成本价(分)"`
|
||||
CalculatedCostPrice int64 `json:"calculated_cost_price" description:"原计算成本价(分),供参考"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ID uint `json:"id" description:"分配ID"`
|
||||
ShopID uint `json:"shop_id" description:"被分配的店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"被分配的店铺名称"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
SeriesID uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName string `json:"series_name" description:"套餐系列名称"`
|
||||
SeriesAllocationID *uint `json:"series_allocation_id" description:"关联的系列分配ID"`
|
||||
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID,0表示平台分配"`
|
||||
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
|
||||
CostPrice int64 `json:"cost_price" description:"该代理的成本价(分)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// ShopPackageAllocationPageResult 单套餐分配分页结果
|
||||
// ShopPackageAllocationPageResult 套餐分配分页结果
|
||||
type ShopPackageAllocationPageResult struct {
|
||||
List []*ShopPackageAllocationResponse `json:"list" description:"分配列表"`
|
||||
Total int64 `json:"total" description:"总数"`
|
||||
@@ -51,13 +52,13 @@ type ShopPackageAllocationPageResult struct {
|
||||
TotalPages int `json:"total_pages" description:"总页数"`
|
||||
}
|
||||
|
||||
// UpdateShopPackageAllocationParams 更新单套餐分配聚合参数
|
||||
// UpdateShopPackageAllocationParams 更新套餐分配聚合参数
|
||||
type UpdateShopPackageAllocationParams struct {
|
||||
IDReq
|
||||
UpdateShopPackageAllocationRequest
|
||||
}
|
||||
|
||||
// UpdateShopPackageAllocationStatusParams 更新单套餐分配状态聚合参数
|
||||
// UpdateShopPackageAllocationStatusParams 更新套餐分配状态聚合参数
|
||||
type UpdateShopPackageAllocationStatusParams struct {
|
||||
IDReq
|
||||
UpdateShopPackageAllocationStatusRequest
|
||||
|
||||
@@ -8,15 +8,14 @@ type PriceAdjustment struct {
|
||||
|
||||
// BatchAllocatePackagesRequest 批量分配套餐请求
|
||||
type BatchAllocatePackagesRequest struct {
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"`
|
||||
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
||||
PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"`
|
||||
BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"`
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"`
|
||||
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
||||
PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" description:"该代理能拿到的一次性佣金(分)"`
|
||||
}
|
||||
|
||||
// BatchAllocatePackagesResponse 批量分配套餐响应
|
||||
type BatchAllocatePackagesResponse struct {
|
||||
AllocationID uint `json:"allocation_id" description:"系列分配ID"`
|
||||
TotalPackages int `json:"total_packages" description:"总套餐数"`
|
||||
AllocatedCount int `json:"allocated_count" description:"成功分配数量"`
|
||||
SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"`
|
||||
|
||||
@@ -1,86 +1,58 @@
|
||||
package dto
|
||||
|
||||
// BaseCommissionConfig 基础返佣配置
|
||||
type BaseCommissionConfig struct {
|
||||
Mode string `json:"mode" validate:"required,oneof=fixed percent" required:"true" description:"返佣模式 (fixed:固定金额, percent:百分比)"`
|
||||
Value int64 `json:"value" validate:"required,min=0" required:"true" minimum:"0" description:"返佣值(分或千分比,如200=20%)"`
|
||||
}
|
||||
|
||||
// OneTimeCommissionConfig 一次性佣金配置
|
||||
type OneTimeCommissionConfig struct {
|
||||
Type string `json:"type" validate:"required,oneof=fixed tiered" required:"true" description:"一次性佣金类型 (fixed:固定, tiered:梯度)"`
|
||||
Trigger string `json:"trigger" validate:"required,oneof=single_recharge accumulated_recharge" required:"true" description:"触发条件 (single_recharge:单次充值, accumulated_recharge:累计充值)"`
|
||||
Threshold int64 `json:"threshold" validate:"required,min=1" required:"true" minimum:"1" description:"最低阈值(分)"`
|
||||
Mode string `json:"mode" validate:"omitempty,oneof=fixed percent" description:"返佣模式 (fixed:固定金额, percent:百分比) - 固定类型时必填"`
|
||||
Value int64 `json:"value" validate:"omitempty,min=1" minimum:"1" description:"佣金金额(分)或比例(千分比)- 固定类型时必填"`
|
||||
Tiers []OneTimeCommissionTierEntry `json:"tiers" validate:"omitempty,dive" description:"梯度档位列表 - 梯度类型时必填"`
|
||||
}
|
||||
|
||||
// OneTimeCommissionTierEntry 一次性佣金梯度档位条目
|
||||
type OneTimeCommissionTierEntry struct {
|
||||
TierType string `json:"tier_type" validate:"required,oneof=sales_count sales_amount" required:"true" description:"梯度类型 (sales_count:销量, sales_amount:销售额)"`
|
||||
Threshold int64 `json:"threshold" validate:"required,min=1" required:"true" minimum:"1" description:"梯度阈值(销量或销售额分)"`
|
||||
Mode string `json:"mode" validate:"required,oneof=fixed percent" required:"true" description:"返佣模式 (fixed:固定金额, percent:百分比)"`
|
||||
Value int64 `json:"value" validate:"required,min=1" required:"true" minimum:"1" description:"返佣值(分或千分比)"`
|
||||
}
|
||||
|
||||
// CreateShopSeriesAllocationRequest 创建套餐系列分配请求
|
||||
type CreateShopSeriesAllocationRequest struct {
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"`
|
||||
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
||||
BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"`
|
||||
EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置(启用一次性佣金时必填)"`
|
||||
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用强充(累计充值强充)"`
|
||||
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"强充金额(分,0表示使用阈值金额)"`
|
||||
ForceRechargeTriggerType *int `json:"force_recharge_trigger_type,omitempty" description:"强充触发类型(1:单次充值, 2:累计充值)"`
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"`
|
||||
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
||||
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" validate:"required,min=0" required:"true" minimum:"0" description:"该代理能拿的一次性佣金金额上限(分)"`
|
||||
EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionTrigger string `json:"one_time_commission_trigger" validate:"omitempty,oneof=first_recharge accumulated_recharge" description:"一次性佣金触发类型 (first_recharge:首次充值, accumulated_recharge:累计充值)"`
|
||||
OneTimeCommissionThreshold *int64 `json:"one_time_commission_threshold" validate:"omitempty,min=0" minimum:"0" description:"一次性佣金触发阈值(分)"`
|
||||
EnableForceRecharge *bool `json:"enable_force_recharge" description:"是否启用强制充值"`
|
||||
ForceRechargeAmount *int64 `json:"force_recharge_amount" validate:"omitempty,min=0" minimum:"0" description:"强制充值金额(分)"`
|
||||
ForceRechargeTriggerType *int `json:"force_recharge_trigger_type" validate:"omitempty,oneof=1 2" description:"强充触发类型 (1:单次充值, 2:累计充值)"`
|
||||
}
|
||||
|
||||
// UpdateShopSeriesAllocationRequest 更新套餐系列分配请求
|
||||
type UpdateShopSeriesAllocationRequest struct {
|
||||
BaseCommission *BaseCommissionConfig `json:"base_commission" validate:"omitempty" description:"基础返佣配置"`
|
||||
EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置"`
|
||||
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用强充(累计充值强充)"`
|
||||
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"强充金额(分,0表示使用阈值金额)"`
|
||||
ForceRechargeTriggerType *int `json:"force_recharge_trigger_type,omitempty" description:"强充触发类型(1:单次充值, 2:累计充值)"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" description:"该代理能拿的一次性佣金金额上限(分)"`
|
||||
EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionTrigger *string `json:"one_time_commission_trigger" validate:"omitempty,oneof=first_recharge accumulated_recharge" description:"一次性佣金触发类型"`
|
||||
OneTimeCommissionThreshold *int64 `json:"one_time_commission_threshold" validate:"omitempty,min=0" minimum:"0" description:"一次性佣金触发阈值(分)"`
|
||||
EnableForceRecharge *bool `json:"enable_force_recharge" description:"是否启用强制充值"`
|
||||
ForceRechargeAmount *int64 `json:"force_recharge_amount" validate:"omitempty,min=0" minimum:"0" description:"强制充值金额(分)"`
|
||||
ForceRechargeTriggerType *int `json:"force_recharge_trigger_type" validate:"omitempty,oneof=1 2" description:"强充触发类型 (1:单次充值, 2:累计充值)"`
|
||||
Status *int `json:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
}
|
||||
|
||||
// ShopSeriesAllocationListRequest 套餐系列分配列表请求
|
||||
type ShopSeriesAllocationListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"`
|
||||
SeriesID *uint `json:"series_id" query:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"`
|
||||
SeriesID *uint `json:"series_id" query:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
AllocatorShopID *uint `json:"allocator_shop_id" query:"allocator_shop_id" validate:"omitempty" description:"分配者店铺ID"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"`
|
||||
}
|
||||
|
||||
// UpdateShopSeriesAllocationStatusRequest 更新套餐系列分配状态请求
|
||||
type UpdateShopSeriesAllocationStatusRequest struct {
|
||||
Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"`
|
||||
}
|
||||
|
||||
// ShopSeriesAllocationResponse 套餐系列分配响应
|
||||
type ShopSeriesAllocationResponse struct {
|
||||
ID uint `json:"id" description:"分配ID"`
|
||||
ShopID uint `json:"shop_id" description:"被分配的店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"被分配的店铺名称"`
|
||||
SeriesID uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName string `json:"series_name" description:"套餐系列名称"`
|
||||
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"`
|
||||
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
|
||||
BaseCommission BaseCommissionConfig `json:"base_commission" description:"基础返佣配置"`
|
||||
EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config,omitempty" description:"一次性佣金配置"`
|
||||
EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强充"`
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"`
|
||||
ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型(1:单次充值, 2:累计充值)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ID uint `json:"id" description:"分配ID"`
|
||||
ShopID uint `json:"shop_id" description:"被分配的店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"被分配的店铺名称"`
|
||||
SeriesID uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName string `json:"series_name" description:"套餐系列名称"`
|
||||
SeriesCode string `json:"series_code" description:"套餐系列编码"`
|
||||
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID,0表示平台分配"`
|
||||
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
|
||||
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"该代理能拿的一次性佣金金额上限(分)"`
|
||||
EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionTrigger string `json:"one_time_commission_trigger" description:"一次性佣金触发类型"`
|
||||
OneTimeCommissionThreshold int64 `json:"one_time_commission_threshold" description:"一次性佣金触发阈值(分)"`
|
||||
EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强制充值"`
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
|
||||
ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型 (1:单次充值, 2:累计充值)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// ShopSeriesAllocationPageResult 套餐系列分配分页结果
|
||||
type ShopSeriesAllocationPageResult struct {
|
||||
List []*ShopSeriesAllocationResponse `json:"list" description:"分配列表"`
|
||||
Total int64 `json:"total" description:"总数"`
|
||||
@@ -89,14 +61,7 @@ type ShopSeriesAllocationPageResult struct {
|
||||
TotalPages int `json:"total_pages" description:"总页数"`
|
||||
}
|
||||
|
||||
// UpdateShopSeriesAllocationParams 更新套餐系列分配聚合参数
|
||||
type UpdateShopSeriesAllocationParams struct {
|
||||
IDReq
|
||||
UpdateShopSeriesAllocationRequest
|
||||
}
|
||||
|
||||
// UpdateShopSeriesAllocationStatusParams 更新套餐系列分配状态聚合参数
|
||||
type UpdateShopSeriesAllocationStatusParams struct {
|
||||
IDReq
|
||||
UpdateShopSeriesAllocationStatusRequest
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -11,35 +13,135 @@ import (
|
||||
// 通过 shop_id 区分所有权:NULL=平台所有,有值=店铺所有
|
||||
type IotCard struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(20);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识,电信19位/其他20位)" json:"iccid"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
|
||||
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
|
||||
CarrierType string `gorm:"column:carrier_type;type:varchar(20);comment:运营商类型(CMCC/CUCC/CTCC/CBN),导入时快照" json:"carrier_type"`
|
||||
CarrierName string `gorm:"column:carrier_name;type:varchar(100);comment:运营商名称,导入时快照" json:"carrier_name"`
|
||||
IMSI string `gorm:"column:imsi;type:varchar(50);comment:IMSI" json:"imsi"`
|
||||
MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
Supplier string `gorm:"column:supplier;type:varchar(255);comment:供应商" json:"supplier"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;comment:分销价(分为单位)" json:"distribute_price"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台所有,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
ActivationStatus int `gorm:"column:activation_status;type:int;default:0;not null;comment:激活状态 0-未激活 1-已激活" json:"activation_status"`
|
||||
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
|
||||
NetworkStatus int `gorm:"column:network_status;type:int;default:0;not null;comment:网络状态 0-停机 1-开机" json:"network_status"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:累计流量使用(MB)" json:"data_usage_mb"`
|
||||
EnablePolling bool `gorm:"column:enable_polling;type:boolean;default:true;comment:是否参与轮询 true-参与 false-不参与" json:"enable_polling"`
|
||||
LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"`
|
||||
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`
|
||||
LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"`
|
||||
SeriesID *uint `gorm:"column:series_id;index;comment:套餐系列ID(关联PackageSeries)" json:"series_id,omitempty"`
|
||||
FirstCommissionPaid bool `gorm:"column:first_commission_paid;type:boolean;default:false;comment:一次性佣金是否已发放" json:"first_commission_paid"`
|
||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分)" json:"accumulated_recharge"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(20);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识,电信19位/其他20位)" json:"iccid"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
|
||||
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
|
||||
CarrierType string `gorm:"column:carrier_type;type:varchar(20);comment:运营商类型(CMCC/CUCC/CTCC/CBN),导入时快照" json:"carrier_type"`
|
||||
CarrierName string `gorm:"column:carrier_name;type:varchar(100);comment:运营商名称,导入时快照" json:"carrier_name"`
|
||||
IMSI string `gorm:"column:imsi;type:varchar(50);comment:IMSI" json:"imsi"`
|
||||
MSISDN string `gorm:"column:msisdn;type:varchar(20);comment:MSISDN(手机号码)" json:"msisdn"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
Supplier string `gorm:"column:supplier;type:varchar(255);comment:供应商" json:"supplier"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;comment:分销价(分为单位)" json:"distribute_price"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台所有,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
ActivationStatus int `gorm:"column:activation_status;type:int;default:0;not null;comment:激活状态 0-未激活 1-已激活" json:"activation_status"`
|
||||
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
|
||||
NetworkStatus int `gorm:"column:network_status;type:int;default:0;not null;comment:网络状态 0-停机 1-开机" json:"network_status"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:累计流量使用(MB)" json:"data_usage_mb"`
|
||||
EnablePolling bool `gorm:"column:enable_polling;type:boolean;default:true;comment:是否参与轮询 true-参与 false-不参与" json:"enable_polling"`
|
||||
LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"`
|
||||
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`
|
||||
LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"`
|
||||
SeriesID *uint `gorm:"column:series_id;index;comment:套餐系列ID(关联PackageSeries)" json:"series_id,omitempty"`
|
||||
FirstCommissionPaid bool `gorm:"column:first_commission_paid;type:boolean;default:false;comment:一次性佣金是否已发放(废弃,使用按系列追踪)" json:"first_commission_paid"`
|
||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
|
||||
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
|
||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (IotCard) TableName() string {
|
||||
return "tb_iot_card"
|
||||
}
|
||||
|
||||
func (c *IotCard) GetAccumulatedRechargeBySeriesMap() (map[uint]int64, error) {
|
||||
result := make(map[uint]int64)
|
||||
if c.AccumulatedRechargeBySeriesJSON == "" || c.AccumulatedRechargeBySeriesJSON == "{}" {
|
||||
return result, nil
|
||||
}
|
||||
var raw map[string]int64
|
||||
if err := json.Unmarshal([]byte(c.AccumulatedRechargeBySeriesJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range raw {
|
||||
id, err := strconv.ParseUint(k, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[uint(id)] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *IotCard) SetAccumulatedRechargeBySeriesMap(m map[uint]int64) error {
|
||||
raw := make(map[string]int64)
|
||||
for k, v := range m {
|
||||
raw[strconv.FormatUint(uint64(k), 10)] = v
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.AccumulatedRechargeBySeriesJSON = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *IotCard) GetAccumulatedRechargeBySeries(seriesID uint) int64 {
|
||||
m, err := c.GetAccumulatedRechargeBySeriesMap()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return m[seriesID]
|
||||
}
|
||||
|
||||
func (c *IotCard) AddAccumulatedRechargeBySeries(seriesID uint, amount int64) error {
|
||||
m, err := c.GetAccumulatedRechargeBySeriesMap()
|
||||
if err != nil {
|
||||
m = make(map[uint]int64)
|
||||
}
|
||||
m[seriesID] += amount
|
||||
return c.SetAccumulatedRechargeBySeriesMap(m)
|
||||
}
|
||||
|
||||
func (c *IotCard) GetFirstRechargeTriggeredBySeriesMap() (map[uint]bool, error) {
|
||||
result := make(map[uint]bool)
|
||||
if c.FirstRechargeTriggeredBySeriesJSON == "" || c.FirstRechargeTriggeredBySeriesJSON == "{}" {
|
||||
return result, nil
|
||||
}
|
||||
var raw map[string]bool
|
||||
if err := json.Unmarshal([]byte(c.FirstRechargeTriggeredBySeriesJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range raw {
|
||||
id, err := strconv.ParseUint(k, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[uint(id)] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *IotCard) SetFirstRechargeTriggeredBySeriesMap(m map[uint]bool) error {
|
||||
raw := make(map[string]bool)
|
||||
for k, v := range m {
|
||||
raw[strconv.FormatUint(uint64(k), 10)] = v
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.FirstRechargeTriggeredBySeriesJSON = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *IotCard) IsFirstRechargeTriggeredBySeries(seriesID uint) bool {
|
||||
m, err := c.GetFirstRechargeTriggeredBySeriesMap()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m[seriesID]
|
||||
}
|
||||
|
||||
func (c *IotCard) SetFirstRechargeTriggeredBySeries(seriesID uint, triggered bool) error {
|
||||
m, err := c.GetFirstRechargeTriggeredBySeriesMap()
|
||||
if err != nil {
|
||||
m = make(map[uint]bool)
|
||||
}
|
||||
m[seriesID] = triggered
|
||||
return c.SetFirstRechargeTriggeredBySeriesMap(m)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -10,11 +11,13 @@ import (
|
||||
// 套餐的分组,用于一次性分佣规则配置
|
||||
type PackageSeries struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
SeriesCode string `gorm:"column:series_code;type:varchar(100);uniqueIndex:idx_package_series_code,where:deleted_at IS NULL;not null;comment:系列编码" json:"series_code"`
|
||||
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;comment:系列名称" json:"series_name"`
|
||||
Description string `gorm:"column:description;type:text;comment:描述" json:"description"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
SeriesCode string `gorm:"column:series_code;type:varchar(100);uniqueIndex:idx_package_series_code,where:deleted_at IS NULL;not null;comment:系列编码" json:"series_code"`
|
||||
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;comment:系列名称" json:"series_name"`
|
||||
Description string `gorm:"column:description;type:text;comment:描述" json:"description"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
OneTimeCommissionConfigJSON string `gorm:"column:one_time_commission_config;type:jsonb;default:'{}';comment:一次性佣金规则配置" json:"-"`
|
||||
EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;default:false;comment:是否启用一次性佣金(顶层字段,支持SQL索引)" json:"enable_one_time_commission"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -32,13 +35,11 @@ type Package struct {
|
||||
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
|
||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||
DataType string `gorm:"column:data_type;type:varchar(20);comment:流量类型 real-真流量 virtual-虚流量" json:"data_type"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||
DataAmountMB int64 `gorm:"column:data_amount_mb;type:bigint;default:0;comment:总流量额度(MB)" json:"data_amount_mb"`
|
||||
Price int64 `gorm:"column:price;type:bigint;not null;comment:套餐价格(分为单位)" json:"price"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
SuggestedCostPrice int64 `gorm:"column:suggested_cost_price;type:bigint;default:0;comment:建议成本价(分为单位)" json:"suggested_cost_price"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
}
|
||||
@@ -72,3 +73,68 @@ type PackageUsage struct {
|
||||
func (PackageUsage) TableName() string {
|
||||
return "tb_package_usage"
|
||||
}
|
||||
|
||||
// OneTimeCommissionConfig 一次性佣金规则配置
|
||||
type OneTimeCommissionConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
Threshold int64 `json:"threshold"`
|
||||
CommissionType string `json:"commission_type"`
|
||||
CommissionAmount int64 `json:"commission_amount"`
|
||||
Tiers []OneTimeCommissionTier `json:"tiers,omitempty"`
|
||||
ValidityType string `json:"validity_type"`
|
||||
ValidityValue string `json:"validity_value"`
|
||||
EnableForceRecharge bool `json:"enable_force_recharge"`
|
||||
ForceCalcType string `json:"force_calc_type"`
|
||||
ForceAmount int64 `json:"force_amount"`
|
||||
}
|
||||
|
||||
// OneTimeCommissionTier 一次性佣金梯度配置
|
||||
type OneTimeCommissionTier struct {
|
||||
Dimension string `json:"dimension"`
|
||||
StatScope string `json:"stat_scope"`
|
||||
Threshold int64 `json:"threshold"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
const (
|
||||
OneTimeCommissionTriggerFirstRecharge = "first_recharge"
|
||||
OneTimeCommissionTriggerAccumulatedRecharge = "accumulated_recharge"
|
||||
|
||||
OneTimeCommissionValidityPermanent = "permanent"
|
||||
OneTimeCommissionValidityFixedDate = "fixed_date"
|
||||
OneTimeCommissionValidityRelative = "relative"
|
||||
|
||||
OneTimeCommissionForceCalcFixed = "fixed"
|
||||
OneTimeCommissionForceCalcDynamic = "dynamic"
|
||||
|
||||
OneTimeCommissionStatScopeSelf = "self"
|
||||
OneTimeCommissionStatScopeSelfAndSub = "self_and_sub"
|
||||
|
||||
TierTypeSalesCount = "sales_count"
|
||||
TierTypeSalesAmount = "sales_amount"
|
||||
)
|
||||
|
||||
func (ps *PackageSeries) GetOneTimeCommissionConfig() (*OneTimeCommissionConfig, error) {
|
||||
if ps.OneTimeCommissionConfigJSON == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var config OneTimeCommissionConfig
|
||||
if err := json.Unmarshal([]byte(ps.OneTimeCommissionConfigJSON), &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (ps *PackageSeries) SetOneTimeCommissionConfig(config *OneTimeCommissionConfig) error {
|
||||
if config == nil {
|
||||
ps.OneTimeCommissionConfigJSON = ""
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps.OneTimeCommissionConfigJSON = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,17 +4,15 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ShopPackageAllocation 店铺单套餐分配模型
|
||||
// 用于对单个套餐设置覆盖成本价,优先级高于系列级别的加价计算
|
||||
// 适用于特殊定价场景(如某个套餐给特定代理优惠价)
|
||||
type ShopPackageAllocation struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||
AllocationID uint `gorm:"column:allocation_id;index;not null;comment:关联的系列分配ID" json:"allocation_id"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:覆盖的成本价(分)" json:"cost_price"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:该代理的成本价(分)" json:"cost_price"`
|
||||
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -4,59 +4,22 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ShopSeriesAllocation 店铺套餐系列分配模型
|
||||
// 记录上级店铺为下级店铺分配的套餐系列,包含基础返佣配置和梯度返佣开关
|
||||
// 分配者只能分配自己已被分配的套餐系列,且只能分配给直属下级
|
||||
type ShopSeriesAllocation struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
SeriesID uint `gorm:"column:series_id;index;not null;comment:套餐系列ID" json:"series_id"`
|
||||
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;comment:分配者店铺ID(上级)" json:"allocator_shop_id"`
|
||||
BaseCommissionMode string `gorm:"column:base_commission_mode;type:varchar(20);not null;default:percent;comment:基础返佣模式 fixed-固定金额 percent-百分比" json:"base_commission_mode"`
|
||||
BaseCommissionValue int64 `gorm:"column:base_commission_value;type:bigint;not null;default:0;comment:基础返佣值(分或千分比,如200=20%)" json:"base_commission_value"`
|
||||
|
||||
// 一次性佣金配置
|
||||
EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;type:boolean;not null;default:false;comment:是否启用一次性佣金" json:"enable_one_time_commission"`
|
||||
OneTimeCommissionType string `gorm:"column:one_time_commission_type;type:varchar(20);comment:一次性佣金类型 fixed-固定 tiered-梯度" json:"one_time_commission_type"`
|
||||
OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(30);comment:触发条件 single_recharge-单次充值 accumulated_recharge-累计充值" json:"one_time_commission_trigger"`
|
||||
OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;comment:最低阈值(分)" json:"one_time_commission_threshold"`
|
||||
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"`
|
||||
ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;comment:强充触发类型(1:单次充值, 2:累计充值)" json:"force_recharge_trigger_type"`
|
||||
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
SeriesID uint `gorm:"column:series_id;index;not null;comment:套餐系列ID" json:"series_id"`
|
||||
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"`
|
||||
OneTimeCommissionAmount int64 `gorm:"column:one_time_commission_amount;type:bigint;default:0;not null;comment:该代理能拿的一次性佣金金额上限(分)" json:"one_time_commission_amount"`
|
||||
EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;default:false;not null;comment:是否启用一次性佣金" json:"enable_one_time_commission"`
|
||||
OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(50);comment:一次性佣金触发类型" json:"one_time_commission_trigger"`
|
||||
OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;not null;comment:一次性佣金触发阈值(分)" json:"one_time_commission_threshold"`
|
||||
EnableForceRecharge bool `gorm:"column:enable_force_recharge;default:false;not null;comment:是否启用强制充值" json:"enable_force_recharge"`
|
||||
ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;not null;comment:强制充值金额(分)" json:"force_recharge_amount"`
|
||||
ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;not null;comment:强充触发类型 1-单次充值 2-累计充值" json:"force_recharge_trigger_type"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ShopSeriesAllocation) TableName() string {
|
||||
return "tb_shop_series_allocation"
|
||||
}
|
||||
|
||||
// 返佣模式常量
|
||||
const (
|
||||
// CommissionModeFixed 固定金额返佣
|
||||
CommissionModeFixed = "fixed"
|
||||
// CommissionModePercent 百分比返佣(千分比)
|
||||
CommissionModePercent = "percent"
|
||||
)
|
||||
|
||||
// 一次性佣金类型常量
|
||||
const (
|
||||
// OneTimeCommissionTypeFixed 固定一次性佣金
|
||||
OneTimeCommissionTypeFixed = "fixed"
|
||||
// OneTimeCommissionTypeTiered 梯度一次性佣金
|
||||
OneTimeCommissionTypeTiered = "tiered"
|
||||
)
|
||||
|
||||
// 一次性佣金触发类型常量
|
||||
const (
|
||||
// OneTimeCommissionTriggerSingleRecharge 单次充值触发
|
||||
OneTimeCommissionTriggerSingleRecharge = "single_recharge"
|
||||
// OneTimeCommissionTriggerAccumulatedRecharge 累计充值触发
|
||||
OneTimeCommissionTriggerAccumulatedRecharge = "accumulated_recharge"
|
||||
)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ShopSeriesAllocationConfig 套餐系列分配配置版本模型
|
||||
// 记录返佣配置的历史版本,订单创建时锁定配置版本
|
||||
// 支持配置追溯和数据一致性保障
|
||||
type ShopSeriesAllocationConfig struct {
|
||||
gorm.Model
|
||||
AllocationID uint `gorm:"column:allocation_id;index;not null;comment:关联的分配ID" json:"allocation_id"`
|
||||
Version int `gorm:"column:version;type:int;not null;comment:配置版本号" json:"version"`
|
||||
BaseCommissionMode string `gorm:"column:base_commission_mode;type:varchar(20);not null;comment:基础返佣模式(配置快照)" json:"base_commission_mode"`
|
||||
BaseCommissionValue int64 `gorm:"column:base_commission_value;type:bigint;not null;comment:基础返佣值(配置快照)" json:"base_commission_value"`
|
||||
EffectiveFrom time.Time `gorm:"column:effective_from;type:timestamptz;not null;comment:生效开始时间" json:"effective_from"`
|
||||
EffectiveTo *time.Time `gorm:"column:effective_to;type:timestamptz;comment:生效结束时间(NULL表示当前生效)" json:"effective_to"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ShopSeriesAllocationConfig) TableName() string {
|
||||
return "tb_shop_series_allocation_config"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ShopSeriesOneTimeCommissionTier 一次性佣金梯度配置模型
|
||||
// 记录基于销售业绩的一次性佣金梯度档位
|
||||
// 当系列分配的累计销量或销售额达到不同阈值时,返不同的一次性佣金金额
|
||||
type ShopSeriesOneTimeCommissionTier struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
AllocationID uint `gorm:"column:allocation_id;index;not null;comment:系列分配ID" json:"allocation_id"`
|
||||
TierType string `gorm:"column:tier_type;type:varchar(20);not null;comment:梯度类型 sales_count-销量 sales_amount-销售额" json:"tier_type"`
|
||||
ThresholdValue int64 `gorm:"column:threshold_value;type:bigint;not null;comment:梯度阈值(销量或销售额分)" json:"threshold_value"`
|
||||
CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;default:fixed;comment:返佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"`
|
||||
CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:返佣值(分或千分比)" json:"commission_value"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-停用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ShopSeriesOneTimeCommissionTier) TableName() string {
|
||||
return "tb_shop_series_one_time_commission_tier"
|
||||
}
|
||||
|
||||
// 梯度类型常量
|
||||
const (
|
||||
// TierTypeSalesCount 销量梯度
|
||||
TierTypeSalesCount = "sales_count"
|
||||
// TierTypeSalesAmount 销售额梯度
|
||||
TierTypeSalesAmount = "sales_amount"
|
||||
)
|
||||
|
||||
// 返佣模式常量在 shop_series_allocation.go 中定义
|
||||
// CommissionModeFixed = "fixed"
|
||||
// CommissionModePercent = "percent"
|
||||
@@ -14,7 +14,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "单套餐分配列表",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.ShopPackageAllocationListRequest),
|
||||
Output: new(dto.ShopPackageAllocationPageResult),
|
||||
Auth: true,
|
||||
@@ -22,7 +22,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||
Summary: "创建单套餐分配",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.CreateShopPackageAllocationRequest),
|
||||
Output: new(dto.ShopPackageAllocationResponse),
|
||||
Auth: true,
|
||||
@@ -30,7 +30,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
|
||||
Summary: "获取单套餐分配详情",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.ShopPackageAllocationResponse),
|
||||
Auth: true,
|
||||
@@ -38,7 +38,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
|
||||
Summary: "更新单套餐分配",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.UpdateShopPackageAllocationParams),
|
||||
Output: new(dto.ShopPackageAllocationResponse),
|
||||
Auth: true,
|
||||
@@ -46,7 +46,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||
Summary: "删除单套餐分配",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
@@ -54,7 +54,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "PUT", "/:id/status", handler.UpdateStatus, RouteSpec{
|
||||
Summary: "更新单套餐分配状态",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.UpdateStatusParams),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
@@ -62,7 +62,7 @@ func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.Sho
|
||||
|
||||
Register(allocations, doc, groupPath, "PUT", "/:id/cost-price", handler.UpdateCostPrice, RouteSpec{
|
||||
Summary: "更新单套餐分配成本价",
|
||||
Tags: []string{"单套餐分配"},
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.ShopPackageAllocationResponse),
|
||||
Auth: true,
|
||||
|
||||
@@ -8,56 +8,47 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerShopSeriesAllocationRoutes 注册套餐系列分配相关路由
|
||||
func registerShopSeriesAllocationRoutes(router fiber.Router, handler *admin.ShopSeriesAllocationHandler, doc *openapi.Generator, basePath string) {
|
||||
allocations := router.Group("/shop-series-allocations")
|
||||
groupPath := basePath + "/shop-series-allocations"
|
||||
|
||||
Register(allocations, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "套餐系列分配列表",
|
||||
Tags: []string{"套餐系列分配"},
|
||||
Summary: "系列分配列表",
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.ShopSeriesAllocationListRequest),
|
||||
Output: new(dto.ShopSeriesAllocationPageResult),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(allocations, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||
Summary: "创建套餐系列分配",
|
||||
Tags: []string{"套餐系列分配"},
|
||||
Summary: "创建系列分配",
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.CreateShopSeriesAllocationRequest),
|
||||
Output: new(dto.ShopSeriesAllocationResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(allocations, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
|
||||
Summary: "获取套餐系列分配详情",
|
||||
Tags: []string{"套餐系列分配"},
|
||||
Summary: "获取系列分配详情",
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.ShopSeriesAllocationResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(allocations, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
|
||||
Summary: "更新套餐系列分配",
|
||||
Tags: []string{"套餐系列分配"},
|
||||
Summary: "更新系列分配",
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.UpdateShopSeriesAllocationParams),
|
||||
Output: new(dto.ShopSeriesAllocationResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(allocations, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||
Summary: "删除套餐系列分配",
|
||||
Tags: []string{"套餐系列分配"},
|
||||
Summary: "删除系列分配",
|
||||
Tags: []string{"套餐分配"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(allocations, doc, groupPath, "PUT", "/:id/status", handler.UpdateStatus, RouteSpec{
|
||||
Summary: "更新套餐系列分配状态",
|
||||
Tags: []string{"套餐系列分配"},
|
||||
Input: new(dto.UpdateStatusParams),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,34 +9,36 @@ import (
|
||||
"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/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
shopStore *postgres.ShopStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
shopSeriesOneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
walletStore *postgres.WalletStore
|
||||
walletTransactionStore *postgres.WalletTransactionStore
|
||||
orderStore *postgres.OrderStore
|
||||
orderItemStore *postgres.OrderItemStore
|
||||
packageStore *postgres.PackageStore
|
||||
commissionStatsService *commission_stats.Service
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
shopStore *postgres.ShopStore
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
walletStore *postgres.WalletStore
|
||||
walletTransactionStore *postgres.WalletTransactionStore
|
||||
orderStore *postgres.OrderStore
|
||||
orderItemStore *postgres.OrderItemStore
|
||||
packageStore *postgres.PackageStore
|
||||
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore
|
||||
commissionStatsService *commission_stats.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
commissionRecordStore *postgres.CommissionRecordStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopSeriesOneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
walletStore *postgres.WalletStore,
|
||||
@@ -44,24 +46,27 @@ func New(
|
||||
orderStore *postgres.OrderStore,
|
||||
orderItemStore *postgres.OrderItemStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore,
|
||||
commissionStatsService *commission_stats.Service,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
commissionRecordStore: commissionRecordStore,
|
||||
shopStore: shopStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
shopSeriesOneTimeCommissionTierStore: shopSeriesOneTimeCommissionTierStore,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
walletStore: walletStore,
|
||||
walletTransactionStore: walletTransactionStore,
|
||||
orderStore: orderStore,
|
||||
orderItemStore: orderItemStore,
|
||||
packageStore: packageStore,
|
||||
commissionStatsService: commissionStatsService,
|
||||
logger: logger,
|
||||
db: db,
|
||||
commissionRecordStore: commissionRecordStore,
|
||||
shopStore: shopStore,
|
||||
shopPackageAllocationStore: shopPackageAllocationStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
walletStore: walletStore,
|
||||
walletTransactionStore: walletTransactionStore,
|
||||
orderStore: orderStore,
|
||||
orderItemStore: orderItemStore,
|
||||
packageStore: packageStore,
|
||||
commissionStatsStore: commissionStatsStore,
|
||||
commissionStatsService: commissionStatsService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +151,14 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
|
||||
})
|
||||
}
|
||||
|
||||
// 获取订单明细以获取套餐ID(用于成本价查询)
|
||||
orderItems, err := s.orderItemStore.ListByOrderID(ctx, order.ID)
|
||||
if err != nil || len(orderItems) == 0 {
|
||||
s.logger.Warn("获取订单明细失败或订单无明细,跳过成本价差佣金计算", zap.Uint("order_id", order.ID), zap.Error(err))
|
||||
return records, nil
|
||||
}
|
||||
packageID := orderItems[0].PackageID
|
||||
|
||||
childCostPrice := order.SellerCostPrice
|
||||
currentShopID := sellerShop.ParentID
|
||||
|
||||
@@ -156,13 +169,13 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
|
||||
break
|
||||
}
|
||||
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, currentShop.ID, *order.SeriesID)
|
||||
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, currentShop.ID, packageID)
|
||||
if err != nil {
|
||||
s.logger.Warn("上级店铺未分配该系列,跳过", zap.Uint("shop_id", currentShop.ID), zap.Uint("series_id", *order.SeriesID))
|
||||
s.logger.Warn("上级店铺未分配该套餐,跳过", zap.Uint("shop_id", currentShop.ID), zap.Uint("package_id", packageID))
|
||||
break
|
||||
}
|
||||
|
||||
myCostPrice := s.calculateCostPrice(allocation, order.TotalAmount)
|
||||
myCostPrice := allocation.CostPrice
|
||||
profit := childCostPrice - myCostPrice
|
||||
if profit > 0 {
|
||||
records = append(records, &model.CommissionRecord{
|
||||
@@ -187,12 +200,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Service) calculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
||||
return utils.CalculateCostPrice(allocation, orderAmount)
|
||||
}
|
||||
|
||||
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
|
||||
// 代购订单不触发一次性佣金和累计充值更新
|
||||
if order.IsPurchaseOnBehalf {
|
||||
return nil
|
||||
}
|
||||
@@ -206,79 +214,64 @@ func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *g
|
||||
return nil
|
||||
}
|
||||
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, *card.ShopID, *card.SeriesID)
|
||||
seriesID := *card.SeriesID
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取系列分配失败")
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取套餐系列失败")
|
||||
}
|
||||
|
||||
if !allocation.EnableOneTimeCommission {
|
||||
config, err := series.GetOneTimeCommissionConfig()
|
||||
if err != nil || config == nil || !config.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
||||
newAccumulated := card.AccumulatedRecharge + order.TotalAmount
|
||||
if s.isOneTimeCommissionExpired(config, card.ActivatedAt) {
|
||||
s.logger.Info("一次性佣金规则已过期,跳过",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Uint("series_id", seriesID),
|
||||
zap.String("validity_type", config.ValidityType))
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
|
||||
accumulatedBySeries := card.GetAccumulatedRechargeBySeries(seriesID)
|
||||
newAccumulated := accumulatedBySeries + order.TotalAmount
|
||||
card.AddAccumulatedRechargeBySeries(seriesID, order.TotalAmount)
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
|
||||
Update("accumulated_recharge", newAccumulated).Error; err != nil {
|
||||
Update("accumulated_recharge_by_series", card.AccumulatedRechargeBySeriesJSON).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡累计充值金额失败")
|
||||
}
|
||||
card.AccumulatedRecharge = newAccumulated
|
||||
|
||||
if card.IsFirstRechargeTriggeredBySeries(seriesID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if newAccumulated < config.Threshold {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if card.FirstCommissionPaid {
|
||||
if card.IsFirstRechargeTriggeredBySeries(seriesID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rechargeAmount int64
|
||||
switch allocation.OneTimeCommissionTrigger {
|
||||
case model.OneTimeCommissionTriggerSingleRecharge:
|
||||
rechargeAmount = order.TotalAmount
|
||||
case model.OneTimeCommissionTriggerAccumulatedRecharge:
|
||||
rechargeAmount = card.AccumulatedRecharge
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if rechargeAmount < allocation.OneTimeCommissionThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount)
|
||||
records, err := s.calculateChainOneTimeCommission(ctx, *card.ShopID, seriesID, order, &cardID, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "计算一次性佣金失败")
|
||||
return err
|
||||
}
|
||||
|
||||
if commissionAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if card.ShopID == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "卡未归属任何店铺,无法发放佣金")
|
||||
}
|
||||
|
||||
record := &model.CommissionRecord{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
Updater: order.Updater,
|
||||
},
|
||||
ShopID: *card.ShopID,
|
||||
OrderID: order.ID,
|
||||
IotCardID: &cardID,
|
||||
CommissionSource: model.CommissionSourceOneTime,
|
||||
Amount: commissionAmount,
|
||||
Status: model.CommissionStatusReleased,
|
||||
Remark: "一次性佣金",
|
||||
}
|
||||
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
||||
}
|
||||
|
||||
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
||||
for _, record := range records {
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
||||
}
|
||||
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
||||
}
|
||||
}
|
||||
|
||||
card.SetFirstRechargeTriggeredBySeries(seriesID, true)
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
|
||||
Update("first_commission_paid", true).Error; err != nil {
|
||||
Update("first_recharge_triggered_by_series", card.FirstRechargeTriggeredBySeriesJSON).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败")
|
||||
}
|
||||
|
||||
@@ -292,7 +285,6 @@ 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
|
||||
}
|
||||
@@ -306,79 +298,64 @@ func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx
|
||||
return nil
|
||||
}
|
||||
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, *device.ShopID, *device.SeriesID)
|
||||
seriesID := *device.SeriesID
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取系列分配失败")
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取套餐系列失败")
|
||||
}
|
||||
|
||||
if !allocation.EnableOneTimeCommission {
|
||||
config, err := series.GetOneTimeCommissionConfig()
|
||||
if err != nil || config == nil || !config.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
||||
newAccumulated := device.AccumulatedRecharge + order.TotalAmount
|
||||
if s.isOneTimeCommissionExpired(config, device.ActivatedAt) {
|
||||
s.logger.Info("一次性佣金规则已过期,跳过",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Uint("series_id", seriesID),
|
||||
zap.String("validity_type", config.ValidityType))
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
|
||||
accumulatedBySeries := device.GetAccumulatedRechargeBySeries(seriesID)
|
||||
newAccumulated := accumulatedBySeries + order.TotalAmount
|
||||
device.AddAccumulatedRechargeBySeries(seriesID, order.TotalAmount)
|
||||
if err := tx.Model(&model.Device{}).Where("id = ?", deviceID).
|
||||
Update("accumulated_recharge", newAccumulated).Error; err != nil {
|
||||
Update("accumulated_recharge_by_series", device.AccumulatedRechargeBySeriesJSON).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备累计充值金额失败")
|
||||
}
|
||||
device.AccumulatedRecharge = newAccumulated
|
||||
|
||||
if device.IsFirstRechargeTriggeredBySeries(seriesID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if newAccumulated < config.Threshold {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if device.FirstCommissionPaid {
|
||||
if device.IsFirstRechargeTriggeredBySeries(seriesID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rechargeAmount int64
|
||||
switch allocation.OneTimeCommissionTrigger {
|
||||
case model.OneTimeCommissionTriggerSingleRecharge:
|
||||
rechargeAmount = order.TotalAmount
|
||||
case model.OneTimeCommissionTriggerAccumulatedRecharge:
|
||||
rechargeAmount = device.AccumulatedRecharge
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if rechargeAmount < allocation.OneTimeCommissionThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount)
|
||||
records, err := s.calculateChainOneTimeCommission(ctx, *device.ShopID, seriesID, order, nil, &deviceID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "计算一次性佣金失败")
|
||||
return err
|
||||
}
|
||||
|
||||
if commissionAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if device.ShopID == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "设备未归属任何店铺,无法发放佣金")
|
||||
}
|
||||
|
||||
record := &model.CommissionRecord{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
Updater: order.Updater,
|
||||
},
|
||||
ShopID: *device.ShopID,
|
||||
OrderID: order.ID,
|
||||
DeviceID: &deviceID,
|
||||
CommissionSource: model.CommissionSourceOneTime,
|
||||
Amount: commissionAmount,
|
||||
Status: model.CommissionStatusReleased,
|
||||
Remark: "一次性佣金(设备)",
|
||||
}
|
||||
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
||||
}
|
||||
|
||||
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
||||
for _, record := range records {
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
||||
}
|
||||
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
||||
}
|
||||
}
|
||||
|
||||
device.SetFirstRechargeTriggeredBySeries(seriesID, true)
|
||||
if err := tx.Model(&model.Device{}).Where("id = ?", deviceID).
|
||||
Update("first_commission_paid", true).Error; err != nil {
|
||||
Update("first_recharge_triggered_by_series", device.FirstRechargeTriggeredBySeriesJSON).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败")
|
||||
}
|
||||
|
||||
@@ -391,74 +368,197 @@ func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order *
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) calculateOneTimeCommission(ctx context.Context, allocation *model.ShopSeriesAllocation, orderAmount int64) (int64, error) {
|
||||
switch allocation.OneTimeCommissionType {
|
||||
case model.OneTimeCommissionTypeFixed:
|
||||
return s.calculateFixedCommission(allocation.OneTimeCommissionMode, allocation.OneTimeCommissionValue, orderAmount), nil
|
||||
case model.OneTimeCommissionTypeTiered:
|
||||
return s.calculateTieredCommission(ctx, allocation.ID, orderAmount)
|
||||
func (s *Service) isOneTimeCommissionExpired(config *model.OneTimeCommissionConfig, activatedAt *time.Time) bool {
|
||||
if config == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch config.ValidityType {
|
||||
case model.OneTimeCommissionValidityPermanent:
|
||||
return false
|
||||
|
||||
case model.OneTimeCommissionValidityFixedDate:
|
||||
if config.ValidityValue == "" {
|
||||
return false
|
||||
}
|
||||
expiryDate, err := time.Parse("2006-01-02", config.ValidityValue)
|
||||
if err != nil {
|
||||
s.logger.Warn("解析一次性佣金到期日期失败",
|
||||
zap.String("validity_value", config.ValidityValue),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
expiryDate = expiryDate.Add(24*time.Hour - time.Second)
|
||||
return now.After(expiryDate)
|
||||
|
||||
case model.OneTimeCommissionValidityRelative:
|
||||
if activatedAt == nil {
|
||||
return false
|
||||
}
|
||||
if config.ValidityValue == "" {
|
||||
return false
|
||||
}
|
||||
months := 0
|
||||
if _, err := fmt.Sscanf(config.ValidityValue, "%d", &months); err != nil || months <= 0 {
|
||||
s.logger.Warn("解析一次性佣金相对时长失败",
|
||||
zap.String("validity_value", config.ValidityValue),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
expiryTime := activatedAt.AddDate(0, months, 0)
|
||||
return now.After(expiryTime)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) calculateFixedCommission(mode string, value int64, orderAmount int64) int64 {
|
||||
if mode == model.CommissionModeFixed {
|
||||
return value
|
||||
} else if mode == model.CommissionModePercent {
|
||||
return orderAmount * value / 1000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (s *Service) calculateChainOneTimeCommission(ctx context.Context, bottomShopID uint, seriesID uint, order *model.Order, cardID *uint, deviceID *uint) ([]*model.CommissionRecord, error) {
|
||||
var records []*model.CommissionRecord
|
||||
|
||||
func (s *Service) calculateTieredCommission(ctx context.Context, allocationID uint, orderAmount int64) (int64, error) {
|
||||
tiers, err := s.shopSeriesOneTimeCommissionTierStore.ListByAllocationID(ctx, allocationID)
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "获取梯度配置失败")
|
||||
s.logger.Warn("获取套餐系列失败,跳过一次性佣金", zap.Uint("series_id", seriesID), zap.Error(err))
|
||||
return records, nil
|
||||
}
|
||||
|
||||
config, err := series.GetOneTimeCommissionConfig()
|
||||
if err != nil || config == nil || !config.Enable {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
bottomSeriesAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, bottomShopID, seriesID)
|
||||
if err != nil {
|
||||
s.logger.Warn("底层店铺未分配该系列,跳过一次性佣金", zap.Uint("shop_id", bottomShopID), zap.Uint("series_id", seriesID))
|
||||
return records, nil
|
||||
}
|
||||
|
||||
bottomShop, err := s.shopStore.GetByID(ctx, bottomShopID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取店铺信息失败")
|
||||
}
|
||||
|
||||
childAmountGiven := int64(0)
|
||||
currentShopID := bottomShopID
|
||||
currentShop := bottomShop
|
||||
currentSeriesAllocation := bottomSeriesAllocation
|
||||
|
||||
for {
|
||||
var myAmount int64
|
||||
|
||||
if config.CommissionType == "tiered" && len(config.Tiers) > 0 {
|
||||
tieredAmount, tierErr := s.matchOneTimeCommissionTier(ctx, currentShopID, seriesID, currentSeriesAllocation.ID, config.Tiers)
|
||||
if tierErr != nil {
|
||||
s.logger.Warn("匹配梯度佣金失败,使用固定金额", zap.Uint("shop_id", currentShopID), zap.Error(tierErr))
|
||||
myAmount = currentSeriesAllocation.OneTimeCommissionAmount
|
||||
} else {
|
||||
myAmount = tieredAmount
|
||||
}
|
||||
} else {
|
||||
myAmount = currentSeriesAllocation.OneTimeCommissionAmount
|
||||
}
|
||||
|
||||
actualProfit := myAmount - childAmountGiven
|
||||
|
||||
if actualProfit > 0 {
|
||||
remark := "一次性佣金"
|
||||
if deviceID != nil {
|
||||
remark = "一次性佣金(设备)"
|
||||
}
|
||||
|
||||
records = append(records, &model.CommissionRecord{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
Updater: order.Updater,
|
||||
},
|
||||
ShopID: currentShopID,
|
||||
OrderID: order.ID,
|
||||
IotCardID: cardID,
|
||||
DeviceID: deviceID,
|
||||
CommissionSource: model.CommissionSourceOneTime,
|
||||
Amount: actualProfit,
|
||||
Status: model.CommissionStatusReleased,
|
||||
Remark: remark,
|
||||
})
|
||||
}
|
||||
|
||||
if currentShop.ParentID == nil || *currentShop.ParentID == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
parentShopID := *currentShop.ParentID
|
||||
parentSeriesAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, parentShopID, seriesID)
|
||||
if err != nil {
|
||||
s.logger.Warn("上级店铺未分配该系列,停止链式计算",
|
||||
zap.Uint("parent_shop_id", parentShopID),
|
||||
zap.Uint("series_id", seriesID))
|
||||
break
|
||||
}
|
||||
|
||||
parentShop, err := s.shopStore.GetByID(ctx, parentShopID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取上级店铺失败", zap.Uint("shop_id", parentShopID), zap.Error(err))
|
||||
break
|
||||
}
|
||||
|
||||
childAmountGiven = myAmount
|
||||
currentShopID = parentShopID
|
||||
currentShop = parentShop
|
||||
currentSeriesAllocation = parentSeriesAllocation
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Service) matchOneTimeCommissionTier(ctx context.Context, shopID uint, seriesID uint, allocationID uint, tiers []model.OneTimeCommissionTier) (int64, error) {
|
||||
if len(tiers) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
stats, err := s.commissionStatsService.GetCurrentStats(ctx, allocationID, "all_time")
|
||||
if err != nil {
|
||||
s.logger.Error("获取销售业绩统计失败", zap.Uint("allocation_id", allocationID), zap.Error(err))
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var matchedAmount int64 = 0
|
||||
|
||||
if stats == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var matchedTier *model.ShopSeriesOneTimeCommissionTier
|
||||
for _, tier := range tiers {
|
||||
var salesValue int64
|
||||
if tier.TierType == model.TierTypeSalesCount {
|
||||
salesValue = stats.TotalSalesCount
|
||||
} else if tier.TierType == model.TierTypeSalesAmount {
|
||||
salesValue = stats.TotalSalesAmount
|
||||
var salesCount, salesAmount int64
|
||||
var err error
|
||||
|
||||
if tier.StatScope == model.OneTimeCommissionStatScopeSelfAndSub {
|
||||
subordinateIDs, subErr := s.shopStore.GetSubordinateShopIDs(ctx, shopID)
|
||||
if subErr != nil {
|
||||
s.logger.Warn("获取下级店铺失败", zap.Uint("shop_id", shopID), zap.Error(subErr))
|
||||
subordinateIDs = []uint{shopID}
|
||||
}
|
||||
|
||||
allocationIDs, allocErr := s.shopSeriesAllocationStore.GetIDsByShopIDsAndSeries(ctx, subordinateIDs, seriesID)
|
||||
if allocErr != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, allocErr, "获取下级分配ID失败")
|
||||
}
|
||||
|
||||
salesCount, salesAmount, err = s.commissionStatsStore.GetAggregatedStats(ctx, allocationIDs, "monthly", now)
|
||||
} else {
|
||||
salesCount, salesAmount, err = s.commissionStatsStore.GetAggregatedStats(ctx, []uint{allocationID}, "monthly", now)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.logger.Warn("获取销售统计失败", zap.Uint("allocation_id", allocationID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if salesValue >= tier.ThresholdValue {
|
||||
if matchedTier == nil || tier.ThresholdValue > matchedTier.ThresholdValue {
|
||||
matchedTier = tier
|
||||
}
|
||||
var currentValue int64
|
||||
if tier.Dimension == model.TierTypeSalesCount {
|
||||
currentValue = salesCount
|
||||
} else {
|
||||
currentValue = salesAmount
|
||||
}
|
||||
|
||||
if currentValue >= tier.Threshold && tier.Amount > matchedAmount {
|
||||
matchedAmount = tier.Amount
|
||||
}
|
||||
}
|
||||
|
||||
if matchedTier == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if matchedTier.CommissionMode == model.CommissionModeFixed {
|
||||
return matchedTier.CommissionValue, nil
|
||||
} else if matchedTier.CommissionMode == model.CommissionModePercent {
|
||||
return orderAmount * matchedTier.CommissionValue / 1000, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
return matchedAmount, nil
|
||||
}
|
||||
|
||||
func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord) error {
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
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,
|
||||
SeriesID: &allocation.SeriesID,
|
||||
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,
|
||||
SeriesID: &allocation.SeriesID,
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ type Service struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
shopStore *postgres.ShopStore
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -31,7 +31,8 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
@@ -41,9 +42,9 @@ func New(
|
||||
iotCardStore: iotCardStore,
|
||||
shopStore: shopStore,
|
||||
assetAllocationRecordStore: assetAllocationRecordStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
shopPackageAllocationStore: shopPackageAllocationStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
shopSeriesAllocationStore: seriesAllocationStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,20 +633,27 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证操作者权限(仅代理用户)
|
||||
// 验证操作者权限(仅代理用户)- 检查是否有该系列的套餐分配
|
||||
if operatorShopID != nil && req.SeriesID > 0 {
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, *operatorShopID, req.SeriesID)
|
||||
seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, *operatorShopID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound || allocation.Status != 1 {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
})
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
hasSeriesAllocation := false
|
||||
for _, alloc := range seriesAllocations {
|
||||
if alloc.SeriesID == req.SeriesID && alloc.Status == 1 {
|
||||
hasSeriesAllocation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSeriesAllocation {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 验证设备权限(基于 device.ShopID)
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"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/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueTestDeviceNoPrefix() string {
|
||||
return fmt.Sprintf("D%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestDeviceService_BatchSetSeriesBinding(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
assetAllocationRecordStore := postgres.NewAssetAllocationRecordStore(tx, rdb)
|
||||
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
|
||||
svc := New(tx, deviceStore, deviceSimBindingStore, iotCardStore, shopStore, assetAllocationRecordStore, seriesAllocationStore, packageSeriesStore)
|
||||
ctx := context.Background()
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺",
|
||||
ShopCode: fmt.Sprintf("SHOP%d", time.Now().UnixNano()%1000000),
|
||||
Level: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES%d", time.Now().UnixNano()%1000000),
|
||||
SeriesName: "测试系列",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(series).Error)
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shop.ID,
|
||||
SeriesID: series.ID,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(allocation).Error)
|
||||
|
||||
prefix := uniqueTestDeviceNoPrefix()
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "001", DeviceName: "测试设备1", Status: 1, ShopID: &shop.ID},
|
||||
{DeviceNo: prefix + "002", DeviceName: "测试设备2", Status: 1, ShopID: &shop.ID},
|
||||
{DeviceNo: prefix + "003", DeviceName: "测试设备3", Status: 1, ShopID: nil},
|
||||
}
|
||||
require.NoError(t, deviceStore.CreateBatch(ctx, devices))
|
||||
|
||||
t.Run("成功设置系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[0].ID, devices[1].ID},
|
||||
SeriesID: allocation.SeriesID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, resp.SuccessCount)
|
||||
assert.Equal(t, 0, resp.FailCount)
|
||||
|
||||
var updatedDevices []*model.Device
|
||||
require.NoError(t, tx.Where("id IN ?", req.DeviceIDs).Find(&updatedDevices).Error)
|
||||
for _, device := range updatedDevices {
|
||||
require.NotNil(t, device.SeriesID)
|
||||
assert.Equal(t, allocation.SeriesID, *device.SeriesID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("设备不属于套餐系列分配的店铺", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[2].ID},
|
||||
SeriesID: allocation.SeriesID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "设备不属于套餐系列分配的店铺", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("设备不存在", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{99999},
|
||||
SeriesID: allocation.SeriesID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "设备不存在", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("清除系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[0].ID},
|
||||
SeriesID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.SuccessCount)
|
||||
|
||||
var updatedDevice model.Device
|
||||
require.NoError(t, tx.First(&updatedDevice, devices[0].ID).Error)
|
||||
assert.Nil(t, updatedDevice.SeriesID)
|
||||
})
|
||||
|
||||
t.Run("代理用户只能操作自己店铺的设备", func(t *testing.T) {
|
||||
otherShopID := uint(99999)
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[1].ID},
|
||||
SeriesID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, &otherShopID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "无权操作此设备", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("套餐系列分配不存在", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[1].ID},
|
||||
SeriesID: 99999,
|
||||
}
|
||||
|
||||
_, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,8 @@ type Service struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
shopStore *postgres.ShopStore
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
@@ -30,7 +31,8 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
gatewayClient *gateway.Client,
|
||||
logger *zap.Logger,
|
||||
@@ -40,7 +42,8 @@ func New(
|
||||
iotCardStore: iotCardStore,
|
||||
shopStore: shopStore,
|
||||
assetAllocationRecordStore: assetAllocationRecordStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
shopPackageAllocationStore: shopPackageAllocationStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
@@ -603,17 +606,24 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa
|
||||
|
||||
// 验证操作者权限(仅代理用户)
|
||||
if operatorShopID != nil && req.SeriesID > 0 {
|
||||
allocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, *operatorShopID, req.SeriesID)
|
||||
seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, *operatorShopID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound || allocation.Status != 1 {
|
||||
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
|
||||
ICCID: iccid,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
})
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
hasSeriesAllocation := false
|
||||
for _, alloc := range seriesAllocations {
|
||||
if alloc.SeriesID == req.SeriesID && alloc.Status == 1 {
|
||||
hasSeriesAllocation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSeriesAllocation {
|
||||
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
|
||||
ICCID: iccid,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 验证卡权限(基于 card.ShopID)
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"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/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueTestICCIDPrefix() string {
|
||||
return fmt.Sprintf("T%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestIotCardService_BatchSetSeriesBinding(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
assetAllocationRecordStore := postgres.NewAssetAllocationRecordStore(tx, rdb)
|
||||
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(tx, iotCardStore, shopStore, assetAllocationRecordStore, seriesAllocationStore, packageSeriesStore, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺",
|
||||
ShopCode: fmt.Sprintf("SHOP%d", time.Now().UnixNano()%1000000),
|
||||
Level: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES%d", time.Now().UnixNano()%1000000),
|
||||
SeriesName: "测试系列",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(series).Error)
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shop.ID,
|
||||
SeriesID: series.ID,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(allocation).Error)
|
||||
|
||||
prefix := uniqueTestICCIDPrefix()
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "001", CarrierID: 1, Status: 1, ShopID: &shop.ID},
|
||||
{ICCID: prefix + "002", CarrierID: 1, Status: 1, ShopID: &shop.ID},
|
||||
{ICCID: prefix + "003", CarrierID: 1, Status: 1, ShopID: nil},
|
||||
}
|
||||
require.NoError(t, iotCardStore.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("成功设置系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "001", prefix + "002"},
|
||||
SeriesID: allocation.SeriesID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, resp.SuccessCount)
|
||||
assert.Equal(t, 0, resp.FailCount)
|
||||
|
||||
var updatedCards []*model.IotCard
|
||||
require.NoError(t, tx.Where("iccid IN ?", req.ICCIDs).Find(&updatedCards).Error)
|
||||
for _, card := range updatedCards {
|
||||
require.NotNil(t, card.SeriesID)
|
||||
assert.Equal(t, allocation.SeriesID, *card.SeriesID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("卡不属于套餐系列分配的店铺", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "003"},
|
||||
SeriesID: allocation.SeriesID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "卡不属于套餐系列分配的店铺", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("卡不存在", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{"NOTEXIST001"},
|
||||
SeriesID: allocation.SeriesID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "卡不存在", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("清除系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "001"},
|
||||
SeriesID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.SuccessCount)
|
||||
|
||||
var updatedCard model.IotCard
|
||||
require.NoError(t, tx.Where("iccid = ?", prefix+"001").First(&updatedCard).Error)
|
||||
assert.Nil(t, updatedCard.SeriesID)
|
||||
})
|
||||
|
||||
t.Run("代理用户只能操作自己店铺的卡", func(t *testing.T) {
|
||||
otherShopID := uint(99999)
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "002"},
|
||||
SeriesID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, &otherShopID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "无权操作此卡", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("套餐系列分配不存在", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "002"},
|
||||
SeriesID: 99999,
|
||||
}
|
||||
|
||||
_, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
@@ -21,18 +20,19 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
orderStore *postgres.OrderStore
|
||||
orderItemStore *postgres.OrderItemStore
|
||||
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
|
||||
db *gorm.DB
|
||||
orderStore *postgres.OrderStore
|
||||
orderItemStore *postgres.OrderItemStore
|
||||
walletStore *postgres.WalletStore
|
||||
purchaseValidationService *purchase_validation.Service
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
wechatPayment wechat.PaymentServiceInterface
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -41,27 +41,29 @@ func New(
|
||||
orderItemStore *postgres.OrderItemStore,
|
||||
walletStore *postgres.WalletStore,
|
||||
purchaseValidationService *purchase_validation.Service,
|
||||
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
wechatPayment wechat.PaymentServiceInterface,
|
||||
queueClient *queue.Client,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
orderStore: orderStore,
|
||||
orderItemStore: orderItemStore,
|
||||
walletStore: walletStore,
|
||||
purchaseValidationService: purchaseValidationService,
|
||||
allocationConfigStore: allocationConfigStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
wechatPayment: wechatPayment,
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
db: db,
|
||||
orderStore: orderStore,
|
||||
orderItemStore: orderItemStore,
|
||||
walletStore: walletStore,
|
||||
purchaseValidationService: purchaseValidationService,
|
||||
shopPackageAllocationStore: shopPackageAllocationStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
wechatPayment: wechatPayment,
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +89,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forceRechargeCheck := s.checkForceRechargeRequirement(validationResult)
|
||||
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, 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)
|
||||
|
||||
orderBuyerType := buyerType
|
||||
orderBuyerID := buyerID
|
||||
totalAmount := validationResult.TotalPrice
|
||||
@@ -107,9 +107,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
||||
var sellerShopID *uint
|
||||
var sellerCostPrice int64
|
||||
|
||||
if validationResult.Allocation != nil {
|
||||
seriesID = &validationResult.Allocation.SeriesID
|
||||
sellerShopID = &validationResult.Allocation.ShopID
|
||||
if validationResult.Card != nil {
|
||||
seriesID = validationResult.Card.SeriesID
|
||||
sellerShopID = validationResult.Card.ShopID
|
||||
} else if validationResult.Device != nil {
|
||||
seriesID = validationResult.Device.SeriesID
|
||||
sellerShopID = validationResult.Device.ShopID
|
||||
}
|
||||
|
||||
if sellerShopID != nil && len(validationResult.Packages) > 0 {
|
||||
firstPackageID := validationResult.Packages[0].ID
|
||||
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, *sellerShopID, firstPackageID)
|
||||
if err == nil && allocation != nil {
|
||||
sellerCostPrice = allocation.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||
@@ -125,8 +136,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
||||
paidAt = purchasePaidAt
|
||||
isPurchaseOnBehalf = true
|
||||
sellerCostPrice = buyerCostPrice
|
||||
} else if validationResult.Allocation != nil {
|
||||
sellerCostPrice = utils.CalculateCostPrice(validationResult.Allocation, validationResult.TotalPrice)
|
||||
}
|
||||
|
||||
order := &model.Order{
|
||||
@@ -145,7 +154,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
||||
PaymentStatus: paymentStatus,
|
||||
PaidAt: paidAt,
|
||||
CommissionStatus: model.CommissionStatusPending,
|
||||
CommissionConfigVersion: configVersion,
|
||||
CommissionConfigVersion: 0,
|
||||
SeriesID: seriesID,
|
||||
SellerShopID: sellerShopID,
|
||||
SellerCostPrice: sellerCostPrice,
|
||||
@@ -171,24 +180,36 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
||||
|
||||
func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, int64, *time.Time, error) {
|
||||
var resourceShopID *uint
|
||||
var seriesID *uint
|
||||
|
||||
if result.Card != nil {
|
||||
resourceShopID = result.Card.ShopID
|
||||
seriesID = result.Card.SeriesID
|
||||
} else if result.Device != nil {
|
||||
resourceShopID = result.Device.ShopID
|
||||
seriesID = result.Device.SeriesID
|
||||
}
|
||||
|
||||
if resourceShopID == nil || *resourceShopID == 0 {
|
||||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购")
|
||||
}
|
||||
|
||||
buyerAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, *resourceShopID, result.Allocation.SeriesID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐系列的分配配置")
|
||||
if seriesID == nil || *seriesID == 0 {
|
||||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "资源未关联套餐系列")
|
||||
}
|
||||
|
||||
if len(result.Packages) == 0 {
|
||||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "订单中没有套餐")
|
||||
}
|
||||
|
||||
firstPackageID := result.Packages[0].ID
|
||||
buyerAllocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, *resourceShopID, firstPackageID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐的分配配置")
|
||||
}
|
||||
|
||||
buyerCostPrice := utils.CalculateCostPrice(buyerAllocation, result.TotalPrice)
|
||||
now := time.Now()
|
||||
return *resourceShopID, buyerCostPrice, &now, nil
|
||||
return *resourceShopID, buyerAllocation.CostPrice, &now, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package) []*model.OrderItem {
|
||||
@@ -524,7 +545,7 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
|
||||
OrderID: order.ID,
|
||||
PackageID: item.PackageID,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.DataAmountMB,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
ActivatedAt: now,
|
||||
ExpiresAt: now.AddDate(0, pkg.DurationMonths, 0),
|
||||
Status: 1,
|
||||
@@ -544,17 +565,6 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) snapshotCommissionConfig(ctx context.Context, allocationID uint) int {
|
||||
if s.allocationConfigStore == nil {
|
||||
return 0
|
||||
}
|
||||
config, err := s.allocationConfigStore.GetEffective(ctx, allocationID, time.Now())
|
||||
if err != nil || config == nil {
|
||||
return 0
|
||||
}
|
||||
return config.Version
|
||||
}
|
||||
|
||||
func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
|
||||
if s.queueClient == nil {
|
||||
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
|
||||
@@ -752,39 +762,62 @@ type ForceRechargeRequirement struct {
|
||||
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}
|
||||
}
|
||||
// checkForceRechargeRequirement 检查强充要求
|
||||
// 从 PackageSeries 获取一次性佣金配置,使用 per-series 追踪判断是否需要强充
|
||||
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
|
||||
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
|
||||
|
||||
// 1. 获取 seriesID
|
||||
var seriesID *uint
|
||||
var firstCommissionPaid bool
|
||||
|
||||
if result.Card != nil {
|
||||
firstCommissionPaid = result.Card.FirstCommissionPaid
|
||||
seriesID = result.Card.SeriesID
|
||||
if seriesID != nil {
|
||||
firstCommissionPaid = result.Card.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||||
}
|
||||
} 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,
|
||||
seriesID = result.Device.SeriesID
|
||||
if seriesID != nil {
|
||||
firstCommissionPaid = result.Device.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||||
}
|
||||
}
|
||||
|
||||
if allocation.EnableForceRecharge {
|
||||
forceAmount := allocation.ForceRechargeAmount
|
||||
if seriesID == nil {
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
// 2. 从 PackageSeries 获取一次性佣金配置
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
|
||||
if err != nil {
|
||||
s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err))
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
config, err := series.GetOneTimeCommissionConfig()
|
||||
if err != nil || config == nil || !config.Enable {
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
// 3. 如果该系列的一次性佣金已发放,无需强充
|
||||
if firstCommissionPaid {
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
// 4. 根据触发类型判断是否需要强充
|
||||
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
|
||||
return &ForceRechargeRequirement{
|
||||
NeedForceRecharge: true,
|
||||
ForceRechargeAmount: config.Threshold,
|
||||
TriggerType: model.OneTimeCommissionTriggerFirstRecharge,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 累计充值模式,检查是否启用强充
|
||||
if config.EnableForceRecharge {
|
||||
forceAmount := config.ForceAmount
|
||||
if forceAmount == 0 {
|
||||
forceAmount = allocation.OneTimeCommissionThreshold
|
||||
forceAmount = config.Threshold
|
||||
}
|
||||
return &ForceRechargeRequirement{
|
||||
NeedForceRecharge: true,
|
||||
@@ -793,7 +826,7 @@ func (s *Service) checkForceRechargeRequirement(result *purchase_validation.Purc
|
||||
}
|
||||
}
|
||||
|
||||
return &ForceRechargeRequirement{NeedForceRecharge: false}
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRequest) (*dto.PurchaseCheckResponse, error) {
|
||||
@@ -812,7 +845,7 @@ func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forceRechargeCheck := s.checkForceRechargeRequirement(validationResult)
|
||||
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
|
||||
|
||||
response := &dto.PurchaseCheckResponse{
|
||||
TotalPackageAmount: validationResult.TotalPrice,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -20,20 +19,17 @@ type Service struct {
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
}
|
||||
|
||||
func New(
|
||||
packageStore *postgres.PackageStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
packageStore: packageStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +44,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
return nil, errors.New(errors.CodeConflict, "套餐编码已存在")
|
||||
}
|
||||
|
||||
// 校验虚流量配置:启用时虚流量必须 > 0 且 ≤ 真流量
|
||||
if req.EnableVirtualData {
|
||||
if req.VirtualDataMB == nil || *req.VirtualDataMB <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "启用虚流量时,虚流量额度必须大于0")
|
||||
}
|
||||
realDataMB := int64(0)
|
||||
if req.RealDataMB != nil {
|
||||
realDataMB = *req.RealDataMB
|
||||
}
|
||||
if *req.VirtualDataMB > realDataMB {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "虚流量额度不能大于真流量额度")
|
||||
}
|
||||
}
|
||||
|
||||
var seriesName *string
|
||||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
@@ -61,32 +71,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
}
|
||||
|
||||
pkg := &model.Package{
|
||||
PackageCode: req.PackageCode,
|
||||
PackageName: req.PackageName,
|
||||
PackageType: req.PackageType,
|
||||
DurationMonths: req.DurationMonths,
|
||||
Price: req.Price,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
PackageCode: req.PackageCode,
|
||||
PackageName: req.PackageName,
|
||||
PackageType: req.PackageType,
|
||||
DurationMonths: req.DurationMonths,
|
||||
CostPrice: req.CostPrice,
|
||||
EnableVirtualData: req.EnableVirtualData,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
}
|
||||
if req.SeriesID != nil {
|
||||
pkg.SeriesID = *req.SeriesID
|
||||
}
|
||||
if req.DataType != nil {
|
||||
pkg.DataType = *req.DataType
|
||||
}
|
||||
if req.RealDataMB != nil {
|
||||
pkg.RealDataMB = *req.RealDataMB
|
||||
}
|
||||
if req.VirtualDataMB != nil {
|
||||
pkg.VirtualDataMB = *req.VirtualDataMB
|
||||
}
|
||||
if req.DataAmountMB != nil {
|
||||
pkg.DataAmountMB = *req.DataAmountMB
|
||||
}
|
||||
if req.SuggestedCostPrice != nil {
|
||||
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
|
||||
}
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
@@ -147,7 +149,6 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
pkg.SeriesID = *req.SeriesID
|
||||
seriesName = &series.SeriesName
|
||||
} else if pkg.SeriesID > 0 {
|
||||
// 如果没有更新 SeriesID,但现有套餐有 SeriesID,则查询当前的系列名称
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
|
||||
if err == nil {
|
||||
seriesName = &series.SeriesName
|
||||
@@ -163,27 +164,32 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
if req.DurationMonths != nil {
|
||||
pkg.DurationMonths = *req.DurationMonths
|
||||
}
|
||||
if req.DataType != nil {
|
||||
pkg.DataType = *req.DataType
|
||||
}
|
||||
if req.RealDataMB != nil {
|
||||
pkg.RealDataMB = *req.RealDataMB
|
||||
}
|
||||
if req.VirtualDataMB != nil {
|
||||
pkg.VirtualDataMB = *req.VirtualDataMB
|
||||
}
|
||||
if req.DataAmountMB != nil {
|
||||
pkg.DataAmountMB = *req.DataAmountMB
|
||||
if req.EnableVirtualData != nil {
|
||||
pkg.EnableVirtualData = *req.EnableVirtualData
|
||||
}
|
||||
if req.Price != nil {
|
||||
pkg.Price = *req.Price
|
||||
}
|
||||
if req.SuggestedCostPrice != nil {
|
||||
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
|
||||
if req.CostPrice != nil {
|
||||
pkg.CostPrice = *req.CostPrice
|
||||
}
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
|
||||
// 校验虚流量配置
|
||||
if pkg.EnableVirtualData {
|
||||
if pkg.VirtualDataMB <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "启用虚流量时,虚流量额度必须大于0")
|
||||
}
|
||||
if pkg.VirtualDataMB > pkg.RealDataMB {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "虚流量额度不能大于真流量额度")
|
||||
}
|
||||
}
|
||||
|
||||
pkg.Updater = currentUserID
|
||||
|
||||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||
@@ -246,9 +252,11 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto
|
||||
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询套餐列表失败")
|
||||
}
|
||||
|
||||
// 收集所有唯一的 series_id
|
||||
// 收集所有唯一的 series_id 和 package_id
|
||||
seriesIDMap := make(map[uint]bool)
|
||||
for _, pkg := range packages {
|
||||
packageIDs := make([]uint, len(packages))
|
||||
for i, pkg := range packages {
|
||||
packageIDs[i] = pkg.ID
|
||||
if pkg.SeriesID > 0 {
|
||||
seriesIDMap[pkg.SeriesID] = true
|
||||
}
|
||||
@@ -270,10 +278,16 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto
|
||||
}
|
||||
}
|
||||
|
||||
// 构建响应,填充系列名称
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
var allocationMap map[uint]*model.ShopPackageAllocation
|
||||
if userType == constants.UserTypeAgent && shopID > 0 && len(packageIDs) > 0 {
|
||||
allocationMap = s.batchGetAllocationsForShop(ctx, shopID, packageIDs)
|
||||
}
|
||||
|
||||
responses := make([]*dto.PackageResponse, len(packages))
|
||||
for i, pkg := range packages {
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
resp := s.toResponseWithAllocation(pkg, allocationMap)
|
||||
if pkg.SeriesID > 0 {
|
||||
if seriesName, ok := seriesMap[pkg.SeriesID]; ok {
|
||||
resp.SeriesName = &seriesName
|
||||
@@ -354,12 +368,10 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
DataType: pkg.DataType,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
DataAmountMB: pkg.DataAmountMB,
|
||||
Price: pkg.Price,
|
||||
SuggestedCostPrice: pkg.SuggestedCostPrice,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
@@ -372,34 +384,61 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
if userType == constants.UserTypeAgent && shopID > 0 {
|
||||
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
||||
if err == nil && allocation != nil {
|
||||
resp.CostPrice = &allocation.CostPrice
|
||||
resp.CostPrice = allocation.CostPrice
|
||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
|
||||
commissionInfo := s.getCommissionInfo(ctx, allocation.AllocationID)
|
||||
if commissionInfo != nil {
|
||||
resp.CurrentCommissionRate = commissionInfo.CurrentRate
|
||||
resp.TierInfo = commissionInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *Service) getCommissionInfo(ctx context.Context, allocationID uint) *dto.CommissionTierInfo {
|
||||
seriesAllocation, err := s.seriesAllocationStore.GetByID(ctx, allocationID)
|
||||
if err != nil {
|
||||
return nil
|
||||
func (s *Service) batchGetAllocationsForShop(ctx context.Context, shopID uint, packageIDs []uint) map[uint]*model.ShopPackageAllocation {
|
||||
allocationMap := make(map[uint]*model.ShopPackageAllocation)
|
||||
|
||||
allocations, err := s.packageAllocationStore.GetByShopAndPackages(ctx, shopID, packageIDs)
|
||||
if err != nil || len(allocations) == 0 {
|
||||
return allocationMap
|
||||
}
|
||||
|
||||
info := &dto.CommissionTierInfo{}
|
||||
|
||||
if seriesAllocation.BaseCommissionMode == constants.CommissionModeFixed {
|
||||
info.CurrentRate = fmt.Sprintf("%.2f元/单", float64(seriesAllocation.BaseCommissionValue)/100)
|
||||
} else {
|
||||
info.CurrentRate = fmt.Sprintf("%.1f%%", float64(seriesAllocation.BaseCommissionValue)/10)
|
||||
for _, alloc := range allocations {
|
||||
allocationMap[alloc.PackageID] = alloc
|
||||
}
|
||||
|
||||
return info
|
||||
return allocationMap
|
||||
}
|
||||
|
||||
func (s *Service) toResponseWithAllocation(pkg *model.Package, allocationMap map[uint]*model.ShopPackageAllocation) *dto.PackageResponse {
|
||||
var seriesID *uint
|
||||
if pkg.SeriesID > 0 {
|
||||
seriesID = &pkg.SeriesID
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if allocationMap != nil {
|
||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||
resp.CostPrice = allocation.CostPrice
|
||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestPackageService_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -38,7 +38,6 @@ func TestPackageService_Create(t *testing.T) {
|
||||
PackageName: "创建测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
@@ -57,7 +56,6 @@ func TestPackageService_Create(t *testing.T) {
|
||||
PackageName: "第一个套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
_, err := svc.Create(ctx, req1)
|
||||
require.NoError(t, err)
|
||||
@@ -67,7 +65,6 @@ func TestPackageService_Create(t *testing.T) {
|
||||
PackageName: "第二个套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
_, err = svc.Create(ctx, req2)
|
||||
require.Error(t, err)
|
||||
@@ -82,7 +79,6 @@ func TestPackageService_Create(t *testing.T) {
|
||||
PackageName: "系列测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
SeriesID: func() *uint { id := uint(99999); return &id }(),
|
||||
}
|
||||
|
||||
@@ -98,7 +94,7 @@ func TestPackageService_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -110,7 +106,6 @@ func TestPackageService_UpdateStatus(t *testing.T) {
|
||||
PackageName: "状态测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -138,7 +133,6 @@ func TestPackageService_UpdateStatus(t *testing.T) {
|
||||
PackageName: "启用测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created2, err := svc.Create(ctx, req2)
|
||||
require.NoError(t, err)
|
||||
@@ -168,7 +162,7 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -181,7 +175,6 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
PackageName: "上架测试-启用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -205,7 +198,6 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
PackageName: "上架测试-禁用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -230,7 +222,6 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
PackageName: "下架测试",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -255,7 +246,7 @@ func TestPackageService_Get(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -267,7 +258,6 @@ func TestPackageService_Get(t *testing.T) {
|
||||
PackageName: "查询测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -293,7 +283,7 @@ func TestPackageService_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -305,23 +295,19 @@ func TestPackageService_Update(t *testing.T) {
|
||||
PackageName: "更新测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("更新成功", func(t *testing.T) {
|
||||
newName := "更新后的套餐名称"
|
||||
newPrice := int64(19900)
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
Price: &newPrice,
|
||||
}
|
||||
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, resp.PackageName)
|
||||
assert.Equal(t, newPrice, resp.Price)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的套餐", func(t *testing.T) {
|
||||
@@ -342,7 +328,7 @@ func TestPackageService_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -354,7 +340,6 @@ func TestPackageService_Delete(t *testing.T) {
|
||||
PackageName: "删除测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -377,7 +362,7 @@ func TestPackageService_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -390,21 +375,18 @@ func TestPackageService_List(t *testing.T) {
|
||||
PackageName: "列表测试套餐1",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
},
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_002"),
|
||||
PackageName: "列表测试套餐2",
|
||||
PackageType: "addon",
|
||||
DurationMonths: 1,
|
||||
Price: 4900,
|
||||
},
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_003"),
|
||||
PackageName: "列表测试套餐3",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 12,
|
||||
Price: 99900,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -456,11 +438,118 @@ func TestPackageService_List(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_VirtualDataValidation(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时虚流量必须大于0", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_1"),
|
||||
PackageName: "虚流量测试-零值",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(0); return &v }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "虚流量额度必须大于0")
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时虚流量不能超过真流量", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_2"),
|
||||
PackageName: "虚流量测试-超过",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(2000); return &v }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "虚流量额度不能大于真流量额度")
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时配置正确则创建成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_3"),
|
||||
PackageName: "虚流量测试-正确",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(500); return &v }(),
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.EnableVirtualData)
|
||||
assert.Equal(t, int64(500), resp.VirtualDataMB)
|
||||
})
|
||||
|
||||
t.Run("不启用虚流量时可以不填虚流量值", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_4"),
|
||||
PackageName: "虚流量测试-不启用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: false,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.EnableVirtualData)
|
||||
})
|
||||
|
||||
t.Run("更新时校验虚流量配置", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_5"),
|
||||
PackageName: "虚流量测试-更新",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: false,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
enableVD := true
|
||||
virtualDataMB := int64(2000)
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
EnableVirtualData: &enableVD,
|
||||
VirtualDataMB: &virtualDataMB,
|
||||
}
|
||||
_, err = svc.Update(ctx, created.ID, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
svc := New(packageStore, packageSeriesStore, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -485,7 +574,6 @@ func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
@@ -502,7 +590,6 @@ func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -522,7 +609,6 @@ func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -547,7 +633,6 @@ func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
@@ -578,7 +663,6 @@ func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
PackageName: "无系列套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
Price: 9900,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
|
||||
@@ -35,13 +35,21 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesReques
|
||||
}
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: req.SeriesCode,
|
||||
SeriesName: req.SeriesName,
|
||||
Description: req.Description,
|
||||
Status: constants.StatusEnabled,
|
||||
SeriesCode: req.SeriesCode,
|
||||
SeriesName: req.SeriesName,
|
||||
Description: req.Description,
|
||||
Status: constants.StatusEnabled,
|
||||
OneTimeCommissionConfigJSON: "{}",
|
||||
}
|
||||
series.Creator = currentUserID
|
||||
|
||||
if req.OneTimeCommissionConfig != nil {
|
||||
config := s.dtoToModelConfig(req.OneTimeCommissionConfig)
|
||||
if err := series.SetOneTimeCommissionConfig(config); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "设置一次性佣金配置失败")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.packageSeriesStore.Create(ctx, series); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐系列失败")
|
||||
}
|
||||
@@ -80,6 +88,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSer
|
||||
if req.Description != nil {
|
||||
series.Description = *req.Description
|
||||
}
|
||||
if req.OneTimeCommissionConfig != nil {
|
||||
config := s.dtoToModelConfig(req.OneTimeCommissionConfig)
|
||||
if err := series.SetOneTimeCommissionConfig(config); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "设置一次性佣金配置失败")
|
||||
}
|
||||
}
|
||||
series.Updater = currentUserID
|
||||
|
||||
if err := s.packageSeriesStore.Update(ctx, series); err != nil {
|
||||
@@ -125,6 +139,9 @@ func (s *Service) List(ctx context.Context, req *dto.PackageSeriesListRequest) (
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
if req.EnableOneTimeCommission != nil {
|
||||
filters["enable_one_time_commission"] = *req.EnableOneTimeCommission
|
||||
}
|
||||
|
||||
seriesList, total, err := s.packageSeriesStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
@@ -164,13 +181,86 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
}
|
||||
|
||||
func (s *Service) toResponse(series *model.PackageSeries) *dto.PackageSeriesResponse {
|
||||
return &dto.PackageSeriesResponse{
|
||||
ID: series.ID,
|
||||
SeriesCode: series.SeriesCode,
|
||||
SeriesName: series.SeriesName,
|
||||
Description: series.Description,
|
||||
Status: series.Status,
|
||||
CreatedAt: series.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: series.UpdatedAt.Format(time.RFC3339),
|
||||
resp := &dto.PackageSeriesResponse{
|
||||
ID: series.ID,
|
||||
SeriesCode: series.SeriesCode,
|
||||
SeriesName: series.SeriesName,
|
||||
Description: series.Description,
|
||||
EnableOneTimeCommission: series.EnableOneTimeCommission,
|
||||
Status: series.Status,
|
||||
CreatedAt: series.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: series.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if config, err := series.GetOneTimeCommissionConfig(); err == nil && config != nil {
|
||||
resp.OneTimeCommissionConfig = s.modelToDTO(config)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *Service) dtoToModelConfig(dtoConfig *dto.SeriesOneTimeCommissionConfigDTO) *model.OneTimeCommissionConfig {
|
||||
if dtoConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tiers []model.OneTimeCommissionTier
|
||||
if len(dtoConfig.Tiers) > 0 {
|
||||
tiers = make([]model.OneTimeCommissionTier, len(dtoConfig.Tiers))
|
||||
for i, tier := range dtoConfig.Tiers {
|
||||
tiers[i] = model.OneTimeCommissionTier{
|
||||
Dimension: tier.Dimension,
|
||||
StatScope: tier.StatScope,
|
||||
Threshold: tier.Threshold,
|
||||
Amount: tier.Amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &model.OneTimeCommissionConfig{
|
||||
Enable: dtoConfig.Enable,
|
||||
TriggerType: dtoConfig.TriggerType,
|
||||
Threshold: dtoConfig.Threshold,
|
||||
CommissionType: dtoConfig.CommissionType,
|
||||
CommissionAmount: dtoConfig.CommissionAmount,
|
||||
Tiers: tiers,
|
||||
ValidityType: dtoConfig.ValidityType,
|
||||
ValidityValue: dtoConfig.ValidityValue,
|
||||
EnableForceRecharge: dtoConfig.EnableForceRecharge,
|
||||
ForceCalcType: dtoConfig.ForceCalcType,
|
||||
ForceAmount: dtoConfig.ForceAmount,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) modelToDTO(config *model.OneTimeCommissionConfig) *dto.SeriesOneTimeCommissionConfigDTO {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tiers []dto.OneTimeCommissionTierDTO
|
||||
if len(config.Tiers) > 0 {
|
||||
tiers = make([]dto.OneTimeCommissionTierDTO, len(config.Tiers))
|
||||
for i, tier := range config.Tiers {
|
||||
tiers[i] = dto.OneTimeCommissionTierDTO{
|
||||
Dimension: tier.Dimension,
|
||||
StatScope: tier.StatScope,
|
||||
Threshold: tier.Threshold,
|
||||
Amount: tier.Amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.SeriesOneTimeCommissionConfigDTO{
|
||||
Enable: config.Enable,
|
||||
TriggerType: config.TriggerType,
|
||||
Threshold: config.Threshold,
|
||||
CommissionType: config.CommissionType,
|
||||
CommissionAmount: config.CommissionAmount,
|
||||
Tiers: tiers,
|
||||
ValidityType: config.ValidityType,
|
||||
ValidityValue: config.ValidityValue,
|
||||
EnableForceRecharge: config.EnableForceRecharge,
|
||||
ForceCalcType: config.ForceCalcType,
|
||||
ForceAmount: config.ForceAmount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
packageStore *postgres.PackageStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
packageStore *postgres.PackageStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -23,14 +22,12 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
packageStore: packageStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
packageStore: packageStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +36,6 @@ type PurchaseValidationResult struct {
|
||||
Device *model.Device
|
||||
Packages []*model.Package
|
||||
TotalPrice int64
|
||||
Allocation *model.ShopSeriesAllocation
|
||||
}
|
||||
|
||||
func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package purchase_validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestData(t *testing.T) (context.Context, *Service, *model.IotCard, *model.Device, *model.Package, *model.ShopSeriesAllocation) {
|
||||
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)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "TEST_CARRIER_PV",
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, carrierStore.Create(ctx, carrier))
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺PV",
|
||||
ShopCode: "TEST_SHOP_PV",
|
||||
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_PV",
|
||||
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,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
|
||||
|
||||
pkg := &model.Package{
|
||||
PackageCode: "TEST_PKG_PV",
|
||||
PackageName: "测试套餐",
|
||||
SeriesID: series.ID,
|
||||
SuggestedRetailPrice: 9900,
|
||||
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: "89860000000000000001",
|
||||
ShopID: shopIDPtr,
|
||||
CarrierID: carrier.ID,
|
||||
SeriesID: &series.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, iotCardStore.Create(ctx, card))
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: "DEV_TEST_PV_001",
|
||||
ShopID: shopIDPtr,
|
||||
SeriesID: &series.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, deviceStore.Create(ctx, device))
|
||||
|
||||
svc := New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
||||
|
||||
return ctx, svc, card, device, pkg, allocation
|
||||
}
|
||||
|
||||
func TestPurchaseValidationService_ValidateCardPurchase(t *testing.T) {
|
||||
ctx, svc, card, _, pkg, _ := setupTestData(t)
|
||||
|
||||
t.Run("验证成功", func(t *testing.T) {
|
||||
result, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{pkg.ID})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result.Card)
|
||||
assert.Equal(t, card.ID, result.Card.ID)
|
||||
assert.Len(t, result.Packages, 1)
|
||||
assert.Equal(t, pkg.SuggestedRetailPrice, result.TotalPrice)
|
||||
})
|
||||
|
||||
t.Run("卡不存在", func(t *testing.T) {
|
||||
_, err := svc.ValidateCardPurchase(ctx, 99999, []uint{pkg.ID})
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeIotCardNotFound, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("套餐列表为空", func(t *testing.T) {
|
||||
_, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{})
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("套餐不存在", func(t *testing.T) {
|
||||
_, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{99999})
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurchaseValidationService_ValidateDevicePurchase(t *testing.T) {
|
||||
ctx, svc, _, device, pkg, _ := setupTestData(t)
|
||||
|
||||
t.Run("验证成功", func(t *testing.T) {
|
||||
result, err := svc.ValidateDevicePurchase(ctx, device.ID, []uint{pkg.ID})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result.Device)
|
||||
assert.Equal(t, device.ID, result.Device.ID)
|
||||
assert.Len(t, result.Packages, 1)
|
||||
assert.Equal(t, pkg.SuggestedRetailPrice, result.TotalPrice)
|
||||
})
|
||||
|
||||
t.Run("设备不存在", func(t *testing.T) {
|
||||
_, err := svc.ValidateDevicePurchase(ctx, 99999, []uint{pkg.ID})
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("套餐列表为空", func(t *testing.T) {
|
||||
_, err := svc.ValidateDevicePurchase(ctx, device.ID, []uint{})
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurchaseValidationService_GetPurchasePrice(t *testing.T) {
|
||||
ctx, svc, _, _, pkg, _ := setupTestData(t)
|
||||
|
||||
t.Run("获取个人客户价格", func(t *testing.T) {
|
||||
price := svc.GetPurchasePrice(ctx, pkg, model.BuyerTypePersonal)
|
||||
assert.Equal(t, pkg.SuggestedRetailPrice, price)
|
||||
})
|
||||
|
||||
t.Run("获取代理商价格", func(t *testing.T) {
|
||||
price := svc.GetPurchasePrice(ctx, pkg, model.BuyerTypeAgent)
|
||||
assert.Equal(t, pkg.SuggestedRetailPrice, price)
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,7 @@ type Service struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
@@ -51,6 +52,7 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
commissionRecordStore *postgres.CommissionRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
@@ -62,6 +64,7 @@ func New(
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
commissionRecordStore: commissionRecordStore,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -379,7 +382,6 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceTyp
|
||||
var accumulatedRecharge int64
|
||||
var firstCommissionPaid bool
|
||||
|
||||
// 1. 查询资源信息
|
||||
if resourceType == "iot_card" {
|
||||
card, err := s.iotCardStore.GetByID(ctx, resourceID)
|
||||
if err != nil {
|
||||
@@ -390,8 +392,10 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceTyp
|
||||
}
|
||||
seriesID = card.SeriesID
|
||||
shopID = card.ShopID
|
||||
accumulatedRecharge = card.AccumulatedRecharge
|
||||
firstCommissionPaid = card.FirstCommissionPaid
|
||||
if seriesID != nil {
|
||||
accumulatedRecharge = card.GetAccumulatedRechargeBySeries(*seriesID)
|
||||
firstCommissionPaid = card.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||||
}
|
||||
} else if resourceType == "device" {
|
||||
device, err := s.deviceStore.GetByID(ctx, resourceID)
|
||||
if err != nil {
|
||||
@@ -402,80 +406,101 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceTyp
|
||||
}
|
||||
seriesID = device.SeriesID
|
||||
shopID = device.ShopID
|
||||
accumulatedRecharge = device.AccumulatedRecharge
|
||||
firstCommissionPaid = device.FirstCommissionPaid
|
||||
if seriesID != nil {
|
||||
accumulatedRecharge = device.GetAccumulatedRechargeBySeries(*seriesID)
|
||||
firstCommissionPaid = device.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||||
}
|
||||
}
|
||||
|
||||
result.CurrentAccumulated = accumulatedRecharge
|
||||
result.FirstCommissionPaid = firstCommissionPaid
|
||||
|
||||
// 2. 如果没有系列ID或店铺ID,无强充要求
|
||||
if seriesID == nil || shopID == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. 查询系列分配配置
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, *shopID, *seriesID)
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return result, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败")
|
||||
}
|
||||
|
||||
// 4. 如果未启用一次性佣金,无强充要求
|
||||
if !allocation.EnableOneTimeCommission {
|
||||
config, err := series.GetOneTimeCommissionConfig()
|
||||
if err != nil || config == nil || !config.Enable {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Threshold = allocation.OneTimeCommissionThreshold
|
||||
result.TriggerType = allocation.OneTimeCommissionTrigger
|
||||
result.Threshold = config.Threshold
|
||||
result.TriggerType = config.TriggerType
|
||||
|
||||
// 5. 如果一次性佣金已发放,无强充要求
|
||||
if firstCommissionPaid {
|
||||
result.Message = "一次性佣金已发放,无强充要求"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 6. 根据触发类型判断强充要求
|
||||
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerSingleRecharge {
|
||||
// 首次充值触发:必须充值阈值金额
|
||||
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
|
||||
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)
|
||||
result.ForceRechargeAmount = config.Threshold
|
||||
result.Message = fmt.Sprintf("首次充值必须充值%d分", config.Threshold)
|
||||
} else if config.EnableForceRecharge {
|
||||
result.NeedForceRecharge = true
|
||||
if config.ForceAmount > 0 {
|
||||
result.ForceRechargeAmount = config.ForceAmount
|
||||
} else {
|
||||
result.Message = "累计充值模式,可自由充值"
|
||||
result.ForceRechargeAmount = config.Threshold
|
||||
}
|
||||
result.Message = fmt.Sprintf("每次充值必须充值%d分", result.ForceRechargeAmount)
|
||||
} else {
|
||||
result.Message = "累计充值模式,可自由充值"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// updateAccumulatedRechargeInTx 更新累计充值(事务内使用)
|
||||
// 原子操作更新卡或设备的累计充值金额
|
||||
// 同时更新旧的 accumulated_recharge 字段和新的 accumulated_recharge_by_series JSON 字段
|
||||
func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, amount int64) error {
|
||||
if resourceType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.First(&card, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||
}
|
||||
|
||||
if card.SeriesID != nil {
|
||||
if err := card.AddAccumulatedRechargeBySeries(*card.SeriesID, amount); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新卡按系列累计充值失败")
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(&model.IotCard{}).
|
||||
Where("id = ?", resourceID).
|
||||
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount))
|
||||
Updates(map[string]any{
|
||||
"accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount),
|
||||
"accumulated_recharge_by_series": card.AccumulatedRechargeBySeriesJSON,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡累计充值失败")
|
||||
}
|
||||
} else if resourceType == "device" {
|
||||
var device model.Device
|
||||
if err := tx.First(&device, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
|
||||
if device.SeriesID != nil {
|
||||
if err := device.AddAccumulatedRechargeBySeries(*device.SeriesID, amount); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新设备按系列累计充值失败")
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(&model.Device{}).
|
||||
Where("id = ?", resourceID).
|
||||
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount))
|
||||
Updates(map[string]any{
|
||||
"accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount),
|
||||
"accumulated_recharge_by_series": device.AccumulatedRechargeBySeriesJSON,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新设备累计充值失败")
|
||||
}
|
||||
@@ -491,33 +516,34 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
|
||||
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卡失败")
|
||||
}
|
||||
seriesID = card.SeriesID
|
||||
accumulatedRecharge = card.AccumulatedRecharge
|
||||
firstCommissionPaid = card.FirstCommissionPaid
|
||||
shopID = card.ShopID
|
||||
if seriesID != nil {
|
||||
accumulatedRecharge = card.GetAccumulatedRechargeBySeries(*seriesID)
|
||||
firstCommissionPaid = card.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||||
}
|
||||
} else if resourceType == "device" {
|
||||
var device model.Device
|
||||
if err := tx.First(&device, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
seriesID = device.SeriesID
|
||||
accumulatedRecharge = device.AccumulatedRecharge
|
||||
firstCommissionPaid = device.FirstCommissionPaid
|
||||
shopID = device.ShopID
|
||||
if seriesID != nil {
|
||||
accumulatedRecharge = device.GetAccumulatedRechargeBySeries(*seriesID)
|
||||
firstCommissionPaid = device.IsFirstRechargeTriggeredBySeries(*seriesID)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果没有系列ID或已发放佣金,跳过
|
||||
if seriesID == nil || firstCommissionPaid {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 如果没有归属店铺,无法发放佣金
|
||||
if shopID == nil {
|
||||
s.logger.Warn("资源未归属店铺,无法发放一次性佣金",
|
||||
zap.String("resource_type", resourceType),
|
||||
@@ -526,7 +552,19 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 查询系列分配配置
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败")
|
||||
}
|
||||
|
||||
config, cfgErr := series.GetOneTimeCommissionConfig()
|
||||
if cfgErr != nil || config == nil || !config.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, *shopID, *seriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -535,34 +573,23 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败")
|
||||
}
|
||||
|
||||
// 5. 如果未启用一次性佣金,跳过
|
||||
if !allocation.EnableOneTimeCommission {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 6. 根据触发类型判断是否满足条件
|
||||
var rechargeAmountToCheck int64
|
||||
switch allocation.OneTimeCommissionTrigger {
|
||||
case model.OneTimeCommissionTriggerSingleRecharge:
|
||||
switch config.TriggerType {
|
||||
case model.OneTimeCommissionTriggerFirstRecharge:
|
||||
rechargeAmountToCheck = rechargeAmount
|
||||
case model.OneTimeCommissionTriggerAccumulatedRecharge:
|
||||
rechargeAmountToCheck = accumulatedRecharge
|
||||
default:
|
||||
rechargeAmountToCheck = accumulatedRecharge
|
||||
}
|
||||
|
||||
if rechargeAmountToCheck < config.Threshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 7. 检查是否达到阈值
|
||||
if rechargeAmountToCheck < allocation.OneTimeCommissionThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. 计算佣金金额
|
||||
commissionAmount := s.calculateOneTimeCommission(allocation, rechargeAmount)
|
||||
commissionAmount := allocation.OneTimeCommissionAmount
|
||||
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 {
|
||||
@@ -575,7 +602,6 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺佣金钱包失败")
|
||||
}
|
||||
|
||||
// 10. 创建佣金记录
|
||||
var iotCardID, deviceID *uint
|
||||
if resourceType == "iot_card" {
|
||||
iotCardID = &resourceID
|
||||
@@ -647,13 +673,33 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
|
||||
|
||||
// 14. 标记一次性佣金已发放
|
||||
if resourceType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.First(&card, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||
}
|
||||
if err := card.SetFirstRechargeTriggeredBySeries(*seriesID, true); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "设置卡佣金发放状态失败")
|
||||
}
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", resourceID).
|
||||
Update("first_commission_paid", true).Error; err != nil {
|
||||
Updates(map[string]any{
|
||||
"first_commission_paid": true,
|
||||
"first_recharge_triggered_by_series": card.FirstRechargeTriggeredBySeriesJSON,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败")
|
||||
}
|
||||
} else {
|
||||
var device model.Device
|
||||
if err := tx.First(&device, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
if err := device.SetFirstRechargeTriggeredBySeries(*seriesID, true); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "设置设备佣金发放状态失败")
|
||||
}
|
||||
if err := tx.Model(&model.Device{}).Where("id = ?", resourceID).
|
||||
Update("first_commission_paid", true).Error; err != nil {
|
||||
Updates(map[string]any{
|
||||
"first_commission_paid": true,
|
||||
"first_recharge_triggered_by_series": device.FirstRechargeTriggeredBySeriesJSON,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败")
|
||||
}
|
||||
}
|
||||
@@ -668,21 +714,6 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *
|
||||
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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ type Service struct {
|
||||
priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore
|
||||
shopStore *postgres.ShopStore
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -28,6 +29,7 @@ func New(
|
||||
priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
@@ -35,6 +37,7 @@ func New(
|
||||
priceHistoryStore: priceHistoryStore,
|
||||
shopStore: shopStore,
|
||||
packageStore: packageStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,25 +76,26 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopPackageAllocati
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
|
||||
existing, _ := s.packageAllocationStore.GetByShopAndPackage(ctx, req.ShopID, req.PackageID)
|
||||
if existing != nil {
|
||||
return nil, errors.New(errors.CodeConflict, "该店铺已有此套餐的分配配置")
|
||||
}
|
||||
|
||||
seriesAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, pkg.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeForbidden, "该套餐的系列未分配给此店铺")
|
||||
return nil, errors.New(errors.CodeInvalidParam, "请先分配该套餐所属的系列")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取系列分配失败")
|
||||
}
|
||||
|
||||
existing, _ := s.packageAllocationStore.GetByShopAndPackage(ctx, req.ShopID, req.PackageID)
|
||||
if existing != nil {
|
||||
return nil, errors.New(errors.CodeConflict, "该店铺已有此套餐的覆盖配置")
|
||||
}
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: req.ShopID,
|
||||
PackageID: req.PackageID,
|
||||
AllocationID: seriesAllocation.ID,
|
||||
CostPrice: req.CostPrice,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: req.ShopID,
|
||||
PackageID: req.PackageID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: req.CostPrice,
|
||||
SeriesAllocationID: &seriesAllocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
allocation.Creator = currentUserID
|
||||
|
||||
@@ -204,6 +208,12 @@ func (s *Service) List(ctx context.Context, req *dto.ShopPackageAllocationListRe
|
||||
if req.PackageID != nil {
|
||||
filters["package_id"] = *req.PackageID
|
||||
}
|
||||
if req.SeriesAllocationID != nil {
|
||||
filters["series_allocation_id"] = *req.SeriesAllocationID
|
||||
}
|
||||
if req.AllocatorShopID != nil {
|
||||
filters["allocator_shop_id"] = *req.AllocatorShopID
|
||||
}
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
@@ -258,19 +268,44 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
}
|
||||
|
||||
func (s *Service) buildResponse(ctx context.Context, a *model.ShopPackageAllocation, shopName, packageName, packageCode string) (*dto.ShopPackageAllocationResponse, error) {
|
||||
var seriesID uint
|
||||
seriesName := ""
|
||||
|
||||
pkg, _ := s.packageStore.GetByID(ctx, a.PackageID)
|
||||
if pkg != nil {
|
||||
seriesID = pkg.SeriesID
|
||||
series, _ := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
|
||||
if series != nil {
|
||||
seriesName = series.SeriesName
|
||||
}
|
||||
}
|
||||
|
||||
allocatorShopName := ""
|
||||
if a.AllocatorShopID > 0 {
|
||||
allocatorShop, _ := s.shopStore.GetByID(ctx, a.AllocatorShopID)
|
||||
if allocatorShop != nil {
|
||||
allocatorShopName = allocatorShop.ShopName
|
||||
}
|
||||
} else {
|
||||
allocatorShopName = "平台"
|
||||
}
|
||||
|
||||
return &dto.ShopPackageAllocationResponse{
|
||||
ID: a.ID,
|
||||
ShopID: a.ShopID,
|
||||
ShopName: shopName,
|
||||
PackageID: a.PackageID,
|
||||
PackageName: packageName,
|
||||
PackageCode: packageCode,
|
||||
AllocationID: a.AllocationID,
|
||||
CostPrice: a.CostPrice,
|
||||
CalculatedCostPrice: 0,
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: a.UpdatedAt.Format(time.RFC3339),
|
||||
ID: a.ID,
|
||||
ShopID: a.ShopID,
|
||||
ShopName: shopName,
|
||||
PackageID: a.PackageID,
|
||||
PackageName: packageName,
|
||||
PackageCode: packageCode,
|
||||
SeriesID: seriesID,
|
||||
SeriesName: seriesName,
|
||||
SeriesAllocationID: a.SeriesAllocationID,
|
||||
AllocatorShopID: a.AllocatorShopID,
|
||||
AllocatorShopName: allocatorShopName,
|
||||
CostPrice: a.CostPrice,
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: a.UpdatedAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package shop_package_batch_allocation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
@@ -16,29 +15,23 @@ import (
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
packageStore *postgres.PackageStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
configStore *postgres.ShopSeriesAllocationConfigStore
|
||||
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
shopStore *postgres.ShopStore
|
||||
}
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
packageStore *postgres.PackageStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
configStore *postgres.ShopSeriesAllocationConfigStore,
|
||||
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
packageStore: packageStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
configStore: configStore,
|
||||
commissionStatsStore: commissionStatsStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
shopStore: shopStore,
|
||||
}
|
||||
}
|
||||
@@ -79,48 +72,31 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
||||
return errors.New(errors.CodeInvalidParam, "该系列下没有启用的套餐")
|
||||
}
|
||||
|
||||
// 检查目标店铺是否有该系列的分配
|
||||
seriesAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, req.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInvalidParam, "目标店铺没有该系列的分配权限")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询系列分配失败")
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
seriesAllocation := &model.ShopSeriesAllocation{
|
||||
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
||||
ShopID: req.ShopID,
|
||||
SeriesID: req.SeriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
BaseCommissionMode: req.BaseCommission.Mode,
|
||||
BaseCommissionValue: req.BaseCommission.Value,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
|
||||
if err := tx.Create(seriesAllocation).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建系列分配失败")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
config := &model.ShopSeriesAllocationConfig{
|
||||
AllocationID: seriesAllocation.ID,
|
||||
Version: 1,
|
||||
BaseCommissionMode: req.BaseCommission.Mode,
|
||||
BaseCommissionValue: req.BaseCommission.Value,
|
||||
EffectiveFrom: now,
|
||||
}
|
||||
|
||||
if err := tx.Create(config).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建配置版本失败")
|
||||
}
|
||||
|
||||
packageAllocations := make([]*model.ShopPackageAllocation, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
costPrice := pkg.SuggestedCostPrice
|
||||
costPrice := pkg.CostPrice
|
||||
if req.PriceAdjustment != nil {
|
||||
costPrice = s.calculateAdjustedPrice(pkg.SuggestedCostPrice, req.PriceAdjustment)
|
||||
costPrice = s.calculateAdjustedPrice(pkg.CostPrice, req.PriceAdjustment)
|
||||
}
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
||||
ShopID: req.ShopID,
|
||||
PackageID: pkg.ID,
|
||||
AllocationID: seriesAllocation.ID,
|
||||
CostPrice: costPrice,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
||||
ShopID: req.ShopID,
|
||||
PackageID: pkg.ID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: costPrice,
|
||||
SeriesAllocationID: &seriesAllocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
packageAllocations = append(packageAllocations, allocation)
|
||||
}
|
||||
|
||||
@@ -10,34 +10,29 @@ import (
|
||||
"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"
|
||||
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
allocationStore *postgres.ShopSeriesAllocationStore
|
||||
configStore *postgres.ShopSeriesAllocationConfigStore
|
||||
oneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore
|
||||
shopStore *postgres.ShopStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
packageStore *postgres.PackageStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
shopStore *postgres.ShopStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
}
|
||||
|
||||
func New(
|
||||
allocationStore *postgres.ShopSeriesAllocationStore,
|
||||
configStore *postgres.ShopSeriesAllocationConfigStore,
|
||||
oneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
allocationStore: allocationStore,
|
||||
configStore: configStore,
|
||||
oneTimeCommissionTierStore: oneTimeCommissionTierStore,
|
||||
shopStore: shopStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
packageStore: packageStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
shopStore: shopStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +57,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocatio
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
|
||||
}
|
||||
|
||||
isPlatformUser := userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
|
||||
isFirstLevelShop := targetShop.ParentID == nil
|
||||
|
||||
if isPlatformUser {
|
||||
if !isFirstLevelShop {
|
||||
return nil, errors.New(errors.CodeForbidden, "平台只能为一级店铺分配套餐")
|
||||
}
|
||||
} else {
|
||||
if isFirstLevelShop || *targetShop.ParentID != allocatorShopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "只能为直属下级分配套餐")
|
||||
if userType == constants.UserTypeAgent {
|
||||
if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "只能为直属下级分配套餐系列")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,49 +71,54 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocatio
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||||
}
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
myAllocation, err := s.allocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查分配权限失败")
|
||||
}
|
||||
if myAllocation == nil || myAllocation.Status != constants.StatusEnabled {
|
||||
return nil, errors.New(errors.CodeForbidden, "您没有该套餐系列的分配权限")
|
||||
}
|
||||
// 检查是否已存在分配(跳过数据权限过滤,避免误判)
|
||||
skipCtx := pkggorm.SkipDataPermission(ctx)
|
||||
exists, err := s.seriesAllocationStore.ExistsByShopAndSeries(skipCtx, req.ShopID, req.SeriesID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "检查分配记录失败")
|
||||
}
|
||||
|
||||
existing, _ := s.allocationStore.GetByShopAndSeries(ctx, req.ShopID, req.SeriesID)
|
||||
if existing != nil {
|
||||
if exists {
|
||||
return nil, errors.New(errors.CodeConflict, "该店铺已分配此套餐系列")
|
||||
}
|
||||
|
||||
if err := s.validateOneTimeCommissionConfig(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: req.ShopID,
|
||||
SeriesID: req.SeriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
BaseCommissionMode: req.BaseCommission.Mode,
|
||||
BaseCommissionValue: req.BaseCommission.Value,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
|
||||
// 处理一次性佣金配置
|
||||
allocation.EnableOneTimeCommission = req.EnableOneTimeCommission
|
||||
if req.EnableOneTimeCommission && req.OneTimeCommissionConfig != nil {
|
||||
cfg := req.OneTimeCommissionConfig
|
||||
allocation.OneTimeCommissionType = cfg.Type
|
||||
allocation.OneTimeCommissionTrigger = cfg.Trigger
|
||||
allocation.OneTimeCommissionThreshold = cfg.Threshold
|
||||
// fixed 类型需要保存 mode 和 value
|
||||
if cfg.Type == model.OneTimeCommissionTypeFixed {
|
||||
allocation.OneTimeCommissionMode = cfg.Mode
|
||||
allocation.OneTimeCommissionValue = cfg.Value
|
||||
// 代理用户:检查自己是否有该系列的分配权限,且金额不能超过上级给的上限
|
||||
// 平台用户:无上限限制,可自由设定金额
|
||||
if userType == constants.UserTypeAgent {
|
||||
allocatorAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(skipCtx, allocatorShopID, req.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeForbidden, "您没有该套餐系列的分配权限")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配权限失败")
|
||||
}
|
||||
if req.OneTimeCommissionAmount > allocatorAllocation.OneTimeCommissionAmount {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "一次性佣金金额不能超过您的分配上限")
|
||||
}
|
||||
}
|
||||
|
||||
// 处理强充配置
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: req.ShopID,
|
||||
SeriesID: req.SeriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
OneTimeCommissionAmount: req.OneTimeCommissionAmount,
|
||||
EnableOneTimeCommission: false,
|
||||
OneTimeCommissionTrigger: "",
|
||||
OneTimeCommissionThreshold: 0,
|
||||
EnableForceRecharge: false,
|
||||
ForceRechargeAmount: 0,
|
||||
ForceRechargeTriggerType: 2,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
|
||||
if req.EnableOneTimeCommission != nil {
|
||||
allocation.EnableOneTimeCommission = *req.EnableOneTimeCommission
|
||||
}
|
||||
if req.OneTimeCommissionTrigger != "" {
|
||||
allocation.OneTimeCommissionTrigger = req.OneTimeCommissionTrigger
|
||||
}
|
||||
if req.OneTimeCommissionThreshold != nil {
|
||||
allocation.OneTimeCommissionThreshold = *req.OneTimeCommissionThreshold
|
||||
}
|
||||
if req.EnableForceRecharge != nil {
|
||||
allocation.EnableForceRecharge = *req.EnableForceRecharge
|
||||
}
|
||||
@@ -138,23 +131,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocatio
|
||||
|
||||
allocation.Creator = currentUserID
|
||||
|
||||
if err := s.allocationStore.Create(ctx, allocation); err != nil {
|
||||
if err := s.seriesAllocationStore.Create(ctx, allocation); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建分配失败")
|
||||
}
|
||||
|
||||
// 如果是梯度类型,保存梯度配置
|
||||
if req.EnableOneTimeCommission && req.OneTimeCommissionConfig != nil &&
|
||||
req.OneTimeCommissionConfig.Type == model.OneTimeCommissionTypeTiered {
|
||||
if err := s.saveOneTimeCommissionTiers(ctx, allocation.ID, req.OneTimeCommissionConfig.Tiers, currentUserID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建一次性佣金梯度配置失败")
|
||||
}
|
||||
}
|
||||
|
||||
return s.buildResponse(ctx, allocation, targetShop.ShopName, series.SeriesName)
|
||||
return s.buildResponse(ctx, allocation, targetShop.ShopName, series.SeriesName, series.SeriesCode)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.ShopSeriesAllocationResponse, error) {
|
||||
allocation, err := s.allocationStore.GetByID(ctx, id)
|
||||
allocation, err := s.seriesAllocationStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
|
||||
@@ -167,14 +152,16 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.ShopSeriesAllocationRe
|
||||
|
||||
shopName := ""
|
||||
seriesName := ""
|
||||
seriesCode := ""
|
||||
if shop != nil {
|
||||
shopName = shop.ShopName
|
||||
}
|
||||
if series != nil {
|
||||
seriesName = series.SeriesName
|
||||
seriesCode = series.SeriesCode
|
||||
}
|
||||
|
||||
return s.buildResponse(ctx, allocation, shopName, seriesName)
|
||||
return s.buildResponse(ctx, allocation, shopName, seriesName, seriesCode)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesAllocationRequest) (*dto.ShopSeriesAllocationResponse, error) {
|
||||
@@ -183,7 +170,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
allocation, err := s.allocationStore.GetByID(ctx, id)
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
allocatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
|
||||
allocation, err := s.seriesAllocationStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
|
||||
@@ -191,52 +181,27 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||
}
|
||||
|
||||
configChanged := false
|
||||
if req.BaseCommission != nil {
|
||||
if allocation.BaseCommissionMode != req.BaseCommission.Mode ||
|
||||
allocation.BaseCommissionValue != req.BaseCommission.Value {
|
||||
configChanged = true
|
||||
if req.OneTimeCommissionAmount != nil {
|
||||
newAmount := *req.OneTimeCommissionAmount
|
||||
if userType == constants.UserTypeAgent {
|
||||
allocatorAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, allocation.SeriesID)
|
||||
if err == nil && allocatorAllocation != nil {
|
||||
if newAmount > allocatorAllocation.OneTimeCommissionAmount {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "一次性佣金金额不能超过您的分配上限")
|
||||
}
|
||||
}
|
||||
}
|
||||
allocation.BaseCommissionMode = req.BaseCommission.Mode
|
||||
allocation.BaseCommissionValue = req.BaseCommission.Value
|
||||
allocation.OneTimeCommissionAmount = newAmount
|
||||
}
|
||||
|
||||
enableOneTimeCommission := allocation.EnableOneTimeCommission
|
||||
if req.EnableOneTimeCommission != nil {
|
||||
enableOneTimeCommission = *req.EnableOneTimeCommission
|
||||
}
|
||||
if err := s.validateOneTimeCommissionConfigForUpdate(enableOneTimeCommission, req.OneTimeCommissionConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oneTimeCommissionChanged := false
|
||||
if req.EnableOneTimeCommission != nil {
|
||||
if allocation.EnableOneTimeCommission != *req.EnableOneTimeCommission {
|
||||
oneTimeCommissionChanged = true
|
||||
}
|
||||
allocation.EnableOneTimeCommission = *req.EnableOneTimeCommission
|
||||
}
|
||||
if req.OneTimeCommissionConfig != nil && allocation.EnableOneTimeCommission {
|
||||
cfg := req.OneTimeCommissionConfig
|
||||
if allocation.OneTimeCommissionType != cfg.Type ||
|
||||
allocation.OneTimeCommissionTrigger != cfg.Trigger ||
|
||||
allocation.OneTimeCommissionThreshold != cfg.Threshold ||
|
||||
allocation.OneTimeCommissionMode != cfg.Mode ||
|
||||
allocation.OneTimeCommissionValue != cfg.Value {
|
||||
oneTimeCommissionChanged = true
|
||||
}
|
||||
allocation.OneTimeCommissionType = cfg.Type
|
||||
allocation.OneTimeCommissionTrigger = cfg.Trigger
|
||||
allocation.OneTimeCommissionThreshold = cfg.Threshold
|
||||
if cfg.Type == model.OneTimeCommissionTypeFixed {
|
||||
allocation.OneTimeCommissionMode = cfg.Mode
|
||||
allocation.OneTimeCommissionValue = cfg.Value
|
||||
} else {
|
||||
allocation.OneTimeCommissionMode = ""
|
||||
allocation.OneTimeCommissionValue = 0
|
||||
}
|
||||
if req.OneTimeCommissionTrigger != nil {
|
||||
allocation.OneTimeCommissionTrigger = *req.OneTimeCommissionTrigger
|
||||
}
|
||||
if req.OneTimeCommissionThreshold != nil {
|
||||
allocation.OneTimeCommissionThreshold = *req.OneTimeCommissionThreshold
|
||||
}
|
||||
|
||||
if req.EnableForceRecharge != nil {
|
||||
allocation.EnableForceRecharge = *req.EnableForceRecharge
|
||||
}
|
||||
@@ -246,46 +211,36 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
||||
if req.ForceRechargeTriggerType != nil {
|
||||
allocation.ForceRechargeTriggerType = *req.ForceRechargeTriggerType
|
||||
}
|
||||
if req.Status != nil {
|
||||
allocation.Status = *req.Status
|
||||
}
|
||||
|
||||
allocation.Updater = currentUserID
|
||||
|
||||
if configChanged {
|
||||
if err := s.createNewConfigVersion(ctx, allocation); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建配置版本失败")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.allocationStore.Update(ctx, allocation); err != nil {
|
||||
if err := s.seriesAllocationStore.Update(ctx, allocation); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新分配失败")
|
||||
}
|
||||
|
||||
if oneTimeCommissionChanged && req.OneTimeCommissionConfig != nil &&
|
||||
req.OneTimeCommissionConfig.Type == model.OneTimeCommissionTypeTiered {
|
||||
if err := s.oneTimeCommissionTierStore.DeleteByAllocationID(ctx, allocation.ID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "清理旧梯度配置失败")
|
||||
}
|
||||
if err := s.saveOneTimeCommissionTiers(ctx, allocation.ID, req.OneTimeCommissionConfig.Tiers, currentUserID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新一次性佣金梯度配置失败")
|
||||
}
|
||||
}
|
||||
|
||||
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
|
||||
series, _ := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
|
||||
|
||||
shopName := ""
|
||||
seriesName := ""
|
||||
seriesCode := ""
|
||||
if shop != nil {
|
||||
shopName = shop.ShopName
|
||||
}
|
||||
if series != nil {
|
||||
seriesName = series.SeriesName
|
||||
seriesCode = series.SeriesCode
|
||||
}
|
||||
|
||||
return s.buildResponse(ctx, allocation, shopName, seriesName)
|
||||
return s.buildResponse(ctx, allocation, shopName, seriesName, seriesCode)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
allocation, err := s.allocationStore.GetByID(ctx, id)
|
||||
skipCtx := pkggorm.SkipDataPermission(ctx)
|
||||
_, err := s.seriesAllocationStore.GetByID(skipCtx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "分配记录不存在")
|
||||
@@ -293,15 +248,15 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||
}
|
||||
|
||||
hasDependent, err := s.allocationStore.HasDependentAllocations(ctx, allocation.ShopID, allocation.SeriesID)
|
||||
count, err := s.packageAllocationStore.CountBySeriesAllocationID(skipCtx, id)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查依赖关系失败")
|
||||
return errors.Wrap(errors.CodeInternalError, err, "检查关联套餐分配失败")
|
||||
}
|
||||
if hasDependent {
|
||||
return errors.New(errors.CodeConflict, "存在下级依赖,无法删除")
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "存在关联的套餐分配,无法删除")
|
||||
}
|
||||
|
||||
if err := s.allocationStore.Delete(ctx, id); err != nil {
|
||||
if err := s.seriesAllocationStore.Delete(skipCtx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除分配失败")
|
||||
}
|
||||
|
||||
@@ -309,9 +264,6 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, req *dto.ShopSeriesAllocationListRequest) ([]*dto.ShopSeriesAllocationResponse, int64, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
|
||||
opts := &store.QueryOptions{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
@@ -331,14 +283,14 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesAllocationListReq
|
||||
if req.SeriesID != nil {
|
||||
filters["series_id"] = *req.SeriesID
|
||||
}
|
||||
if req.AllocatorShopID != nil {
|
||||
filters["allocator_shop_id"] = *req.AllocatorShopID
|
||||
}
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
if shopID > 0 && userType == constants.UserTypeAgent {
|
||||
filters["allocator_shop_id"] = shopID
|
||||
}
|
||||
|
||||
allocations, total, err := s.allocationStore.List(ctx, opts, filters)
|
||||
allocations, total, err := s.seriesAllocationStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询分配列表失败")
|
||||
}
|
||||
@@ -350,233 +302,55 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesAllocationListReq
|
||||
|
||||
shopName := ""
|
||||
seriesName := ""
|
||||
seriesCode := ""
|
||||
if shop != nil {
|
||||
shopName = shop.ShopName
|
||||
}
|
||||
if series != nil {
|
||||
seriesName = series.SeriesName
|
||||
seriesCode = series.SeriesCode
|
||||
}
|
||||
|
||||
resp, _ := s.buildResponse(ctx, a, shopName, seriesName)
|
||||
resp, _ := s.buildResponse(ctx, a, shopName, seriesName, seriesCode)
|
||||
responses[i] = resp
|
||||
}
|
||||
|
||||
return responses, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
_, err := s.allocationStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "分配记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||
}
|
||||
|
||||
if err := s.allocationStore.UpdateStatus(ctx, id, status, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetParentCostPrice(ctx context.Context, shopID, packageID uint) (int64, error) {
|
||||
pkg, err := s.packageStore.GetByID(ctx, packageID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
|
||||
shop, err := s.shopStore.GetByID(ctx, shopID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
|
||||
}
|
||||
|
||||
if shop.ParentID == nil || *shop.ParentID == 0 {
|
||||
return pkg.SuggestedCostPrice, nil
|
||||
}
|
||||
|
||||
return 0, errors.New(errors.CodeInvalidParam, "自动计算成本价功能已移除,请手动设置成本价")
|
||||
}
|
||||
|
||||
func (s *Service) buildResponse(ctx context.Context, a *model.ShopSeriesAllocation, shopName, seriesName string) (*dto.ShopSeriesAllocationResponse, error) {
|
||||
allocatorShop, _ := s.shopStore.GetByID(ctx, a.AllocatorShopID)
|
||||
func (s *Service) buildResponse(ctx context.Context, a *model.ShopSeriesAllocation, shopName, seriesName, seriesCode string) (*dto.ShopSeriesAllocationResponse, error) {
|
||||
allocatorShopName := ""
|
||||
if allocatorShop != nil {
|
||||
allocatorShopName = allocatorShop.ShopName
|
||||
}
|
||||
|
||||
resp := &dto.ShopSeriesAllocationResponse{
|
||||
ID: a.ID,
|
||||
ShopID: a.ShopID,
|
||||
ShopName: shopName,
|
||||
SeriesID: a.SeriesID,
|
||||
SeriesName: seriesName,
|
||||
AllocatorShopID: a.AllocatorShopID,
|
||||
AllocatorShopName: allocatorShopName,
|
||||
BaseCommission: dto.BaseCommissionConfig{
|
||||
Mode: a.BaseCommissionMode,
|
||||
Value: a.BaseCommissionValue,
|
||||
},
|
||||
EnableOneTimeCommission: a.EnableOneTimeCommission,
|
||||
EnableForceRecharge: a.EnableForceRecharge,
|
||||
ForceRechargeAmount: a.ForceRechargeAmount,
|
||||
ForceRechargeTriggerType: a.ForceRechargeTriggerType,
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: a.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if a.EnableOneTimeCommission {
|
||||
cfg := &dto.OneTimeCommissionConfig{
|
||||
Type: a.OneTimeCommissionType,
|
||||
Trigger: a.OneTimeCommissionTrigger,
|
||||
Threshold: a.OneTimeCommissionThreshold,
|
||||
Mode: a.OneTimeCommissionMode,
|
||||
Value: a.OneTimeCommissionValue,
|
||||
if a.AllocatorShopID > 0 {
|
||||
allocatorShop, _ := s.shopStore.GetByID(ctx, a.AllocatorShopID)
|
||||
if allocatorShop != nil {
|
||||
allocatorShopName = allocatorShop.ShopName
|
||||
}
|
||||
if a.OneTimeCommissionType == model.OneTimeCommissionTypeTiered {
|
||||
tiers, err := s.oneTimeCommissionTierStore.ListByAllocationID(ctx, a.ID)
|
||||
if err == nil && len(tiers) > 0 {
|
||||
cfg.Tiers = make([]dto.OneTimeCommissionTierEntry, len(tiers))
|
||||
for i, t := range tiers {
|
||||
cfg.Tiers[i] = dto.OneTimeCommissionTierEntry{
|
||||
TierType: t.TierType,
|
||||
Threshold: t.ThresholdValue,
|
||||
Mode: t.CommissionMode,
|
||||
Value: t.CommissionValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.OneTimeCommissionConfig = cfg
|
||||
} else {
|
||||
allocatorShopName = "平台"
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
return &dto.ShopSeriesAllocationResponse{
|
||||
ID: a.ID,
|
||||
ShopID: a.ShopID,
|
||||
ShopName: shopName,
|
||||
SeriesID: a.SeriesID,
|
||||
SeriesName: seriesName,
|
||||
SeriesCode: seriesCode,
|
||||
AllocatorShopID: a.AllocatorShopID,
|
||||
AllocatorShopName: allocatorShopName,
|
||||
OneTimeCommissionAmount: a.OneTimeCommissionAmount,
|
||||
EnableOneTimeCommission: a.EnableOneTimeCommission,
|
||||
OneTimeCommissionTrigger: a.OneTimeCommissionTrigger,
|
||||
OneTimeCommissionThreshold: a.OneTimeCommissionThreshold,
|
||||
EnableForceRecharge: a.EnableForceRecharge,
|
||||
ForceRechargeAmount: a.ForceRechargeAmount,
|
||||
ForceRechargeTriggerType: a.ForceRechargeTriggerType,
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: a.UpdatedAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) createNewConfigVersion(ctx context.Context, allocation *model.ShopSeriesAllocation) error {
|
||||
now := time.Now()
|
||||
|
||||
if err := s.configStore.InvalidateCurrent(ctx, allocation.ID, now); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "失效当前配置版本失败")
|
||||
}
|
||||
|
||||
latestVersion, err := s.configStore.GetLatestVersion(ctx, allocation.ID)
|
||||
newVersion := 1
|
||||
if err == nil && latestVersion != nil {
|
||||
newVersion = latestVersion.Version + 1
|
||||
}
|
||||
|
||||
newConfig := &model.ShopSeriesAllocationConfig{
|
||||
AllocationID: allocation.ID,
|
||||
Version: newVersion,
|
||||
BaseCommissionMode: allocation.BaseCommissionMode,
|
||||
BaseCommissionValue: allocation.BaseCommissionValue,
|
||||
EffectiveFrom: now,
|
||||
}
|
||||
|
||||
if err := s.configStore.Create(ctx, newConfig); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建新配置版本失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validateOneTimeCommissionConfig(req *dto.CreateShopSeriesAllocationRequest) error {
|
||||
if !req.EnableOneTimeCommission {
|
||||
return nil
|
||||
}
|
||||
if req.OneTimeCommissionConfig == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "启用一次性佣金时必须提供配置")
|
||||
}
|
||||
cfg := req.OneTimeCommissionConfig
|
||||
if cfg.Type == model.OneTimeCommissionTypeFixed {
|
||||
if cfg.Mode == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣模式")
|
||||
}
|
||||
if cfg.Value <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣金额")
|
||||
}
|
||||
} else if cfg.Type == model.OneTimeCommissionTypeTiered {
|
||||
if len(cfg.Tiers) == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "梯度类型一次性佣金必须提供梯度档位")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validateOneTimeCommissionConfigForUpdate(enableOneTimeCommission bool, cfg *dto.OneTimeCommissionConfig) error {
|
||||
if !enableOneTimeCommission {
|
||||
return nil
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.Type == model.OneTimeCommissionTypeFixed {
|
||||
if cfg.Mode == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣模式")
|
||||
}
|
||||
if cfg.Value <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣金额")
|
||||
}
|
||||
} else if cfg.Type == model.OneTimeCommissionTypeTiered {
|
||||
if len(cfg.Tiers) == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "梯度类型一次性佣金必须提供梯度档位")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) saveOneTimeCommissionTiers(ctx context.Context, allocationID uint, tiers []dto.OneTimeCommissionTierEntry, userID uint) error {
|
||||
if len(tiers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tierModels := make([]*model.ShopSeriesOneTimeCommissionTier, len(tiers))
|
||||
for i, t := range tiers {
|
||||
tierModels[i] = &model.ShopSeriesOneTimeCommissionTier{
|
||||
AllocationID: allocationID,
|
||||
TierType: t.TierType,
|
||||
ThresholdValue: t.Threshold,
|
||||
CommissionMode: t.Mode,
|
||||
CommissionValue: t.Value,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
tierModels[i].Creator = userID
|
||||
}
|
||||
|
||||
return s.oneTimeCommissionTierStore.BatchCreate(ctx, tierModels)
|
||||
}
|
||||
|
||||
func (s *Service) GetEffectiveConfig(ctx context.Context, allocationID uint, at time.Time) (*model.ShopSeriesAllocationConfig, error) {
|
||||
config, err := s.configStore.GetEffective(ctx, allocationID, at)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "未找到生效的配置版本")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取生效配置失败")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListConfigVersions(ctx context.Context, allocationID uint) ([]*model.ShopSeriesAllocationConfig, error) {
|
||||
_, err := s.allocationStore.GetByID(ctx, allocationID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||
}
|
||||
|
||||
configs, err := s.configStore.List(ctx, allocationID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取配置版本列表失败")
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
func (s *Service) GetByShopAndSeries(ctx context.Context, shopID, seriesID uint) (*model.ShopSeriesAllocation, error) {
|
||||
return s.seriesAllocationStore.GetByShopAndSeries(ctx, shopID, seriesID)
|
||||
}
|
||||
|
||||
@@ -203,3 +203,12 @@ func (s *DeviceStore) ListBySeriesID(ctx context.Context, seriesID uint) ([]*mod
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (s *DeviceStore) UpdateRechargeTrackingFields(ctx context.Context, deviceID uint, accumulatedJSON, triggeredJSON string) error {
|
||||
return s.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Where("id = ?", deviceID).
|
||||
Updates(map[string]interface{}{
|
||||
"accumulated_recharge_by_series": accumulatedJSON,
|
||||
"first_recharge_triggered_by_series": triggeredJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -401,3 +401,12 @@ func (s *IotCardStore) ListBySeriesID(ctx context.Context, seriesID uint) ([]*mo
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) UpdateRechargeTrackingFields(ctx context.Context, cardID uint, accumulatedJSON, triggeredJSON string) error {
|
||||
return s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", cardID).
|
||||
Updates(map[string]interface{}{
|
||||
"accumulated_recharge_by_series": accumulatedJSON,
|
||||
"first_recharge_triggered_by_series": triggeredJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ func (s *PackageSeriesStore) List(ctx context.Context, opts *store.QueryOptions,
|
||||
if status, ok := filters["status"]; ok {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if enableOneTime, ok := filters["enable_one_time_commission"].(bool); ok {
|
||||
query = query.Where("enable_one_time_commission = ?", enableOneTime)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -18,17 +18,16 @@ func TestPackageStore_Create(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
pkg := &model.Package{
|
||||
PackageCode: "PKG_TEST_001",
|
||||
PackageName: "测试套餐",
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 1024,
|
||||
DataAmountMB: 1024,
|
||||
Price: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
PackageCode: "PKG_TEST_001",
|
||||
PackageName: "测试套餐",
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
RealDataMB: 1024,
|
||||
CostPrice: 9900,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, pkg)
|
||||
@@ -47,12 +46,12 @@ func TestPackageStore_GetByID(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 2048,
|
||||
DataAmountMB: 2048,
|
||||
Price: 19900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 2048,
|
||||
CostPrice: 19900,
|
||||
SuggestedRetailPrice: 25800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, pkg))
|
||||
|
||||
@@ -79,12 +78,12 @@ func TestPackageStore_GetByCode(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 3072,
|
||||
DataAmountMB: 3072,
|
||||
Price: 29900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 3072,
|
||||
CostPrice: 29900,
|
||||
SuggestedRetailPrice: 39800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, pkg))
|
||||
|
||||
@@ -111,24 +110,24 @@ func TestPackageStore_Update(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 4096,
|
||||
DataAmountMB: 4096,
|
||||
Price: 39900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 4096,
|
||||
CostPrice: 39900,
|
||||
SuggestedRetailPrice: 49800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, pkg))
|
||||
|
||||
pkg.PackageName = "测试套餐4-更新"
|
||||
pkg.Price = 49900
|
||||
pkg.CostPrice = 49900
|
||||
err := s.Update(ctx, pkg)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.GetByID(ctx, pkg.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "测试套餐4-更新", updated.PackageName)
|
||||
assert.Equal(t, int64(49900), updated.Price)
|
||||
assert.Equal(t, int64(49900), updated.CostPrice)
|
||||
}
|
||||
|
||||
func TestPackageStore_Delete(t *testing.T) {
|
||||
@@ -142,12 +141,12 @@ func TestPackageStore_Delete(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 1024,
|
||||
DataAmountMB: 1024,
|
||||
Price: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 1024,
|
||||
CostPrice: 9900,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, pkg))
|
||||
|
||||
@@ -170,12 +169,12 @@ func TestPackageStore_List(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 1024,
|
||||
DataAmountMB: 1024,
|
||||
Price: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 1024,
|
||||
CostPrice: 9900,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
},
|
||||
{
|
||||
PackageCode: "LIST_P_002",
|
||||
@@ -183,12 +182,12 @@ func TestPackageStore_List(t *testing.T) {
|
||||
SeriesID: 2,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 12,
|
||||
DataType: "real",
|
||||
RealDataMB: 10240,
|
||||
DataAmountMB: 10240,
|
||||
Price: 99900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 10240,
|
||||
CostPrice: 99900,
|
||||
SuggestedRetailPrice: 129800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
},
|
||||
{
|
||||
PackageCode: "LIST_P_003",
|
||||
@@ -196,12 +195,12 @@ func TestPackageStore_List(t *testing.T) {
|
||||
SeriesID: 3,
|
||||
PackageType: "addon",
|
||||
DurationMonths: 1,
|
||||
DataType: "virtual",
|
||||
VirtualDataMB: 5120,
|
||||
DataAmountMB: 5120,
|
||||
Price: 4900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
|
||||
VirtualDataMB: 5120,
|
||||
CostPrice: 4900,
|
||||
SuggestedRetailPrice: 6800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
},
|
||||
}
|
||||
for _, pkg := range pkgList {
|
||||
@@ -286,12 +285,12 @@ func TestPackageStore_UpdateStatus(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 1024,
|
||||
DataAmountMB: 1024,
|
||||
Price: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 1024,
|
||||
CostPrice: 9900,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, pkg))
|
||||
|
||||
@@ -314,12 +313,12 @@ func TestPackageStore_UpdateShelfStatus(t *testing.T) {
|
||||
SeriesID: 1,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 1024,
|
||||
DataAmountMB: 1024,
|
||||
Price: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
|
||||
RealDataMB: 1024,
|
||||
CostPrice: 9900,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, pkg))
|
||||
|
||||
|
||||
@@ -56,8 +56,11 @@ func (s *ShopPackageAllocationStore) List(ctx context.Context, opts *store.Query
|
||||
if packageID, ok := filters["package_id"].(uint); ok && packageID > 0 {
|
||||
query = query.Where("package_id = ?", packageID)
|
||||
}
|
||||
if allocationID, ok := filters["allocation_id"].(uint); ok && allocationID > 0 {
|
||||
query = query.Where("allocation_id = ?", allocationID)
|
||||
if seriesAllocationID, ok := filters["series_allocation_id"].(uint); ok && seriesAllocationID > 0 {
|
||||
query = query.Where("series_allocation_id = ?", seriesAllocationID)
|
||||
}
|
||||
if allocatorShopID, ok := filters["allocator_shop_id"].(uint); ok {
|
||||
query = query.Where("allocator_shop_id = ?", allocatorShopID)
|
||||
}
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
@@ -102,8 +105,33 @@ func (s *ShopPackageAllocationStore) GetByShopID(ctx context.Context, shopID uin
|
||||
return allocations, nil
|
||||
}
|
||||
|
||||
func (s *ShopPackageAllocationStore) DeleteByAllocationID(ctx context.Context, allocationID uint) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Where("allocation_id = ?", allocationID).
|
||||
Delete(&model.ShopPackageAllocation{}).Error
|
||||
func (s *ShopPackageAllocationStore) GetByShopAndPackages(ctx context.Context, shopID uint, packageIDs []uint) ([]*model.ShopPackageAllocation, error) {
|
||||
var allocations []*model.ShopPackageAllocation
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("shop_id = ? AND package_id IN ? AND status = 1", shopID, packageIDs).
|
||||
Find(&allocations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return allocations, nil
|
||||
}
|
||||
|
||||
func (s *ShopPackageAllocationStore) GetBySeriesAllocationID(ctx context.Context, seriesAllocationID uint) ([]*model.ShopPackageAllocation, error) {
|
||||
var allocations []*model.ShopPackageAllocation
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("series_allocation_id = ? AND status = 1", seriesAllocationID).
|
||||
Find(&allocations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return allocations, nil
|
||||
}
|
||||
|
||||
func (s *ShopPackageAllocationStore) CountBySeriesAllocationID(ctx context.Context, seriesAllocationID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.ShopPackageAllocation{}).
|
||||
Where("series_allocation_id = ?", seriesAllocationID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ func TestShopPackageAllocationStore_Create(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: 1,
|
||||
PackageID: 1,
|
||||
AllocationID: 1,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: 1,
|
||||
PackageID: 1,
|
||||
AllocatorShopID: 0,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, allocation)
|
||||
@@ -36,11 +36,11 @@ func TestShopPackageAllocationStore_GetByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: 2,
|
||||
PackageID: 2,
|
||||
AllocationID: 1,
|
||||
CostPrice: 6000,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: 2,
|
||||
PackageID: 2,
|
||||
AllocatorShopID: 0,
|
||||
CostPrice: 6000,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
@@ -64,11 +64,11 @@ func TestShopPackageAllocationStore_GetByShopAndPackage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: 3,
|
||||
PackageID: 3,
|
||||
AllocationID: 1,
|
||||
CostPrice: 7000,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: 3,
|
||||
PackageID: 3,
|
||||
AllocatorShopID: 0,
|
||||
CostPrice: 7000,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
@@ -92,11 +92,11 @@ func TestShopPackageAllocationStore_Update(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: 4,
|
||||
PackageID: 4,
|
||||
AllocationID: 1,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: 4,
|
||||
PackageID: 4,
|
||||
AllocatorShopID: 0,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
@@ -115,11 +115,11 @@ func TestShopPackageAllocationStore_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: 5,
|
||||
PackageID: 5,
|
||||
AllocationID: 1,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: 5,
|
||||
PackageID: 5,
|
||||
AllocatorShopID: 0,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
@@ -136,9 +136,9 @@ func TestShopPackageAllocationStore_List(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocations := []*model.ShopPackageAllocation{
|
||||
{ShopID: 10, PackageID: 10, AllocationID: 1, CostPrice: 5000, Status: constants.StatusEnabled},
|
||||
{ShopID: 11, PackageID: 11, AllocationID: 1, CostPrice: 6000, Status: constants.StatusEnabled},
|
||||
{ShopID: 12, PackageID: 12, AllocationID: 2, CostPrice: 7000, Status: constants.StatusEnabled},
|
||||
{ShopID: 10, PackageID: 10, AllocatorShopID: 0, CostPrice: 5000, Status: constants.StatusEnabled},
|
||||
{ShopID: 11, PackageID: 11, AllocatorShopID: 0, CostPrice: 6000, Status: constants.StatusEnabled},
|
||||
{ShopID: 12, PackageID: 12, AllocatorShopID: 10, CostPrice: 7000, Status: constants.StatusEnabled},
|
||||
}
|
||||
for _, a := range allocations {
|
||||
require.NoError(t, s.Create(ctx, a))
|
||||
@@ -173,16 +173,6 @@ func TestShopPackageAllocationStore_List(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按分配ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"allocation_id": uint(1)}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(2))
|
||||
for _, a := range result {
|
||||
assert.Equal(t, uint(1), a.AllocationID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤-启用状态值为1", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"status": 1}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
@@ -223,11 +213,11 @@ func TestShopPackageAllocationStore_UpdateStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: 20,
|
||||
PackageID: 20,
|
||||
AllocationID: 1,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
ShopID: 20,
|
||||
PackageID: 20,
|
||||
AllocatorShopID: 0,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ShopSeriesAllocationConfigStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewShopSeriesAllocationConfigStore(db *gorm.DB) *ShopSeriesAllocationConfigStore {
|
||||
return &ShopSeriesAllocationConfigStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationConfigStore) Create(ctx context.Context, config *model.ShopSeriesAllocationConfig) error {
|
||||
return s.db.WithContext(ctx).Create(config).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationConfigStore) GetEffective(ctx context.Context, allocationID uint, at time.Time) (*model.ShopSeriesAllocationConfig, error) {
|
||||
var config model.ShopSeriesAllocationConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("allocation_id = ?", allocationID).
|
||||
Where("effective_from <= ?", at).
|
||||
Where("effective_to IS NULL OR effective_to > ?", at).
|
||||
First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationConfigStore) GetLatestVersion(ctx context.Context, allocationID uint) (*model.ShopSeriesAllocationConfig, error) {
|
||||
var config model.ShopSeriesAllocationConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("allocation_id = ?", allocationID).
|
||||
Order("version DESC").
|
||||
First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationConfigStore) InvalidateCurrent(ctx context.Context, allocationID uint, effectiveTo time.Time) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.ShopSeriesAllocationConfig{}).
|
||||
Where("allocation_id = ? AND effective_to IS NULL", allocationID).
|
||||
Update("effective_to", effectiveTo).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationConfigStore) List(ctx context.Context, allocationID uint) ([]*model.ShopSeriesAllocationConfig, error) {
|
||||
var configs []*model.ShopSeriesAllocationConfig
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("allocation_id = ?", allocationID).
|
||||
Order("version DESC").
|
||||
Find(&configs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
@@ -30,7 +30,9 @@ func (s *ShopSeriesAllocationStore) GetByID(ctx context.Context, id uint) (*mode
|
||||
|
||||
func (s *ShopSeriesAllocationStore) GetByShopAndSeries(ctx context.Context, shopID, seriesID uint) (*model.ShopSeriesAllocation, error) {
|
||||
var allocation model.ShopSeriesAllocation
|
||||
if err := s.db.WithContext(ctx).Where("shop_id = ? AND series_id = ?", shopID, seriesID).First(&allocation).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("shop_id = ? AND series_id = ?", shopID, seriesID).
|
||||
First(&allocation).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &allocation, nil
|
||||
@@ -56,7 +58,7 @@ func (s *ShopSeriesAllocationStore) List(ctx context.Context, opts *store.QueryO
|
||||
if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 {
|
||||
query = query.Where("series_id = ?", seriesID)
|
||||
}
|
||||
if allocatorShopID, ok := filters["allocator_shop_id"].(uint); ok && allocatorShopID > 0 {
|
||||
if allocatorShopID, ok := filters["allocator_shop_id"].(uint); ok {
|
||||
query = query.Where("allocator_shop_id = ?", allocatorShopID)
|
||||
}
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
@@ -75,6 +77,8 @@ func (s *ShopSeriesAllocationStore) List(ctx context.Context, opts *store.QueryO
|
||||
|
||||
if opts.OrderBy != "" {
|
||||
query = query.Order(opts.OrderBy)
|
||||
} else {
|
||||
query = query.Order("id DESC")
|
||||
}
|
||||
|
||||
if err := query.Find(&allocations).Error; err != nil {
|
||||
@@ -94,31 +98,58 @@ func (s *ShopSeriesAllocationStore) UpdateStatus(ctx context.Context, id uint, s
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationStore) HasDependentAllocations(ctx context.Context, allocatorShopID, seriesID uint) (bool, error) {
|
||||
func (s *ShopSeriesAllocationStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.ShopSeriesAllocation, error) {
|
||||
var allocations []*model.ShopSeriesAllocation
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = 1", shopID).
|
||||
Find(&allocations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return allocations, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationStore) CountBySeriesID(ctx context.Context, seriesID uint) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.ShopSeriesAllocation{}).
|
||||
Where("allocator_shop_id IN (SELECT id FROM tb_shop WHERE parent_id = ?)", allocatorShopID).
|
||||
Where("series_id = ?", seriesID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationStore) ExistsByShopAndSeries(ctx context.Context, shopID, seriesID uint) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.ShopSeriesAllocation{}).
|
||||
Where("shop_id = ? AND series_id = ?", shopID, seriesID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.ShopSeriesAllocation, error) {
|
||||
func (s *ShopSeriesAllocationStore) GetByAllocatorShopID(ctx context.Context, allocatorShopID uint) ([]*model.ShopSeriesAllocation, error) {
|
||||
var allocations []*model.ShopSeriesAllocation
|
||||
if err := s.db.WithContext(ctx).Where("shop_id = ? AND status = 1", shopID).Find(&allocations).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("allocator_shop_id = ? AND status = 1", allocatorShopID).
|
||||
Find(&allocations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return allocations, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesAllocationStore) GetByAllocatorShopID(ctx context.Context, allocatorShopID uint) ([]*model.ShopSeriesAllocation, error) {
|
||||
var allocations []*model.ShopSeriesAllocation
|
||||
if err := s.db.WithContext(ctx).Where("allocator_shop_id = ?", allocatorShopID).Find(&allocations).Error; err != nil {
|
||||
func (s *ShopSeriesAllocationStore) GetIDsByShopIDsAndSeries(ctx context.Context, shopIDs []uint, seriesID uint) ([]uint, error) {
|
||||
if len(shopIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var ids []uint
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.ShopSeriesAllocation{}).
|
||||
Where("shop_id IN ? AND series_id = ? AND status = 1", shopIDs, seriesID).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return allocations, nil
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestShopSeriesAllocationStore_GetByShopAndSeries(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s := NewShopSeriesAllocationStore(tx)
|
||||
|
||||
// 创建测试数据
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: 1,
|
||||
SeriesID: 100,
|
||||
AllocatorShopID: 0,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
t.Run("查询存在的分配", func(t *testing.T) {
|
||||
result, err := s.GetByShopAndSeries(ctx, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, uint(1), result.ShopID)
|
||||
assert.Equal(t, uint(100), result.SeriesID)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的分配", func(t *testing.T) {
|
||||
result, err := s.GetByShopAndSeries(ctx, 999, 999)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocationStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s := NewShopSeriesAllocationStore(tx)
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: 1,
|
||||
SeriesID: 100,
|
||||
AllocatorShopID: 0,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, allocation)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, allocation.ID)
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocationStore_GetByID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s := NewShopSeriesAllocationStore(tx)
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: 1,
|
||||
SeriesID: 100,
|
||||
AllocatorShopID: 0,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, allocation))
|
||||
|
||||
result, err := s.GetByID(ctx, allocation.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, allocation.ID, result.ID)
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocationStore_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s := NewShopSeriesAllocationStore(tx)
|
||||
|
||||
// 创建测试数据
|
||||
allocations := []*model.ShopSeriesAllocation{
|
||||
{ShopID: 1, SeriesID: 100, AllocatorShopID: 0, Status: 1},
|
||||
{ShopID: 1, SeriesID: 101, AllocatorShopID: 0, Status: 1},
|
||||
{ShopID: 2, SeriesID: 100, AllocatorShopID: 0, Status: 1},
|
||||
}
|
||||
for _, a := range allocations {
|
||||
require.NoError(t, s.Create(ctx, a))
|
||||
}
|
||||
|
||||
t.Run("按店铺ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"shop_id": uint(1)}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
|
||||
t.Run("按系列ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"series_id": uint(100)}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
}
|
||||
@@ -68,3 +68,29 @@ func (s *ShopSeriesCommissionStatsStore) ListExpired(ctx context.Context, before
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesCommissionStatsStore) GetAggregatedStats(ctx context.Context, allocationIDs []uint, periodType string, now time.Time) (int64, int64, error) {
|
||||
if len(allocationIDs) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TotalSalesCount int64
|
||||
TotalSalesAmount int64
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&model.ShopSeriesCommissionStats{}).
|
||||
Select("COALESCE(SUM(total_sales_count), 0) as total_sales_count, COALESCE(SUM(total_sales_amount), 0) as total_sales_amount").
|
||||
Where("allocation_id IN ?", allocationIDs).
|
||||
Where("period_type = ?", periodType).
|
||||
Where("period_start <= ? AND period_end >= ?", now, now).
|
||||
Where("status = ?", model.StatsStatusActive).
|
||||
Scan(&result).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return result.TotalSalesCount, result.TotalSalesAmount, nil
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ShopSeriesOneTimeCommissionTierStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewShopSeriesOneTimeCommissionTierStore(db *gorm.DB) *ShopSeriesOneTimeCommissionTierStore {
|
||||
return &ShopSeriesOneTimeCommissionTierStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) Create(ctx context.Context, tier *model.ShopSeriesOneTimeCommissionTier) error {
|
||||
return s.db.WithContext(ctx).Create(tier).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) BatchCreate(ctx context.Context, tiers []*model.ShopSeriesOneTimeCommissionTier) error {
|
||||
if len(tiers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Create(&tiers).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) GetByID(ctx context.Context, id uint) (*model.ShopSeriesOneTimeCommissionTier, error) {
|
||||
var tier model.ShopSeriesOneTimeCommissionTier
|
||||
if err := s.db.WithContext(ctx).First(&tier, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tier, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) Update(ctx context.Context, tier *model.ShopSeriesOneTimeCommissionTier) error {
|
||||
return s.db.WithContext(ctx).Save(tier).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.ShopSeriesOneTimeCommissionTier{}, id).Error
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) ListByAllocationID(ctx context.Context, allocationID uint) ([]*model.ShopSeriesOneTimeCommissionTier, error) {
|
||||
var tiers []*model.ShopSeriesOneTimeCommissionTier
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("allocation_id = ?", allocationID).
|
||||
Where("status = ?", 1).
|
||||
Order("threshold_value ASC").
|
||||
Find(&tiers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tiers, nil
|
||||
}
|
||||
|
||||
func (s *ShopSeriesOneTimeCommissionTierStore) DeleteByAllocationID(ctx context.Context, allocationID uint) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Where("allocation_id = ?", allocationID).
|
||||
Delete(&model.ShopSeriesOneTimeCommissionTier{}).Error
|
||||
}
|
||||
@@ -23,14 +23,14 @@ type CommissionStatsUpdatePayload struct {
|
||||
type CommissionStatsUpdateHandler struct {
|
||||
redis *redis.Client
|
||||
statsStore *postgres.ShopSeriesCommissionStatsStore
|
||||
allocationStore *postgres.ShopSeriesAllocationStore
|
||||
allocationStore *postgres.ShopPackageAllocationStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewCommissionStatsUpdateHandler(
|
||||
redis *redis.Client,
|
||||
statsStore *postgres.ShopSeriesCommissionStatsStore,
|
||||
allocationStore *postgres.ShopSeriesAllocationStore,
|
||||
allocationStore *postgres.ShopPackageAllocationStore,
|
||||
logger *zap.Logger,
|
||||
) *CommissionStatsUpdateHandler {
|
||||
return &CommissionStatsUpdateHandler{
|
||||
|
||||
95
migrations/000042_refactor_commission_package_model.down.sql
Normal file
95
migrations/000042_refactor_commission_package_model.down.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- 套餐与佣金模型重构 - 回滚
|
||||
-- 注意:此回滚会丢失新增字段的数据
|
||||
|
||||
-- ============================================
|
||||
-- 7. ShopSeriesAllocation 表恢复废弃字段
|
||||
-- ============================================
|
||||
|
||||
-- 移除新增的字段
|
||||
ALTER TABLE tb_shop_series_allocation
|
||||
DROP COLUMN IF EXISTS one_time_commission_amount;
|
||||
|
||||
-- 恢复一次性佣金完整配置字段
|
||||
ALTER TABLE tb_shop_series_allocation
|
||||
ADD COLUMN IF NOT EXISTS enable_one_time_commission BOOLEAN DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_type VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_trigger VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_threshold BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_mode VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_value BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS enable_force_recharge BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS force_recharge_amount BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS force_recharge_trigger_type INT DEFAULT 2;
|
||||
|
||||
-- ============================================
|
||||
-- 6. ShopSeriesOneTimeCommissionTier 表移除统计范围
|
||||
-- ============================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'tb_shop_series_one_time_commission_tier'
|
||||
) THEN
|
||||
ALTER TABLE tb_shop_series_one_time_commission_tier
|
||||
DROP COLUMN IF EXISTS stat_scope;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================
|
||||
-- 5. PackageSeries 表移除一次性佣金规则配置
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_package_series
|
||||
DROP COLUMN IF EXISTS one_time_commission_config;
|
||||
|
||||
-- ============================================
|
||||
-- 4. Device 表移除追踪字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_device
|
||||
DROP COLUMN IF EXISTS accumulated_recharge_by_series,
|
||||
DROP COLUMN IF EXISTS first_recharge_triggered_by_series;
|
||||
|
||||
-- ============================================
|
||||
-- 3. IoTCard 表移除追踪字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_iot_card
|
||||
DROP COLUMN IF EXISTS accumulated_recharge_by_series,
|
||||
DROP COLUMN IF EXISTS first_recharge_triggered_by_series;
|
||||
|
||||
-- ============================================
|
||||
-- 2. ShopPackageAllocation 表移除字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS one_time_commission_amount;
|
||||
|
||||
-- ============================================
|
||||
-- 1. Package 表恢复废弃字段
|
||||
-- ============================================
|
||||
|
||||
-- 移除虚流量开关字段
|
||||
ALTER TABLE tb_package
|
||||
DROP COLUMN IF EXISTS enable_virtual_data;
|
||||
|
||||
-- 恢复 cost_price 为 suggested_cost_price
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tb_package' AND column_name = 'cost_price'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tb_package' AND column_name = 'suggested_cost_price'
|
||||
) THEN
|
||||
ALTER TABLE tb_package RENAME COLUMN cost_price TO suggested_cost_price;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 恢复废弃字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS price BIGINT DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS data_type VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS data_amount_mb BIGINT DEFAULT 0;
|
||||
126
migrations/000042_refactor_commission_package_model.up.sql
Normal file
126
migrations/000042_refactor_commission_package_model.up.sql
Normal file
@@ -0,0 +1,126 @@
|
||||
-- 套餐与佣金模型重构
|
||||
-- 重构说明:
|
||||
-- 1. Package 表:移除废弃字段,新增虚流量开关
|
||||
-- 2. ShopPackageAllocation 表:新增一次性佣金金额字段
|
||||
-- 3. IoTCard/Device 表:新增按系列追踪的累计充值和首充状态字段
|
||||
-- 4. PackageSeries 表:新增一次性佣金规则配置字段
|
||||
-- 5. ShopSeriesOneTimeCommissionTier 表:新增统计范围字段
|
||||
-- 6. ShopSeriesAllocation 表:移除完整一次性佣金配置,改为只存金额
|
||||
|
||||
-- ============================================
|
||||
-- 1. Package 表调整
|
||||
-- ============================================
|
||||
|
||||
-- 移除废弃字段
|
||||
ALTER TABLE tb_package
|
||||
DROP COLUMN IF EXISTS price,
|
||||
DROP COLUMN IF EXISTS data_type,
|
||||
DROP COLUMN IF EXISTS data_amount_mb;
|
||||
|
||||
-- 新增虚流量开关字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_package.enable_virtual_data IS '是否启用虚流量';
|
||||
|
||||
-- 重命名 suggested_cost_price 为 cost_price(如果存在的话,通过 RENAME 实现)
|
||||
-- 注意:PostgreSQL 不支持条件性重命名,此处直接重命名
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tb_package' AND column_name = 'suggested_cost_price'
|
||||
) THEN
|
||||
ALTER TABLE tb_package RENAME COLUMN suggested_cost_price TO cost_price;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN tb_package.cost_price IS '成本价(分为单位)';
|
||||
|
||||
-- ============================================
|
||||
-- 2. ShopPackageAllocation 表新增字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0 NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.one_time_commission_amount IS '该代理能拿到的一次性佣金(分)';
|
||||
|
||||
-- ============================================
|
||||
-- 3. IoTCard 表新增追踪字段
|
||||
-- ============================================
|
||||
|
||||
-- 新增按系列追踪的累计充值字段(JSON Map: series_id -> amount)
|
||||
ALTER TABLE tb_iot_card
|
||||
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}';
|
||||
|
||||
-- 新增按系列追踪的首充触发状态(JSON Map: series_id -> bool)
|
||||
ALTER TABLE tb_iot_card
|
||||
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';
|
||||
|
||||
COMMENT ON COLUMN tb_iot_card.accumulated_recharge_by_series IS '按套餐系列追踪的累计充值金额(JSON Map: series_id -> amount 分)';
|
||||
COMMENT ON COLUMN tb_iot_card.first_recharge_triggered_by_series IS '按套餐系列追踪的首充触发状态(JSON Map: series_id -> bool)';
|
||||
|
||||
-- ============================================
|
||||
-- 4. Device 表新增追踪字段
|
||||
-- ============================================
|
||||
|
||||
-- 新增按系列追踪的累计充值字段
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}';
|
||||
|
||||
-- 新增按系列追踪的首充触发状态
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';
|
||||
|
||||
COMMENT ON COLUMN tb_device.accumulated_recharge_by_series IS '按套餐系列追踪的累计充值金额(JSON Map: series_id -> amount 分)';
|
||||
COMMENT ON COLUMN tb_device.first_recharge_triggered_by_series IS '按套餐系列追踪的首充触发状态(JSON Map: series_id -> bool)';
|
||||
|
||||
-- ============================================
|
||||
-- 5. PackageSeries 表新增一次性佣金规则配置
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_package_series
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_config JSONB;
|
||||
|
||||
COMMENT ON COLUMN tb_package_series.one_time_commission_config IS '一次性佣金规则配置(JSON:enable, trigger_type, threshold, commission_type, commission_amount, validity_type 等)';
|
||||
|
||||
-- ============================================
|
||||
-- 6. ShopSeriesOneTimeCommissionTier 表新增统计范围
|
||||
-- ============================================
|
||||
|
||||
-- 检查表是否存在,存在则添加字段
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'tb_shop_series_one_time_commission_tier'
|
||||
) THEN
|
||||
ALTER TABLE tb_shop_series_one_time_commission_tier
|
||||
ADD COLUMN IF NOT EXISTS stat_scope VARCHAR(20) DEFAULT 'self' NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.stat_scope IS '统计范围 self-仅自己 self_and_sub-自己+下级';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================
|
||||
-- 7. ShopSeriesAllocation 表移除废弃字段,新增新字段
|
||||
-- ============================================
|
||||
|
||||
-- 移除一次性佣金完整配置字段
|
||||
ALTER TABLE tb_shop_series_allocation
|
||||
DROP COLUMN IF EXISTS enable_one_time_commission,
|
||||
DROP COLUMN IF EXISTS one_time_commission_type,
|
||||
DROP COLUMN IF EXISTS one_time_commission_trigger,
|
||||
DROP COLUMN IF EXISTS one_time_commission_threshold,
|
||||
DROP COLUMN IF EXISTS one_time_commission_mode,
|
||||
DROP COLUMN IF EXISTS one_time_commission_value,
|
||||
DROP COLUMN IF EXISTS enable_force_recharge,
|
||||
DROP COLUMN IF EXISTS force_recharge_amount,
|
||||
DROP COLUMN IF EXISTS force_recharge_trigger_type;
|
||||
|
||||
-- 新增一次性佣金金额字段
|
||||
ALTER TABLE tb_shop_series_allocation
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0 NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_amount IS '给被分配店铺的一次性佣金金额(分)';
|
||||
68
migrations/000043_simplify_commission_allocation.down.sql
Normal file
68
migrations/000043_simplify_commission_allocation.down.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- 回滚:恢复 ShopSeriesAllocation 层
|
||||
|
||||
-- ============================================
|
||||
-- 1. 重建 ShopSeriesAllocation 表
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tb_shop_series_allocation (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
shop_id BIGINT NOT NULL,
|
||||
series_id BIGINT NOT NULL,
|
||||
allocator_shop_id BIGINT NOT NULL DEFAULT 0,
|
||||
cost_price_markup BIGINT NOT NULL DEFAULT 0,
|
||||
one_time_commission_amount BIGINT NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tb_shop_series_allocation IS '店铺系列分配表';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.shop_id IS '被分配店铺ID';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.series_id IS '套餐系列ID';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.allocator_shop_id IS '分配者店铺ID(0表示平台分配)';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.cost_price_markup IS '成本价加价(分)';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_amount IS '一次性佣金金额(分)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_id ON tb_shop_series_allocation(shop_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_series_id ON tb_shop_series_allocation(series_id);
|
||||
|
||||
-- ============================================
|
||||
-- 2. 重建 ShopSeriesAllocationConfig 表
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tb_shop_series_allocation_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
allocation_id BIGINT NOT NULL,
|
||||
config_type VARCHAR(50) NOT NULL,
|
||||
config_value JSONB
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tb_shop_series_allocation_config IS '店铺系列分配配置表';
|
||||
|
||||
-- ============================================
|
||||
-- 3. 恢复 allocation_id 字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS allocation_id BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- ============================================
|
||||
-- 4. 删除新增的字段
|
||||
-- ============================================
|
||||
|
||||
DROP INDEX IF EXISTS idx_shop_package_allocation_series_id;
|
||||
DROP INDEX IF EXISTS idx_shop_package_allocation_allocator_shop_id;
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS series_id;
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS allocator_shop_id;
|
||||
61
migrations/000043_simplify_commission_allocation.up.sql
Normal file
61
migrations/000043_simplify_commission_allocation.up.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- 简化佣金分配模型:删除 ShopSeriesAllocation 层,使用 ShopPackageAllocation 直接管理
|
||||
-- 重构原因:业务模型只需要套餐级别的分配,不需要系列级别的中间层
|
||||
|
||||
-- ============================================
|
||||
-- 1. 为 ShopPackageAllocation 添加新字段
|
||||
-- ============================================
|
||||
|
||||
-- 添加 series_id 字段(记录套餐所属系列,便于查询)
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS series_id BIGINT DEFAULT 0 NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.series_id IS '套餐系列ID(冗余字段,便于查询)';
|
||||
|
||||
-- 添加 allocator_shop_id 字段(记录是谁分配的,0表示平台分配)
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0 NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.allocator_shop_id IS '分配者店铺ID(0表示平台分配)';
|
||||
|
||||
-- ============================================
|
||||
-- 2. 从现有数据迁移 series_id 和 allocator_shop_id
|
||||
-- ============================================
|
||||
|
||||
-- 通过 package 表获取 series_id
|
||||
UPDATE tb_shop_package_allocation spa
|
||||
SET series_id = p.series_id
|
||||
FROM tb_package p
|
||||
WHERE spa.package_id = p.id AND spa.series_id = 0;
|
||||
|
||||
-- 通过 shop_series_allocation 获取 allocator_shop_id
|
||||
UPDATE tb_shop_package_allocation spa
|
||||
SET allocator_shop_id = ssa.allocator_shop_id
|
||||
FROM tb_shop_series_allocation ssa
|
||||
WHERE spa.allocation_id = ssa.id AND spa.allocator_shop_id = 0;
|
||||
|
||||
-- ============================================
|
||||
-- 3. 删除废弃的 allocation_id 字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS allocation_id;
|
||||
|
||||
-- ============================================
|
||||
-- 4. 添加索引优化查询性能
|
||||
-- ============================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_package_allocation_series_id
|
||||
ON tb_shop_package_allocation(series_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_package_allocation_allocator_shop_id
|
||||
ON tb_shop_package_allocation(allocator_shop_id);
|
||||
|
||||
-- ============================================
|
||||
-- 5. 删除废弃的表
|
||||
-- ============================================
|
||||
|
||||
-- 删除系列分配配置表
|
||||
DROP TABLE IF EXISTS tb_shop_series_allocation_config;
|
||||
|
||||
-- 删除系列分配表
|
||||
DROP TABLE IF EXISTS tb_shop_series_allocation;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 回滚:恢复原有结构
|
||||
|
||||
-- ============================================
|
||||
-- 1. 恢复 tb_shop_package_allocation 字段
|
||||
-- ============================================
|
||||
|
||||
-- 恢复 series_id 字段
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS series_id BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.series_id IS '套餐系列ID(冗余字段,便于查询)';
|
||||
|
||||
-- 恢复 one_time_commission_amount 字段
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.one_time_commission_amount IS '该代理能拿到的一次性佣金(分)';
|
||||
|
||||
-- 删除 series_allocation_id 字段
|
||||
DROP INDEX IF EXISTS idx_shop_package_allocation_series_allocation_id;
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS series_allocation_id;
|
||||
|
||||
-- ============================================
|
||||
-- 2. 恢复 tb_shop_series_one_time_commission_tier 表
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tb_shop_series_one_time_commission_tier (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
allocation_id BIGINT NOT NULL,
|
||||
tier_type VARCHAR(50) NOT NULL,
|
||||
threshold_value BIGINT NOT NULL DEFAULT 0,
|
||||
commission_mode VARCHAR(20) NOT NULL DEFAULT 'fixed',
|
||||
commission_value BIGINT NOT NULL DEFAULT 0,
|
||||
stat_scope VARCHAR(50) NOT NULL DEFAULT 'self',
|
||||
status INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_series_otc_tier_allocation_id
|
||||
ON tb_shop_series_one_time_commission_tier(allocation_id);
|
||||
|
||||
COMMENT ON TABLE tb_shop_series_one_time_commission_tier IS '一次性佣金梯度配置表';
|
||||
|
||||
-- ============================================
|
||||
-- 3. 删除 tb_shop_series_allocation 表
|
||||
-- ============================================
|
||||
|
||||
DROP TABLE IF EXISTS tb_shop_series_allocation;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- 重构一次性佣金分配:恢复系列分配表,移除套餐分配中的佣金字段
|
||||
-- 原因:一次性佣金是系列级概念,不应存储在套餐分配中
|
||||
|
||||
-- ============================================
|
||||
-- 1. 创建 tb_shop_series_allocation 表
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tb_shop_series_allocation (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
shop_id BIGINT NOT NULL,
|
||||
series_id BIGINT NOT NULL,
|
||||
allocator_shop_id BIGINT NOT NULL DEFAULT 0,
|
||||
one_time_commission_amount BIGINT NOT NULL DEFAULT 0,
|
||||
enable_one_time_commission BOOLEAN NOT NULL DEFAULT false,
|
||||
one_time_commission_trigger VARCHAR(50),
|
||||
one_time_commission_threshold BIGINT NOT NULL DEFAULT 0,
|
||||
enable_force_recharge BOOLEAN NOT NULL DEFAULT false,
|
||||
force_recharge_amount BIGINT NOT NULL DEFAULT 0,
|
||||
force_recharge_trigger_type INT NOT NULL DEFAULT 2,
|
||||
status INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_id
|
||||
ON tb_shop_series_allocation(shop_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_series_id
|
||||
ON tb_shop_series_allocation(series_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_allocator_shop_id
|
||||
ON tb_shop_series_allocation(allocator_shop_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_series
|
||||
ON tb_shop_series_allocation(shop_id, series_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- 注释
|
||||
COMMENT ON TABLE tb_shop_series_allocation IS '店铺系列分配表 - 管理系列级一次性佣金配置';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.shop_id IS '被分配店铺ID';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.series_id IS '套餐系列ID';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.allocator_shop_id IS '分配者店铺ID(0表示平台分配)';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_amount IS '该代理能拿的一次性佣金金额上限(分)';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.enable_one_time_commission IS '是否启用一次性佣金';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_trigger IS '一次性佣金触发类型 first_recharge-首次充值 accumulated_recharge-累计充值';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_threshold IS '一次性佣金触发阈值(分)';
|
||||
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-累计充值';
|
||||
COMMENT ON COLUMN tb_shop_series_allocation.status IS '状态 1-启用 2-禁用';
|
||||
|
||||
-- ============================================
|
||||
-- 2. 修改 tb_shop_package_allocation 表
|
||||
-- ============================================
|
||||
|
||||
-- 添加 series_allocation_id 字段(如果不存在)
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS series_allocation_id BIGINT;
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.series_allocation_id IS '关联的系列分配ID';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shop_package_allocation_series_allocation_id
|
||||
ON tb_shop_package_allocation(series_allocation_id);
|
||||
|
||||
-- 删除 one_time_commission_amount 字段(佣金配置移到系列分配)
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS one_time_commission_amount;
|
||||
|
||||
-- 删除 series_id 字段(通过 series_allocation_id 关联)
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS series_id;
|
||||
|
||||
-- ============================================
|
||||
-- 3. 删除 tb_shop_series_one_time_commission_tier 表(未使用)
|
||||
-- ============================================
|
||||
|
||||
DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_package_series_enable_one_time_commission;
|
||||
|
||||
ALTER TABLE tb_package_series
|
||||
DROP COLUMN IF EXISTS enable_one_time_commission;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 修复:tb_package_series 表添加 enable_one_time_commission 字段
|
||||
-- 该字段在 000042 迁移中被删除,但按新设计需要在系列级启用/禁用一次性佣金
|
||||
|
||||
ALTER TABLE tb_package_series
|
||||
ADD COLUMN IF NOT EXISTS enable_one_time_commission BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN tb_package_series.enable_one_time_commission IS '是否启用一次性佣金(顶层字段,支持SQL索引)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_package_series_enable_one_time_commission
|
||||
ON tb_package_series(enable_one_time_commission);
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-03
|
||||
@@ -0,0 +1,462 @@
|
||||
## Context
|
||||
|
||||
### 背景
|
||||
|
||||
当前套餐与佣金系统在快速迭代中积累了技术债务,主要问题:
|
||||
|
||||
1. **套餐价格字段混乱**:`Price`、`SuggestedCostPrice`、`SuggestedRetailPrice` 三个字段语义不清,不同场景使用不一致
|
||||
2. **流量字段设计缺陷**:`DataType` 暗示真/虚流量二选一,但业务需求是共存机制
|
||||
3. **分配层次过多**:存在 `ShopSeriesAllocation`(系列分配)和 `ShopPackageAllocation`(套餐分配)两层,但业务模型只需要一层
|
||||
4. **差价佣金计算复杂**:使用 `BaseCommissionMode/Value` 动态计算,但业务模型是简单的成本价差值
|
||||
5. **一次性佣金配置位置错误**:配置在系列分配表中,应该只在套餐分配表中存储金额
|
||||
|
||||
### 业务模型(Source of Truth)
|
||||
|
||||
详见 [`docs/commission-package-model.md`](../../../docs/commission-package-model.md)
|
||||
|
||||
核心要点:
|
||||
- **只有一层分配**:`ShopPackageAllocation`(套餐分配)
|
||||
- **差价佣金 = 下级成本价 - 自己成本价**(固定差值,无动态计算)
|
||||
- **一次性佣金规则在系列定义,金额在套餐分配中设置**
|
||||
|
||||
### 现有架构(需要重构)
|
||||
|
||||
```
|
||||
tb_package_series # 套餐系列(含一次性佣金规则)
|
||||
│
|
||||
├── tb_package # 套餐
|
||||
│
|
||||
└── tb_shop_series_allocation # ❌ 系列分配(需要删除)
|
||||
│
|
||||
└── tb_shop_package_allocation # 套餐分配
|
||||
```
|
||||
|
||||
### 目标架构
|
||||
|
||||
```
|
||||
tb_package_series # 套餐系列(含一次性佣金规则配置)
|
||||
│
|
||||
└── tb_package # 套餐
|
||||
│
|
||||
└── tb_shop_package_allocation # 套餐分配(唯一的分配表)
|
||||
├── shop_id # 被分配的店铺
|
||||
├── package_id # 套餐ID
|
||||
├── series_id # 系列ID(新增,用于关联规则)
|
||||
├── allocator_shop_id # 分配者店铺ID(新增)
|
||||
├── cost_price # 该代理的成本价
|
||||
├── one_time_commission_amount # 该代理能拿到的一次性佣金
|
||||
└── status
|
||||
```
|
||||
|
||||
### 约束条件
|
||||
|
||||
- 不使用外键约束和 GORM 关联关系
|
||||
- 必须支持向后兼容的数据迁移
|
||||
- 分层架构:Handler → Service → Store → Model
|
||||
- 异步任务使用 Asynq
|
||||
|
||||
---
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. **简化套餐模型**:只保留 `cost_price` + `suggested_retail_price`,语义清晰
|
||||
2. **支持流量共存**:真流量必填 + 虚流量可选开关,停机判断逻辑统一
|
||||
3. **删除 ShopSeriesAllocation**:移除多余的分配层次
|
||||
4. **简化差价佣金计算**:直接使用成本价差值,删除动态计算逻辑
|
||||
5. **统一分配模型**:一次性佣金金额只存储在 `ShopPackageAllocation` 中
|
||||
6. **代理视角隔离**:不同代理看到自己的成本价和能拿到的一次性佣金
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- 不重构订单支付流程(仅适配新的佣金计算)
|
||||
- 不修改钱包充值逻辑(仅增加累计追踪)
|
||||
- 不修改梯度佣金的统计存储结构(仅增加统计范围开关)
|
||||
- 不处理历史订单的佣金重算(迁移只处理配置数据)
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: 删除 ShopSeriesAllocation 及相关表
|
||||
|
||||
**决策**:完全删除以下表和相关代码
|
||||
|
||||
| 表名 | 说明 | 删除原因 |
|
||||
|------|------|----------|
|
||||
| `tb_shop_series_allocation` | 系列分配表 | 多余的分配层次 |
|
||||
| `tb_shop_series_allocation_config` | 系列分配配置版本表 | 依赖于 series_allocation |
|
||||
| `tb_shop_series_one_time_commission_tier` | 系列分配梯度配置表 | 梯度配置移到系列规则中 |
|
||||
|
||||
**代码删除清单**:
|
||||
- `internal/model/shop_series_allocation.go`
|
||||
- `internal/model/shop_series_allocation_config.go`
|
||||
- `internal/model/dto/shop_series_allocation.go`
|
||||
- `internal/store/postgres/shop_series_allocation_store.go`
|
||||
- `internal/store/postgres/shop_series_allocation_config_store.go`
|
||||
- `internal/service/shop_series_allocation/service.go`
|
||||
- `internal/handler/admin/shop_series_allocation.go`
|
||||
- `internal/routes/shop_series_allocation.go`
|
||||
- `pkg/utils/commission.go`(CalculateCostPrice 函数)
|
||||
|
||||
**理由**:
|
||||
- 业务模型只需要一层分配(套餐分配)
|
||||
- 系列分配增加了不必要的复杂度
|
||||
- 所有需要的信息都可以在套餐分配中存储
|
||||
|
||||
### D2: 修改 ShopPackageAllocation 模型
|
||||
|
||||
**决策**:扩展 `ShopPackageAllocation` 以承担原 `ShopSeriesAllocation` 的职责
|
||||
|
||||
```go
|
||||
// Before
|
||||
type ShopPackageAllocation struct {
|
||||
ID uint
|
||||
AllocationID uint // ❌ 外键到 ShopSeriesAllocation(删除)
|
||||
PackageID uint
|
||||
CostPrice int64
|
||||
Status int
|
||||
}
|
||||
|
||||
// After
|
||||
type ShopPackageAllocation struct {
|
||||
ID uint
|
||||
ShopID uint // 被分配的店铺(新增)
|
||||
PackageID uint // 套餐ID
|
||||
SeriesID uint // 系列ID(新增,用于关联一次性佣金规则)
|
||||
AllocatorShopID uint // 分配者店铺ID(新增,0表示平台)
|
||||
CostPrice int64 // 该代理的成本价
|
||||
OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金金额(新增)
|
||||
Status int
|
||||
Creator uint
|
||||
Updater uint
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- `ShopID` 和 `AllocatorShopID` 原本存储在 `ShopSeriesAllocation` 中
|
||||
- `SeriesID` 用于查询一次性佣金规则(从 `PackageSeries.OneTimeCommissionConfig` 获取)
|
||||
- `OneTimeCommissionAmount` 存储分配时设置的金额
|
||||
|
||||
### D3: 差价佣金计算简化
|
||||
|
||||
**决策**:删除动态计算逻辑,使用固定成本价差值
|
||||
|
||||
```go
|
||||
// Before: 动态计算(删除)
|
||||
// pkg/utils/commission.go
|
||||
func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
||||
switch allocation.BaseCommissionMode {
|
||||
case "fixed":
|
||||
return orderAmount - allocation.BaseCommissionValue
|
||||
case "percent":
|
||||
return orderAmount * (100 - allocation.BaseCommissionValue) / 100
|
||||
}
|
||||
return orderAmount
|
||||
}
|
||||
|
||||
// After: 简单差值计算
|
||||
// 差价佣金 = 下级成本价 - 自己成本价
|
||||
func CalculateDifferenceCommission(myCostPrice, subCostPrice int64) int64 {
|
||||
return subCostPrice - myCostPrice
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```
|
||||
平台成本价: 100
|
||||
代理A成本价: 120(分配时设置)
|
||||
代理A1成本价: 130(A分配给A1时设置)
|
||||
|
||||
当A1销售时:
|
||||
- A1利润 = 售价 - 130
|
||||
- A差价佣金 = 130 - 120 = 10(固定)
|
||||
- 平台收入 = 120
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 业务模型明确定义差价佣金 = 下级成本价 - 自己成本价
|
||||
- 无需 `BaseCommissionMode/Value` 的动态计算
|
||||
- 简化代码,减少出错可能
|
||||
|
||||
### D4: 一次性佣金金额存储位置
|
||||
|
||||
**决策**:一次性佣金金额只存储在 `ShopPackageAllocation.OneTimeCommissionAmount`
|
||||
|
||||
```go
|
||||
// 套餐系列定义规则(不变)
|
||||
type PackageSeries struct {
|
||||
// ... 基础字段
|
||||
EnableOneTimeCommission bool // 是否启用
|
||||
OneTimeCommissionConfig string // JSON:触发条件、阈值、金额/梯度等
|
||||
}
|
||||
|
||||
// 套餐分配记录每个代理能拿到的金额
|
||||
type ShopPackageAllocation struct {
|
||||
// ... 其他字段
|
||||
OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金
|
||||
}
|
||||
```
|
||||
|
||||
**分配流程**:
|
||||
```
|
||||
1. 平台创建套餐系列,配置一次性佣金规则:首充100返20
|
||||
2. 平台分配给代理A:设置成本价120,一次性佣金20
|
||||
→ 创建 ShopPackageAllocation(shop_id=A, cost_price=120, one_time_commission_amount=20)
|
||||
3. 代理A分配给A1:设置成本价130,一次性佣金8
|
||||
→ 创建 ShopPackageAllocation(shop_id=A1, cost_price=130, one_time_commission_amount=8)
|
||||
4. 触发一次性佣金时:
|
||||
- A1 获得:8
|
||||
- A 获得:20 - 8 = 12
|
||||
- 合计:20 ✓
|
||||
```
|
||||
|
||||
**约束**:
|
||||
- 给下级的金额 ≤ 自己能拿到的金额
|
||||
- 给下级的金额 ≥ 0
|
||||
|
||||
**理由**:
|
||||
- 与成本价分配逻辑一致,都在套餐分配中设置
|
||||
- 每个代理只存储"自己能拿到多少",计算简单
|
||||
- 删除了 `ShopSeriesAllocation` 后的自然归属
|
||||
|
||||
### D5: 套餐价格字段简化
|
||||
|
||||
**决策**:移除 `Price` 和 `DataAmountMB`,保留并重命名字段
|
||||
|
||||
```go
|
||||
// Before
|
||||
type Package struct {
|
||||
Price int64 // 语义不清
|
||||
SuggestedCostPrice int64 // 建议成本价
|
||||
SuggestedRetailPrice int64 // 建议售价
|
||||
DataType string // real/virtual 二选一
|
||||
RealDataMB int64
|
||||
VirtualDataMB int64
|
||||
DataAmountMB int64 // 语义不清
|
||||
}
|
||||
|
||||
// After
|
||||
type Package struct {
|
||||
CostPrice int64 // 成本价(平台设置的基础成本价)
|
||||
SuggestedRetailPrice int64 // 建议售价
|
||||
RealDataMB int64 // 真实流量(必填)
|
||||
EnableVirtualData bool // 是否启用虚流量
|
||||
VirtualDataMB int64 // 虚流量(启用时必填,≤ 真实流量)
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- `Price` 在不同上下文含义不同,造成混乱
|
||||
- `DataType` 是二选一设计,但业务需要共存
|
||||
- `DataAmountMB` 没有明确定义是真流量还是虚流量
|
||||
|
||||
### D6: 代理视角套餐列表实现
|
||||
|
||||
**决策**:在 Service 层动态计算,从 `ShopPackageAllocation` 获取数据
|
||||
|
||||
```go
|
||||
// PackageService.List 方法
|
||||
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) (*dto.PackagePageResult, error) {
|
||||
// 1. 查询基础套餐数据
|
||||
packages, total, err := s.store.List(ctx, req)
|
||||
|
||||
// 2. 获取当前用户信息
|
||||
userInfo := middleware.GetUserContextInfo(ctx)
|
||||
|
||||
// 3. 如果是代理用户,查询分配关系并填充视角数据
|
||||
if userInfo.UserType == constants.UserTypeAgent {
|
||||
allocations := s.packageAllocationStore.GetByShopAndPackages(ctx, userInfo.ShopID, packageIDs)
|
||||
for _, pkg := range packages {
|
||||
if alloc, ok := allocations[pkg.ID]; ok {
|
||||
pkg.CostPrice = alloc.CostPrice // 覆盖为代理视角
|
||||
pkg.OneTimeCommissionAmount = alloc.OneTimeCommissionAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 不存储冗余数据,避免一致性问题
|
||||
- 查询时动态计算,逻辑集中在 Service 层
|
||||
- 使用批量查询避免 N+1 问题
|
||||
|
||||
### D7: 累计充值追踪方案
|
||||
|
||||
**决策**:在 `IoTCard` 和 `Device` 模型中新增追踪字段
|
||||
|
||||
```go
|
||||
type IoTCard struct {
|
||||
// ... 现有字段
|
||||
|
||||
// 按套餐系列追踪(JSON Map: series_id -> amount)
|
||||
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb"`
|
||||
|
||||
// 按套餐系列追踪首充状态(JSON Map: series_id -> bool)
|
||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb"`
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 累计充值和首充状态都是"按系列"的,需要按系列追踪
|
||||
- 使用 JSONB 避免多表关联,查询性能好
|
||||
- PostgreSQL 原生支持 JSONB 索引和查询
|
||||
|
||||
---
|
||||
|
||||
## 删除清单
|
||||
|
||||
### 需要删除的文件
|
||||
|
||||
| 文件路径 | 类型 | 说明 |
|
||||
|----------|------|------|
|
||||
| `internal/model/shop_series_allocation.go` | Model | 系列分配模型 |
|
||||
| `internal/model/shop_series_allocation_config.go` | Model | 配置版本模型 |
|
||||
| `internal/model/dto/shop_series_allocation.go` | DTO | 请求/响应 DTO |
|
||||
| `internal/store/postgres/shop_series_allocation_store.go` | Store | 数据访问层 |
|
||||
| `internal/store/postgres/shop_series_allocation_config_store.go` | Store | 配置版本 Store |
|
||||
| `internal/store/postgres/shop_series_allocation_store_test.go` | Test | Store 测试 |
|
||||
| `internal/service/shop_series_allocation/service.go` | Service | 业务逻辑层 |
|
||||
| `internal/handler/admin/shop_series_allocation.go` | Handler | HTTP Handler |
|
||||
| `internal/routes/shop_series_allocation.go` | Routes | 路由注册 |
|
||||
| `tests/integration/shop_series_allocation_test.go` | Test | 集成测试 |
|
||||
| `pkg/utils/commission.go` | Utils | CalculateCostPrice 函数 |
|
||||
|
||||
### 需要删除的字段
|
||||
|
||||
| 表/模型 | 字段 | 说明 |
|
||||
|---------|------|------|
|
||||
| `ShopPackageAllocation` | `AllocationID` | 外键到 ShopSeriesAllocation |
|
||||
| `ShopSeriesAllocation` | 整表 | 删除整个表 |
|
||||
| `ShopSeriesAllocationConfig` | 整表 | 删除整个表 |
|
||||
|
||||
### 需要修改的文件
|
||||
|
||||
见 `tasks.md` 中的详细任务列表。
|
||||
|
||||
---
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### R1: 数据迁移复杂度
|
||||
|
||||
**风险**:现有数据结构与新结构差异大,迁移可能导致数据丢失或不一致
|
||||
|
||||
**缓解措施**:
|
||||
- 分阶段迁移:先新增字段,再迁移数据,最后删除旧字段
|
||||
- 迁移前完整备份
|
||||
- 迁移脚本支持回滚
|
||||
- 新旧字段并存过渡期(2周)
|
||||
|
||||
### R2: API 破坏性变更
|
||||
|
||||
**风险**:前端需要同步修改,上线需要协调
|
||||
|
||||
**缓解措施**:
|
||||
- 提前沟通 API 变更内容
|
||||
- 删除 `/api/admin/shop-series-allocations/*` 路由
|
||||
- 修改 `/api/admin/shop-package-allocations/*` 路由参数
|
||||
- 提供详细的迁移文档
|
||||
|
||||
### R3: 链式分配计算性能
|
||||
|
||||
**风险**:触发一次性佣金时需要沿代理链向上计算,可能涉及多级查询
|
||||
|
||||
**缓解措施**:
|
||||
- 使用 Redis 缓存代理链关系
|
||||
- 限制代理层级(最多 7 级)
|
||||
- 佣金分配使用异步任务处理
|
||||
|
||||
### R4: JSONB 字段查询性能
|
||||
|
||||
**风险**:累计充值和首充状态使用 JSONB 存储,复杂查询可能慢
|
||||
|
||||
**缓解措施**:
|
||||
- 为常用查询路径创建 JSONB 索引
|
||||
- 触发检查时先查内存/Redis 缓存
|
||||
- 监控查询性能,必要时重构为独立表
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**注意**:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。
|
||||
|
||||
### 数据库变更
|
||||
|
||||
```sql
|
||||
-- 1. ShopPackageAllocation 新增字段
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS shop_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS series_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0;
|
||||
|
||||
-- 2. ShopPackageAllocation 删除字段
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS allocation_id;
|
||||
|
||||
-- 3. Package 表调整
|
||||
ALTER TABLE tb_package
|
||||
DROP COLUMN IF EXISTS price,
|
||||
DROP COLUMN IF EXISTS data_type,
|
||||
DROP COLUMN IF EXISTS data_amount_mb,
|
||||
ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false;
|
||||
|
||||
-- 4. IoTCard 新增追踪字段
|
||||
ALTER TABLE tb_iot_card
|
||||
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';
|
||||
|
||||
-- 5. Device 新增追踪字段
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';
|
||||
|
||||
-- 6. 删除废弃表
|
||||
DROP TABLE IF EXISTS tb_shop_series_allocation;
|
||||
DROP TABLE IF EXISTS tb_shop_series_allocation_config;
|
||||
DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier;
|
||||
```
|
||||
|
||||
### 代码变更顺序
|
||||
|
||||
1. **Model 层**
|
||||
- 修改 `ShopPackageAllocation` 模型
|
||||
- 删除 `ShopSeriesAllocation` 相关模型
|
||||
|
||||
2. **DTO 层**
|
||||
- 修改 `ShopPackageAllocation` DTO
|
||||
- 删除 `ShopSeriesAllocation` DTO
|
||||
|
||||
3. **Store 层**
|
||||
- 修改 `ShopPackageAllocationStore`
|
||||
- 删除 `ShopSeriesAllocationStore`
|
||||
|
||||
4. **Service 层**
|
||||
- 修改所有依赖 `ShopSeriesAllocation` 的 Service
|
||||
- 删除 `ShopSeriesAllocationService`
|
||||
|
||||
5. **Handler/Routes 层**
|
||||
- 删除 `ShopSeriesAllocationHandler`
|
||||
- 删除相关路由注册
|
||||
|
||||
6. **Bootstrap 层**
|
||||
- 移除所有 `ShopSeriesAllocation` 相关初始化
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **历史订单佣金**:已完成的订单佣金是否需要按新规则重算?
|
||||
- 建议:不重算,保持历史数据稳定
|
||||
|
||||
2. **过渡期时长**:新旧字段并存多久?
|
||||
- 建议:2周观察期,确认无问题后清理
|
||||
|
||||
3. **前端发版协调**:是否需要灰度发布?
|
||||
- 取决于前端改动量,建议同步上线
|
||||
@@ -0,0 +1,99 @@
|
||||
## Why
|
||||
|
||||
当前套餐与佣金系统存在**概念模型与实现错位**的问题:套餐价格字段过多且语义不清(`Price`、`SuggestedCostPrice`、`SuggestedRetailPrice` 三个字段用途混乱),流量字段设计不支持真流量/虚流量共存机制,一次性佣金缺少链式分配能力(无法设置"给下级多少"),套餐分配与系列分配的关系不清晰。这导致接口入参混乱、不同模块的一致性被破坏、操作流程不是线性的。需要基于梳理清楚的业务模型进行系统性重构。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 套餐模型简化
|
||||
|
||||
- **BREAKING** 移除 `Package.Price` 字段,只保留 `cost_price`(成本价)和 `suggested_retail_price`(建议售价)
|
||||
- **BREAKING** 重构流量字段:`real_data_mb`(必填)+ `enable_virtual_data`(开关)+ `virtual_data_mb`(可选,≤真流量)
|
||||
- 移除 `data_type` 字段(不再是二选一)
|
||||
- 移除 `data_amount_mb` 字段(语义不清)
|
||||
|
||||
### 一次性佣金重构
|
||||
|
||||
- **BREAKING** 修改触发条件命名:`single_recharge` → `first_recharge`(首充)
|
||||
- **BREAKING** 新增链式分配能力:套餐分配时设置"给下级的一次性佣金金额"
|
||||
- 新增一次性佣金时效配置(永久/固定日期/相对时长)
|
||||
- 优化强充金额计算:首充时 `max(首充要求, 套餐售价)`;累计充值时支持固定/动态差额两种模式
|
||||
- 新增梯度佣金统计范围开关(仅自己/自己+下级)
|
||||
|
||||
### 套餐分配统一
|
||||
|
||||
- **BREAKING** 统一套餐分配模型:将一次性佣金额度配置移入套餐分配
|
||||
- 套餐系列仅定义一次性佣金"规则",分配时设置"给谁多少"
|
||||
- 代理只能看到自己能拿到的一次性佣金额度,不能看到总规则
|
||||
|
||||
### 累计充值机制完善
|
||||
|
||||
- 明确累计范围:按卡/设备在该套餐系列下累计
|
||||
- 明确累计操作:只有"充值"操作累计,"直接购买套餐"不累计
|
||||
- 一次性佣金每张卡/设备只触发一次
|
||||
|
||||
### 代理视角优化
|
||||
|
||||
- 不同用户调用同一套餐列表接口,看到不同的成本价(自己的成本价)
|
||||
- 不同用户看到不同的一次性佣金额度(自己能拿到的)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `commission-chain-distribution`: 一次性佣金链式分配能力,支持在套餐分配时设置给下级的佣金金额,自动计算各级代理分得的佣金
|
||||
- `package-virtual-data`: 套餐真流量/虚流量共存机制,支持开关控制虚流量,停机判断基于配置选择使用哪个流量值
|
||||
- `one-time-commission-validity`: 一次性佣金时效管理,支持永久、固定到期日期、相对时长三种时效类型
|
||||
- `accumulated-recharge-tracking`: 累计充值追踪,按卡/设备在套餐系列下累计充值金额,只统计充值操作
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-management`: 移除 `Price`、`data_type`、`data_amount_mb` 字段,简化为 `cost_price` + `suggested_retail_price` + 真流量/虚流量共存
|
||||
- `package-series-management`: 一次性佣金规则仅在系列层面定义,分配时不再复制完整配置
|
||||
- `shop-series-allocation`: 移除完整的一次性佣金配置,改为引用系列规则 + 设置给下级的金额
|
||||
- `one-time-commission-trigger`: 触发条件从 `single_recharge` 改为 `first_recharge`,首充定义为该卡/设备在该系列下的第一次充值
|
||||
- `commission-calculation`: 适配链式分配逻辑,上级佣金 = 自己能拿的 - 给下级的
|
||||
- `force-recharge-check`: 首充强充金额改为 `max(首充要求, 套餐售价)`,累计充值强充支持固定/动态两种计算方式
|
||||
- `agent-available-packages`: 返回代理视角的成本价和一次性佣金额度,而非原始配置
|
||||
- `shop-commission-tier`: 新增统计范围开关(仅自己/自己+下级),统计周期与一次性佣金时效一致
|
||||
|
||||
## Impact
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- `tb_package`: 移除 `price`、`data_type`、`data_amount_mb` 字段,新增 `enable_virtual_data` 字段
|
||||
- `tb_package_series`: 新增一次性佣金时效字段(`validity_type`、`validity_value`)
|
||||
- `tb_shop_series_allocation`: 移除大部分一次性佣金配置字段,仅保留 `one_time_commission_amount`(给下级的金额)
|
||||
- `tb_shop_package_allocation`: 新增 `one_time_commission_amount` 字段
|
||||
- `tb_iot_card` / `tb_device`: 新增 `accumulated_recharge_amount`(累计充值)、`first_recharge_triggered`(首充已触发)字段
|
||||
- `tb_shop_series_one_time_commission_tier`: 新增 `stat_scope` 字段
|
||||
|
||||
### API 变更(BREAKING)
|
||||
|
||||
- `POST /api/admin/packages`: 移除 `price`、`data_type`、`data_amount_mb` 参数
|
||||
- `PUT /api/admin/packages/:id`: 同上
|
||||
- `POST /api/admin/shop-series-allocations`: 移除 `one_time_commission_type/trigger/threshold/mode/value` 等字段,新增 `one_time_commission_amount`
|
||||
- `POST /api/admin/shop-package-allocations`: 新增 `one_time_commission_amount` 字段
|
||||
- `GET /api/admin/packages`: 返回结构变化,成本价和一次性佣金按用户视角返回
|
||||
|
||||
### 代码变更
|
||||
|
||||
- `internal/model/package.go`: Package 结构体字段调整
|
||||
- `internal/model/shop_series_allocation.go`: 移除一次性佣金配置字段
|
||||
- `internal/model/shop_package_allocation.go`: 新增一次性佣金金额字段
|
||||
- `internal/model/dto/package_dto.go`: 请求/响应 DTO 调整
|
||||
- `internal/model/dto/shop_series_allocation.go`: DTO 简化
|
||||
- `internal/service/commission/`: 佣金计算逻辑适配链式分配
|
||||
- `internal/handler/admin/package.go`: 套餐列表按用户视角返回
|
||||
- `internal/task/commission_calculation.go`: 异步任务适配新逻辑
|
||||
|
||||
### 前端影响
|
||||
|
||||
- 套餐管理页面:移除价格字段,调整流量配置 UI
|
||||
- 套餐分配页面:简化一次性佣金配置,改为只设置"给下级多少"
|
||||
- 套餐列表页面:显示的成本价和一次性佣金需要理解为"自己视角"
|
||||
|
||||
### 数据迁移
|
||||
|
||||
- 需要迁移脚本处理历史数据:
|
||||
- `Package.SuggestedCostPrice` → `Package.cost_price`(如果 `Price` 有值需要决定保留哪个)
|
||||
- 已有的 `ShopSeriesAllocation` 一次性佣金配置需要迁移到新结构
|
||||
@@ -0,0 +1,70 @@
|
||||
# 累计充值追踪
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按卡/设备按系列累计
|
||||
|
||||
系统 SHALL 按照卡/设备在每个套餐系列下独立追踪累计充值金额。不同系列的累计互不影响。
|
||||
|
||||
#### Scenario: 同一卡在不同系列的累计
|
||||
- **WHEN** IoT卡 A 在系列1下充值 100 元
|
||||
- **AND** IoT卡 A 在系列2下充值 50 元
|
||||
- **THEN** 系列1的累计金额 SHALL 为 100 元
|
||||
- **AND** 系列2的累计金额 SHALL 为 50 元
|
||||
|
||||
#### Scenario: 同一卡在同一系列的累计
|
||||
- **WHEN** IoT卡 A 在系列1下第一次充值 100 元
|
||||
- **AND** IoT卡 A 在系列1下第二次充值 50 元
|
||||
- **THEN** 系列1的累计金额 SHALL 为 150 元
|
||||
|
||||
### Requirement: 只有充值操作累计
|
||||
|
||||
系统 SHALL 只累计"充值到钱包"的操作,直接购买套餐(不经过钱包)SHALL NOT 累计。
|
||||
|
||||
#### Scenario: 直接充值累计
|
||||
- **WHEN** 客户选择充值 100 元到钱包
|
||||
- **AND** 支付成功
|
||||
- **THEN** 累计金额 SHALL 增加 100 元
|
||||
|
||||
#### Scenario: 直接购买不累计
|
||||
- **WHEN** 客户直接购买 100 元套餐(余额足够)
|
||||
- **AND** 系统从钱包扣款
|
||||
- **THEN** 累计金额 SHALL 保持不变
|
||||
|
||||
#### Scenario: 强充购买累计
|
||||
- **WHEN** 客户通过强充购买套餐
|
||||
- **AND** 强充金额为 200 元
|
||||
- **AND** 套餐价格为 100 元
|
||||
- **THEN** 累计金额 SHALL 增加 200 元(充值部分)
|
||||
- **AND** 钱包余额增加 200 后扣除 100
|
||||
|
||||
### Requirement: 首充状态按系列追踪
|
||||
|
||||
系统 SHALL 按照卡/设备在每个套餐系列下独立追踪首充状态。一个系列触发首充后,其他系列的首充状态不受影响。
|
||||
|
||||
#### Scenario: 首次在系列下充值
|
||||
- **WHEN** IoT卡 A 从未在系列1下充值过
|
||||
- **AND** IoT卡 A 进行充值操作
|
||||
- **THEN** 系统 SHALL 标记该卡在系列1下的首充状态为"已触发"
|
||||
|
||||
#### Scenario: 非首次在系列下充值
|
||||
- **WHEN** IoT卡 A 已在系列1下触发过首充
|
||||
- **AND** IoT卡 A 再次充值
|
||||
- **THEN** 系统 SHALL 不触发首充返佣
|
||||
- **AND** 首充状态保持"已触发"
|
||||
|
||||
#### Scenario: 不同系列首充独立
|
||||
- **WHEN** IoT卡 A 已在系列1下触发过首充
|
||||
- **AND** IoT卡 A 首次在系列2下充值
|
||||
- **THEN** 系统 SHALL 触发系列2的首充返佣(如果规则启用)
|
||||
|
||||
### Requirement: 一次性佣金只触发一次
|
||||
|
||||
每张卡/设备在每个套餐系列下,一次性佣金(无论首充还是累计充值)SHALL 只触发一次。触发后不再重复触发。
|
||||
|
||||
#### Scenario: 累计充值达标后不再触发
|
||||
- **WHEN** 系列规则为累计充值 200 返 40
|
||||
- **AND** IoT卡 A 累计充值达到 200 元
|
||||
- **AND** 系统触发一次性佣金 40 元
|
||||
- **AND** IoT卡 A 继续充值 100 元(累计 300 元)
|
||||
- **THEN** 系统 SHALL 不再触发一次性佣金
|
||||
@@ -0,0 +1,58 @@
|
||||
# 代理可用套餐变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 返回代理视角的套餐信息
|
||||
|
||||
代理查询套餐列表时,系统 SHALL 返回该代理视角的成本价和一次性佣金金额,而非原始配置。
|
||||
|
||||
**变更说明**:成本价和一次性佣金金额需要根据套餐分配关系动态计算。
|
||||
|
||||
#### Scenario: 代理查看套餐列表
|
||||
- **WHEN** 代理A调用套餐列表接口
|
||||
- **AND** 该套餐的基础成本价100元
|
||||
- **AND** 平台给A分配时设置成本价120元
|
||||
- **THEN** 返回的 `cost_price` SHALL 为 120元(A的成本价)
|
||||
|
||||
#### Scenario: 代理查看一次性佣金
|
||||
- **WHEN** 代理A调用套餐列表接口
|
||||
- **AND** 系列规则:首充100返20元
|
||||
- **AND** 平台给A设置的一次性佣金金额为15元
|
||||
- **THEN** 返回的 `one_time_commission_amount` SHALL 为 15元
|
||||
- **AND** 不返回系列规则的20元
|
||||
|
||||
#### Scenario: 平台查看套餐列表
|
||||
- **WHEN** 平台管理员调用套餐列表接口
|
||||
- **THEN** 返回基础成本价(不是代理视角)
|
||||
- **AND** 返回完整的一次性佣金规则
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 未分配套餐不可见
|
||||
|
||||
代理只能看到已分配给自己的套餐。未分配的套餐 SHALL NOT 出现在代理的套餐列表中。
|
||||
|
||||
#### Scenario: 只返回已分配套餐
|
||||
- **WHEN** 代理A调用套餐列表接口
|
||||
- **AND** 系统共有套餐 P1、P2、P3
|
||||
- **AND** 只有 P1、P2 分配给了 A
|
||||
- **THEN** 返回列表只包含 P1、P2
|
||||
- **AND** 不包含 P3
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐分配新增一次性佣金金额
|
||||
|
||||
ShopPackageAllocation 模型 MUST 新增 `one_time_commission_amount` 字段,记录给该代理的一次性佣金金额。
|
||||
|
||||
**变更说明**:一次性佣金金额配置从系列分配移到套餐分配。
|
||||
|
||||
#### Scenario: 分配套餐时设置一次性佣金金额
|
||||
- **WHEN** 上级给下级分配套餐
|
||||
- **AND** 设置一次性佣金金额为10元
|
||||
- **THEN** ShopPackageAllocation 记录 `one_time_commission_amount = 1000`(分)
|
||||
|
||||
#### Scenario: 一次性佣金金额约束
|
||||
- **WHEN** 上级给下级设置一次性佣金金额
|
||||
- **THEN** 该金额 MUST <= 上级自己能拿到的一次性佣金金额
|
||||
- **AND** 该金额 MUST >= 0
|
||||
@@ -0,0 +1,72 @@
|
||||
# 佣金计算变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 一次性佣金计算
|
||||
|
||||
系统 SHALL 按链式分配规则计算一次性佣金。每级代理实际获得的佣金 = 自己能拿到的金额 - 给下级的金额。
|
||||
|
||||
**变更说明**:从"直接发放给归属店铺"改为"链式分配给代理链上所有店铺"。
|
||||
|
||||
#### Scenario: 计算链式分配金额
|
||||
- **WHEN** 触发一次性佣金
|
||||
- **AND** 代理链为 平台 → A → A1 → A2
|
||||
- **AND** 系列规则返20元
|
||||
- **AND** A能拿到20元,给A1设置8元
|
||||
- **AND** A1能拿到8元,给A2设置5元
|
||||
- **THEN** A2实际获得 = 5元
|
||||
- **AND** A1实际获得 = 8 - 5 = 3元
|
||||
- **AND** A实际获得 = 20 - 8 = 12元
|
||||
|
||||
#### Scenario: 末端代理全额获得
|
||||
- **WHEN** 触发一次性佣金
|
||||
- **AND** A1是末端代理(无下级)
|
||||
- **AND** A1能拿到10元
|
||||
- **THEN** A1实际获得 = 10元(全额)
|
||||
|
||||
#### Scenario: 独吞场景
|
||||
- **WHEN** 触发一次性佣金
|
||||
- **AND** A给A1设置的一次性佣金金额为0元
|
||||
- **THEN** A1实际获得 = 0元
|
||||
- **AND** A实际获得 = 自己能拿到的全部金额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 梯度佣金计算
|
||||
|
||||
系统 SHALL 根据代理当前销量/销售额所在梯度档位计算一次性佣金金额。
|
||||
|
||||
**变更说明**:新增统计范围开关(仅自己/自己+下级),梯度升级后上级获得增量。
|
||||
|
||||
#### Scenario: 按梯度计算
|
||||
- **WHEN** 触发一次性佣金
|
||||
- **AND** 代理A当前销量150(适用">=100返10元"档位)
|
||||
- **AND** A给A1设置5元
|
||||
- **THEN** A1实际获得 = 5元
|
||||
- **AND** A实际获得 = 10 - 5 = 5元
|
||||
|
||||
#### Scenario: 梯度升级
|
||||
- **WHEN** 代理A销量从150升到210
|
||||
- **AND** 适用档位从">=100返10元"变为">=200返20元"
|
||||
- **AND** A给A1设置仍为5元
|
||||
- **THEN** A1实际获得 = 5元(不变)
|
||||
- **AND** A实际获得 = 20 - 5 = 15元(增量归上级)
|
||||
|
||||
#### Scenario: 统计范围-仅自己
|
||||
- **WHEN** 梯度配置 `stat_scope = self`
|
||||
- **THEN** 只统计该代理直接产生的销量/销售额
|
||||
|
||||
#### Scenario: 统计范围-自己+下级
|
||||
- **WHEN** 梯度配置 `stat_scope = self_and_sub`
|
||||
- **THEN** 统计该代理及所有下级代理的销量/销售额之和
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 差价佣金计算
|
||||
|
||||
差价佣金计算规则不变:上级代理的佣金 = 下级成本价 - 自己成本价。
|
||||
|
||||
#### Scenario: 差价佣金计算
|
||||
- **WHEN** 代理A1销售一单
|
||||
- **AND** A的成本价120元,A1的成本价130元
|
||||
- **THEN** A的差价佣金 = 130 - 120 = 10元
|
||||
@@ -0,0 +1,61 @@
|
||||
# 一次性佣金链式分配
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 分配时设置下级一次性佣金金额
|
||||
|
||||
系统 SHALL 允许上级代理在分配套餐时设置给下级的一次性佣金金额。该金额 MUST 小于等于上级自己能拿到的一次性佣金金额,且 MUST 大于等于 0。
|
||||
|
||||
#### Scenario: 设置有效的下级佣金金额
|
||||
- **WHEN** 代理A分配套餐给代理A1,设置一次性佣金金额为 10 元
|
||||
- **AND** 代理A自己能拿到的一次性佣金为 20 元
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** 代理A1的一次性佣金金额记录为 10 元
|
||||
|
||||
#### Scenario: 设置超额的下级佣金金额
|
||||
- **WHEN** 代理A分配套餐给代理A1,设置一次性佣金金额为 25 元
|
||||
- **AND** 代理A自己能拿到的一次性佣金为 20 元
|
||||
- **THEN** 系统 SHALL 拒绝该配置
|
||||
- **AND** 返回错误"给下级的一次性佣金不能超过自己能拿到的金额"
|
||||
|
||||
#### Scenario: 设置零佣金(独吞)
|
||||
- **WHEN** 代理A分配套餐给代理A1,设置一次性佣金金额为 0 元
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** 代理A1的一次性佣金金额记录为 0 元
|
||||
|
||||
### Requirement: 链式佣金分配计算
|
||||
|
||||
当一次性佣金触发时,系统 SHALL 沿代理链向上计算并分配佣金。每级代理实际获得的佣金 = 自己能拿到的金额 - 给下级的金额。
|
||||
|
||||
#### Scenario: 三级代理链佣金分配
|
||||
- **WHEN** 代理链为 平台 → A → A1 → A2
|
||||
- **AND** 系列规则:首充100返20元
|
||||
- **AND** 平台给A设置:20元
|
||||
- **AND** A给A1设置:8元
|
||||
- **AND** A1给A2设置:5元
|
||||
- **AND** A2的客户触发首充
|
||||
- **THEN** A2 SHALL 获得 5 元
|
||||
- **AND** A1 SHALL 获得 8 - 5 = 3 元
|
||||
- **AND** A SHALL 获得 20 - 8 = 12 元
|
||||
- **AND** 总分配金额 = 20 元
|
||||
|
||||
#### Scenario: 末端代理无下级
|
||||
- **WHEN** 代理A1是末端代理(无下级)
|
||||
- **AND** A1的客户触发首充
|
||||
- **AND** A1能拿到的一次性佣金为 10 元
|
||||
- **THEN** A1 SHALL 获得完整的 10 元
|
||||
|
||||
### Requirement: 代理只能看到自己的一次性佣金金额
|
||||
|
||||
代理查看套餐列表时,系统 SHALL 只返回该代理能拿到的一次性佣金金额,不得返回系列规则的总金额或其他代理的配置。
|
||||
|
||||
#### Scenario: 代理A查看套餐
|
||||
- **WHEN** 代理A调用套餐列表接口
|
||||
- **AND** 该套餐的系列规则为首充100返20元
|
||||
- **AND** 平台给A设置的一次性佣金为 15 元
|
||||
- **THEN** 返回的一次性佣金金额 SHALL 为 15 元
|
||||
- **AND** 不得返回 20 元(总规则)
|
||||
|
||||
#### Scenario: 平台查看套餐
|
||||
- **WHEN** 平台管理员调用套餐列表接口
|
||||
- **THEN** 返回的一次性佣金 SHALL 显示完整规则(首充100返20元)
|
||||
@@ -0,0 +1,72 @@
|
||||
# 强充检查变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 首充强充金额计算
|
||||
|
||||
当系列启用首充一次性佣金时,强充金额 SHALL 为 max(首充要求, 套餐售价)。
|
||||
|
||||
**变更说明**:明确首充强充金额的计算公式。
|
||||
|
||||
#### Scenario: 首充要求小于套餐价格
|
||||
- **WHEN** 首充要求50元,套餐售价100元
|
||||
- **THEN** 强充金额 = 100元(取套餐价格)
|
||||
|
||||
#### Scenario: 首充要求等于套餐价格
|
||||
- **WHEN** 首充要求100元,套餐售价100元
|
||||
- **THEN** 强充金额 = 100元
|
||||
|
||||
#### Scenario: 首充要求大于套餐价格
|
||||
- **WHEN** 首充要求200元,套餐售价100元
|
||||
- **THEN** 强充金额 = 200元(取首充要求)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 累计充值强充金额计算
|
||||
|
||||
当系列启用累计充值一次性佣金且启用强充时,系统 SHALL 支持两种强充金额计算方式:固定金额和动态差额。
|
||||
|
||||
**变更说明**:新增强充金额计算方式开关。
|
||||
|
||||
#### Scenario: 固定金额模式
|
||||
- **WHEN** 强充配置 `force_calc_type = fixed`
|
||||
- **AND** `force_amount = 10000`(100元)
|
||||
- **THEN** 强充金额 = 100元(固定值)
|
||||
|
||||
#### Scenario: 动态差额模式
|
||||
- **WHEN** 强充配置 `force_calc_type = dynamic`
|
||||
- **AND** 累计要求200元
|
||||
- **AND** 当前已累计80元
|
||||
- **THEN** 强充金额 = 200 - 80 = 120元(差额)
|
||||
|
||||
#### Scenario: 动态差额已达标
|
||||
- **WHEN** 强充配置 `force_calc_type = dynamic`
|
||||
- **AND** 累计要求200元
|
||||
- **AND** 当前已累计250元
|
||||
- **THEN** 强充金额 = 0元(已达标,无需强充)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 强充流程
|
||||
|
||||
强充流程保持不变:先创建充值订单,支付成功后钱进入钱包,然后自动扣款购买套餐。
|
||||
|
||||
#### Scenario: 首充强充流程
|
||||
- **WHEN** 客户购买套餐触发首充强充
|
||||
- **AND** 强充金额200元,套餐售价100元
|
||||
- **THEN** 创建充值订单200元
|
||||
- **AND** 支付成功后钱包余额+200
|
||||
- **AND** 标记首充状态为"已触发"
|
||||
- **AND** 自动创建套餐购买订单并扣款100元
|
||||
- **AND** 触发首充返佣(按链式分配)
|
||||
- **AND** 钱包剩余100元
|
||||
|
||||
#### Scenario: 累计充值强充流程
|
||||
- **WHEN** 客户购买套餐触发累计充值强充
|
||||
- **AND** 强充金额120元,套餐售价100元
|
||||
- **THEN** 创建充值订单120元
|
||||
- **AND** 支付成功后钱包余额+120
|
||||
- **AND** 累计金额 += 120
|
||||
- **AND** 自动创建套餐购买订单并扣款100元
|
||||
- **AND** 如果累计达标则触发返佣
|
||||
- **AND** 钱包剩余20元
|
||||
@@ -0,0 +1,99 @@
|
||||
# 一次性佣金触发变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 一次性充值触发佣金
|
||||
|
||||
系统 SHALL 支持"首充"触发条件:当该卡/设备在该套餐系列下首次充值且金额 ≥ 配置阈值时触发一次性佣金。
|
||||
|
||||
**变更说明**:将 `single_recharge` 重命名为 `first_recharge`(首充),强调是"第一次"充值而非"单次"充值。
|
||||
|
||||
#### Scenario: 首充达到阈值
|
||||
- **WHEN** IoT卡在该系列下首次充值 500 元
|
||||
- **AND** 配置阈值 300 元
|
||||
- **AND** 该卡在该系列下未触发过首充返佣
|
||||
- **THEN** 系统按链式分配规则发放一次性佣金
|
||||
- **AND** 标记该卡在该系列下的首充状态为"已触发"
|
||||
|
||||
#### Scenario: 首充未达到阈值
|
||||
- **WHEN** IoT卡在该系列下首次充值 200 元
|
||||
- **AND** 配置阈值 300 元
|
||||
- **THEN** 系统不发放一次性佣金
|
||||
- **AND** 首充状态保持"未触发"
|
||||
|
||||
#### Scenario: 非首次充值
|
||||
- **WHEN** IoT卡在该系列下已触发过首充
|
||||
- **AND** 再次充值 500 元(≥阈值)
|
||||
- **THEN** 系统不发放一次性佣金
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 累计充值触发佣金
|
||||
|
||||
系统 SHALL 支持"累计充值"触发条件:当卡/设备在该套餐系列下的累计充值金额 ≥ 配置阈值时触发一次性佣金。
|
||||
|
||||
**变更说明**:累计范围改为按"套餐系列"累计,而非全局累计。只有充值操作累计,直接购买套餐不累计。
|
||||
|
||||
#### Scenario: 累计达到阈值
|
||||
- **WHEN** IoT卡在该系列下之前累计充值 200 元
|
||||
- **AND** 本次充值 150 元
|
||||
- **AND** 配置阈值 300 元
|
||||
- **THEN** 系统更新该系列的累计充值为 350 元
|
||||
- **AND** 累计 350 元 ≥ 300 元,系统按链式分配规则发放一次性佣金
|
||||
- **AND** 标记该卡在该系列下已触发累计充值返佣
|
||||
|
||||
#### Scenario: 累计未达到阈值
|
||||
- **WHEN** IoT卡在该系列下累计充值为 100 元
|
||||
- **AND** 本次充值 100 元
|
||||
- **AND** 配置阈值 300 元
|
||||
- **THEN** 系统更新累计充值为 200 元
|
||||
- **AND** 累计 200 元 < 300 元,系统不发放一次性佣金
|
||||
|
||||
#### Scenario: 直接购买不累计
|
||||
- **WHEN** IoT卡直接购买套餐(不经过充值)
|
||||
- **THEN** 该系列的累计充值金额不变
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 一次性佣金只发放一次
|
||||
|
||||
每张卡/设备在每个套餐系列下,一次性佣金 SHALL 只发放一次,无论是首充还是累计充值触发。通过按系列追踪的状态字段控制。
|
||||
|
||||
**变更说明**:首充状态和累计充值触发状态改为按套餐系列追踪,不同系列互不影响。
|
||||
|
||||
#### Scenario: 首次触发
|
||||
- **WHEN** 首次满足触发条件(首充或累计充值)
|
||||
- **THEN** 按链式分配规则发放佣金
|
||||
- **AND** 设置该系列的触发状态为 true
|
||||
|
||||
#### Scenario: 再次满足条件
|
||||
- **WHEN** 再次满足触发条件
|
||||
- **AND** 该系列的触发状态已为 true
|
||||
- **THEN** 不发放佣金
|
||||
|
||||
#### Scenario: 不同系列独立
|
||||
- **WHEN** IoT卡在系列1已触发一次性佣金
|
||||
- **AND** IoT卡首次满足系列2的触发条件
|
||||
- **THEN** 系统发放系列2的一次性佣金(如果规则启用)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 一次性佣金发放对象
|
||||
|
||||
一次性佣金 SHALL 按链式分配规则发放给代理链上的所有相关店铺。
|
||||
|
||||
**变更说明**:从"发放给直接归属店铺"改为"链式分配给代理链上所有店铺"。
|
||||
|
||||
#### Scenario: 链式发放
|
||||
- **WHEN** IoT卡归属代理A1
|
||||
- **AND** 代理链为 平台 → A → A1
|
||||
- **AND** 触发一次性佣金(系列规则返20元)
|
||||
- **AND** A能拿到20元,给A1设置10元
|
||||
- **THEN** A1获得10元
|
||||
- **AND** A获得20-10=10元
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
### Requirement: single_recharge 触发类型
|
||||
- **FROM**: `single_recharge`(单次充值)
|
||||
- **TO**: `first_recharge`(首充)
|
||||
@@ -0,0 +1,54 @@
|
||||
# 一次性佣金时效管理
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 时效类型配置
|
||||
|
||||
系统 SHALL 支持三种一次性佣金时效类型:永久(permanent)、固定日期(fixed_date)、相对时长(relative)。
|
||||
|
||||
#### Scenario: 配置永久有效
|
||||
- **WHEN** 配置一次性佣金规则时设置 `validity_type = permanent`
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** 该规则永久有效,不会过期
|
||||
|
||||
#### Scenario: 配置固定到期日期
|
||||
- **WHEN** 配置一次性佣金规则时设置 `validity_type = fixed_date`
|
||||
- **AND** `validity_value = "2025-12-31"`
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** 该规则在 2025-12-31 23:59:59 后失效
|
||||
|
||||
#### Scenario: 配置相对时长
|
||||
- **WHEN** 配置一次性佣金规则时设置 `validity_type = relative`
|
||||
- **AND** `validity_value = "3"` (表示 3 个月)
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** 该规则从创建时间起 3 个月后失效
|
||||
|
||||
### Requirement: 过期规则不触发返佣
|
||||
|
||||
当一次性佣金规则过期时,系统 SHALL 不再触发返佣,即使满足触发条件(首充/累计充值)。
|
||||
|
||||
#### Scenario: 规则过期后首充
|
||||
- **WHEN** 一次性佣金规则已过期
|
||||
- **AND** 客户首充达到阈值
|
||||
- **THEN** 系统 SHALL 不触发一次性佣金
|
||||
- **AND** 正常完成充值和套餐购买
|
||||
|
||||
#### Scenario: 规则有效期内首充
|
||||
- **WHEN** 一次性佣金规则在有效期内
|
||||
- **AND** 客户首充达到阈值
|
||||
- **THEN** 系统 SHALL 触发一次性佣金
|
||||
- **AND** 按链式分配规则分配佣金
|
||||
|
||||
### Requirement: 梯度统计周期与时效一致
|
||||
|
||||
当一次性佣金使用梯度模式时,梯度统计周期(销量/销售额)SHALL 与一次性佣金时效一致。时效结束后统计归零。
|
||||
|
||||
#### Scenario: 时效内统计
|
||||
- **WHEN** 一次性佣金时效为 3 个月
|
||||
- **AND** 使用销量梯度
|
||||
- **THEN** 销量统计 SHALL 只计算这 3 个月内的销量
|
||||
|
||||
#### Scenario: 时效结束后统计重置
|
||||
- **WHEN** 一次性佣金时效到期
|
||||
- **AND** 配置了新的一次性佣金时效
|
||||
- **THEN** 销量/销售额统计 SHALL 从零开始
|
||||
@@ -0,0 +1,74 @@
|
||||
# 套餐管理变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 创建套餐
|
||||
|
||||
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置(真流量必填、虚流量可选)、成本价和建议售价。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
|
||||
|
||||
**变更说明**:移除 `price`、`data_type`、`data_amount_mb` 参数,新增 `enable_virtual_data` 参数。
|
||||
|
||||
#### Scenario: 成功创建套餐
|
||||
- **WHEN** 管理员提交有效的套餐信息
|
||||
- **AND** 包含 `cost_price`、`suggested_retail_price`、`real_data_mb`
|
||||
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
|
||||
|
||||
#### Scenario: 创建带虚流量的套餐
|
||||
- **WHEN** 管理员提交套餐信息
|
||||
- **AND** `enable_virtual_data = true`
|
||||
- **AND** `virtual_data_mb = 800`
|
||||
- **AND** `real_data_mb = 1000`
|
||||
- **THEN** 系统创建套餐记录,虚流量配置正确保存
|
||||
|
||||
#### Scenario: 套餐编码重复
|
||||
- **WHEN** 管理员提交的套餐编码已存在(未删除)
|
||||
- **THEN** 系统返回错误 "套餐编码已存在"
|
||||
|
||||
#### Scenario: 关联不存在的套餐系列
|
||||
- **WHEN** 管理员指定的系列 ID 不存在
|
||||
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||
|
||||
#### Scenario: 缺少必填字段
|
||||
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、成本价、真流量)
|
||||
- **THEN** 系统返回参数验证错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Package 模型新增字段
|
||||
|
||||
系统 MUST 在 Package 模型中调整以下字段:
|
||||
- 移除 `price` 字段
|
||||
- 移除 `data_type` 字段
|
||||
- 移除 `data_amount_mb` 字段
|
||||
- 保留 `suggested_cost_price` 并重命名为 `cost_price`:成本价(分为单位)
|
||||
- 保留 `suggested_retail_price`:建议售价(分为单位)
|
||||
- 新增 `enable_virtual_data`:是否启用虚流量,布尔值,默认 false
|
||||
- 保留 `real_data_mb`:真实流量(必填)
|
||||
- 保留 `virtual_data_mb`:虚流量(启用时必填)
|
||||
- 保留 `shelf_status`:上架状态,1-上架 2-下架,默认 2
|
||||
|
||||
#### Scenario: 创建套餐时设置价格
|
||||
- **WHEN** 管理员创建套餐并设置成本价和建议售价
|
||||
- **THEN** 系统保存 `cost_price` 和 `suggested_retail_price`
|
||||
|
||||
#### Scenario: 查询套餐时返回价格
|
||||
- **WHEN** 管理员查询套餐详情或列表
|
||||
- **THEN** 响应中包含 `cost_price`、`suggested_retail_price`、`shelf_status`、`enable_virtual_data` 字段
|
||||
- **AND** 不再返回 `price`、`data_type`、`data_amount_mb` 字段
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: price 字段
|
||||
|
||||
**Reason**: `price` 字段语义不清,与 `suggested_cost_price`、`suggested_retail_price` 混淆
|
||||
**Migration**: 使用 `cost_price`(成本价)和 `suggested_retail_price`(建议售价)替代
|
||||
|
||||
### Requirement: data_type 字段
|
||||
|
||||
**Reason**: `data_type` 暗示真流量/虚流量二选一,但业务需求是共存
|
||||
**Migration**: 使用 `enable_virtual_data` 开关控制是否启用虚流量
|
||||
|
||||
### Requirement: data_amount_mb 字段
|
||||
|
||||
**Reason**: 语义不清,不知道是真流量还是虚流量
|
||||
**Migration**: 使用 `real_data_mb`(真流量)和 `virtual_data_mb`(虚流量)明确区分
|
||||
@@ -0,0 +1,55 @@
|
||||
# 套餐系列管理变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 套餐系列一次性佣金规则配置
|
||||
|
||||
系统 SHALL 在套餐系列层面配置一次性佣金的完整规则,包括触发条件、阈值、金额/梯度、时效、强充配置。
|
||||
|
||||
**变更说明**:一次性佣金规则从分配时配置改为在系列层面统一定义。分配时只设置"给下级多少"。
|
||||
|
||||
#### Scenario: 配置首充规则
|
||||
- **WHEN** 创建或更新套餐系列
|
||||
- **AND** 设置一次性佣金规则:`trigger_type = first_recharge`,`threshold = 10000`(100元),`commission_amount = 2000`(20元)
|
||||
- **THEN** 系统保存该规则配置
|
||||
|
||||
#### Scenario: 配置累计充值规则
|
||||
- **WHEN** 创建或更新套餐系列
|
||||
- **AND** 设置一次性佣金规则:`trigger_type = accumulated_recharge`,`threshold = 20000`(200元),`commission_amount = 4000`(40元)
|
||||
- **THEN** 系统保存该规则配置
|
||||
|
||||
#### Scenario: 配置梯度规则
|
||||
- **WHEN** 创建或更新套餐系列
|
||||
- **AND** 设置一次性佣金规则:`commission_type = tiered`
|
||||
- **AND** 梯度配置:销量>=0返5元,>=100返10元,>=200返20元
|
||||
- **THEN** 系统保存梯度配置
|
||||
|
||||
#### Scenario: 配置时效
|
||||
- **WHEN** 创建或更新套餐系列
|
||||
- **AND** 设置时效:`validity_type = relative`,`validity_value = 3`(3个月)
|
||||
- **THEN** 系统保存时效配置
|
||||
- **AND** 该规则在3个月后失效
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PackageSeries 模型新增字段
|
||||
|
||||
系统 MUST 在 PackageSeries 模型中新增一次性佣金规则配置字段:
|
||||
|
||||
**新增字段**(使用 JSONB 存储):
|
||||
- `one_time_commission_config`:一次性佣金规则配置(JSON)
|
||||
- `enable`:是否启用
|
||||
- `trigger_type`:触发类型(first_recharge / accumulated_recharge)
|
||||
- `threshold`:触发阈值(分)
|
||||
- `commission_type`:返佣类型(fixed / tiered)
|
||||
- `commission_amount`:固定返佣金额(分)
|
||||
- `tiers`:梯度配置数组
|
||||
- `validity_type`:时效类型(permanent / fixed_date / relative)
|
||||
- `validity_value`:时效值
|
||||
- `enable_force_recharge`:是否启用强充
|
||||
- `force_calc_type`:强充金额计算方式(fixed / dynamic)
|
||||
- `force_amount`:强充金额(fixed类型时)
|
||||
|
||||
#### Scenario: 查询系列详情包含规则
|
||||
- **WHEN** 查询套餐系列详情
|
||||
- **THEN** 返回完整的一次性佣金规则配置
|
||||
@@ -0,0 +1,62 @@
|
||||
# 套餐真流量/虚流量共存机制
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 真流量必填
|
||||
|
||||
创建或更新套餐时,系统 SHALL 要求 `real_data_mb`(真实流量额度)为必填字段,且 MUST 大于 0。
|
||||
|
||||
#### Scenario: 创建套餐时提供真流量
|
||||
- **WHEN** 创建套餐请求包含 `real_data_mb = 1000`
|
||||
- **THEN** 系统 SHALL 保存该套餐
|
||||
- **AND** `real_data_mb` 记录为 1000 MB
|
||||
|
||||
#### Scenario: 创建套餐时缺少真流量
|
||||
- **WHEN** 创建套餐请求未提供 `real_data_mb` 字段
|
||||
- **THEN** 系统 SHALL 拒绝请求
|
||||
- **AND** 返回参数验证失败错误
|
||||
|
||||
### Requirement: 虚流量可选开关
|
||||
|
||||
系统 SHALL 提供 `enable_virtual_data` 开关控制是否启用虚流量。启用时 MUST 提供 `virtual_data_mb`,且该值 MUST 小于等于 `real_data_mb`。
|
||||
|
||||
#### Scenario: 启用虚流量
|
||||
- **WHEN** 创建套餐请求包含 `enable_virtual_data = true`
|
||||
- **AND** `virtual_data_mb = 800`
|
||||
- **AND** `real_data_mb = 1000`
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** `enable_virtual_data` 记录为 true
|
||||
- **AND** `virtual_data_mb` 记录为 800 MB
|
||||
|
||||
#### Scenario: 启用虚流量但未提供额度
|
||||
- **WHEN** 创建套餐请求包含 `enable_virtual_data = true`
|
||||
- **AND** 未提供 `virtual_data_mb`
|
||||
- **THEN** 系统 SHALL 拒绝请求
|
||||
- **AND** 返回"启用虚流量时必须提供虚流量额度"错误
|
||||
|
||||
#### Scenario: 虚流量超过真流量
|
||||
- **WHEN** 创建套餐请求包含 `enable_virtual_data = true`
|
||||
- **AND** `virtual_data_mb = 1200`
|
||||
- **AND** `real_data_mb = 1000`
|
||||
- **THEN** 系统 SHALL 拒绝请求
|
||||
- **AND** 返回"虚流量不能超过真实流量"错误
|
||||
|
||||
#### Scenario: 不启用虚流量
|
||||
- **WHEN** 创建套餐请求包含 `enable_virtual_data = false`
|
||||
- **THEN** 系统 SHALL 保存该配置
|
||||
- **AND** `virtual_data_mb` 可为空或忽略
|
||||
|
||||
### Requirement: 停机判断目标值
|
||||
|
||||
轮询停机模块在判断是否停机时,系统 SHALL 根据 `enable_virtual_data` 选择目标值:启用虚流量时使用 `virtual_data_mb`,否则使用 `real_data_mb`。
|
||||
|
||||
#### Scenario: 启用虚流量的停机判断
|
||||
- **WHEN** 套餐配置 `enable_virtual_data = true`
|
||||
- **AND** `virtual_data_mb = 800`
|
||||
- **AND** `real_data_mb = 1000`
|
||||
- **THEN** 停机判断的目标流量值 SHALL 为 800 MB
|
||||
|
||||
#### Scenario: 未启用虚流量的停机判断
|
||||
- **WHEN** 套餐配置 `enable_virtual_data = false`
|
||||
- **AND** `real_data_mb = 1000`
|
||||
- **THEN** 停机判断的目标流量值 SHALL 为 1000 MB
|
||||
@@ -0,0 +1,53 @@
|
||||
# 店铺佣金梯度变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 梯度统计范围开关
|
||||
|
||||
系统 SHALL 支持配置梯度佣金的统计范围:仅自己(self)或自己+下级(self_and_sub)。
|
||||
|
||||
**变更说明**:新增 `stat_scope` 字段,允许配置统计是否包含下级代理的销量/销售额。
|
||||
|
||||
#### Scenario: 配置统计范围-仅自己
|
||||
- **WHEN** 配置梯度规则时设置 `stat_scope = self`
|
||||
- **THEN** 系统保存该配置
|
||||
- **AND** 计算梯度时只统计该代理直接产生的销量/销售额
|
||||
|
||||
#### Scenario: 配置统计范围-自己+下级
|
||||
- **WHEN** 配置梯度规则时设置 `stat_scope = self_and_sub`
|
||||
- **THEN** 系统保存该配置
|
||||
- **AND** 计算梯度时统计该代理及所有下级代理的销量/销售额之和
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 梯度统计周期
|
||||
|
||||
梯度佣金的统计周期 SHALL 与一次性佣金时效一致。时效结束后统计归零。
|
||||
|
||||
**变更说明**:统计周期从独立配置改为与一次性佣金时效绑定。
|
||||
|
||||
#### Scenario: 时效内统计
|
||||
- **WHEN** 一次性佣金时效为3个月(relative = 3)
|
||||
- **THEN** 销量/销售额统计只计算这3个月内的数据
|
||||
|
||||
#### Scenario: 时效结束统计重置
|
||||
- **WHEN** 一次性佣金时效到期
|
||||
- **AND** 配置了新的时效周期
|
||||
- **THEN** 销量/销售额统计从零开始
|
||||
|
||||
#### Scenario: 永久时效
|
||||
- **WHEN** 一次性佣金时效为永久(permanent)
|
||||
- **THEN** 销量/销售额永久累计,不会重置
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ShopSeriesOneTimeCommissionTier 模型新增字段
|
||||
|
||||
系统 MUST 在 ShopSeriesOneTimeCommissionTier 模型中新增统计范围字段:
|
||||
|
||||
**新增字段**:
|
||||
- `stat_scope`:统计范围,varchar(20),可选值 `self`/`self_and_sub`,默认 `self`
|
||||
|
||||
#### Scenario: 查询梯度配置包含统计范围
|
||||
- **WHEN** 查询梯度配置详情
|
||||
- **THEN** 返回包含 `stat_scope` 字段
|
||||
@@ -0,0 +1,71 @@
|
||||
# 店铺系列分配变更
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 创建店铺系列分配
|
||||
|
||||
系统 SHALL 允许上级店铺为下级店铺分配套餐系列。分配时配置基础返佣和一次性佣金金额(给下级的金额)。
|
||||
|
||||
**变更说明**:移除完整的一次性佣金配置字段(type、trigger、threshold、mode、value 等),改为只配置"给被分配店铺的一次性佣金金额"。一次性佣金规则从套餐系列获取。
|
||||
|
||||
#### Scenario: 创建分配并设置一次性佣金金额
|
||||
- **WHEN** 平台给代理A分配系列
|
||||
- **AND** 系列启用一次性佣金,规则为首充100返20元
|
||||
- **AND** 设置给A的一次性佣金金额为20元
|
||||
- **THEN** 系统创建分配记录
|
||||
- **AND** `one_time_commission_amount` 记录为 2000(分)
|
||||
|
||||
#### Scenario: 设置超额的一次性佣金金额
|
||||
- **WHEN** 代理A给代理A1分配系列
|
||||
- **AND** A自己能拿到的一次性佣金为15元
|
||||
- **AND** 设置给A1的一次性佣金金额为20元
|
||||
- **THEN** 系统拒绝该配置
|
||||
- **AND** 返回错误"给下级的一次性佣金不能超过自己能拿到的金额"
|
||||
|
||||
#### Scenario: 系列未启用一次性佣金
|
||||
- **WHEN** 分配的系列未启用一次性佣金
|
||||
- **THEN** 不需要设置 `one_time_commission_amount`
|
||||
- **AND** 该字段默认为 0
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ShopSeriesAllocation 模型字段调整
|
||||
|
||||
系统 MUST 调整 ShopSeriesAllocation 模型字段:
|
||||
|
||||
**保留字段**:
|
||||
- `shop_id`:被分配的店铺ID
|
||||
- `series_id`:套餐系列ID
|
||||
- `allocator_shop_id`:分配者店铺ID
|
||||
- `base_commission_mode`:基础返佣模式
|
||||
- `base_commission_value`:基础返佣值
|
||||
- `status`:状态
|
||||
|
||||
**新增字段**:
|
||||
- `one_time_commission_amount`:给被分配店铺的一次性佣金金额(分),默认0
|
||||
|
||||
**移除字段**:
|
||||
- `enable_one_time_commission`
|
||||
- `one_time_commission_type`
|
||||
- `one_time_commission_trigger`
|
||||
- `one_time_commission_threshold`
|
||||
- `one_time_commission_mode`
|
||||
- `one_time_commission_value`
|
||||
- `enable_force_recharge`
|
||||
- `force_recharge_amount`
|
||||
- `force_recharge_trigger_type`
|
||||
|
||||
#### Scenario: 查询分配详情
|
||||
- **WHEN** 查询店铺系列分配详情
|
||||
- **THEN** 返回 `one_time_commission_amount`(给该店铺的一次性佣金金额)
|
||||
- **AND** 不再返回完整的一次性佣金配置字段
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 分配时配置完整一次性佣金规则
|
||||
|
||||
**Reason**: 一次性佣金规则应在套餐系列层面定义,分配时只需设置"给下级多少"
|
||||
**Migration**:
|
||||
1. 一次性佣金规则(触发条件、阈值、金额/梯度)移到套餐系列的配置中
|
||||
2. 分配时只配置 `one_time_commission_amount`(给该代理的金额)
|
||||
3. 迁移脚本将现有 `one_time_commission_value` 迁移到新字段
|
||||
@@ -0,0 +1,125 @@
|
||||
# 套餐与佣金模型重构任务列表
|
||||
|
||||
> **注意**:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。
|
||||
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 创建迁移文件:Package 表移除 `price`/`data_type`/`data_amount_mb` 字段,新增 `enable_virtual_data` 字段
|
||||
- [x] 1.2 创建迁移文件:ShopPackageAllocation 表新增 `one_time_commission_amount` 字段
|
||||
- [x] 1.3 创建迁移文件:IoTCard 表新增 `accumulated_recharge_by_series` 和 `first_recharge_triggered_by_series` 字段(jsonb)
|
||||
- [x] 1.4 创建迁移文件:Device 表新增 `accumulated_recharge_by_series` 和 `first_recharge_triggered_by_series` 字段(jsonb)
|
||||
- [x] 1.5 创建迁移文件:PackageSeries 表新增 `one_time_commission_config` 字段(jsonb)
|
||||
- [x] 1.6 创建迁移文件:ShopSeriesOneTimeCommissionTier 表新增 `stat_scope` 字段
|
||||
- [x] 1.7 创建迁移文件:ShopSeriesAllocation 表移除一次性佣金配置字段,新增 `one_time_commission_amount` 字段
|
||||
- [x] 1.8 执行迁移并验证
|
||||
|
||||
## 2. Model 层更新
|
||||
|
||||
- [x] 2.1 更新 Package 模型:移除 `Price`/`DataType`/`DataAmountMB` 字段,新增 `EnableVirtualData` 字段
|
||||
- [x] 2.2 更新 ShopPackageAllocation 模型:新增 `OneTimeCommissionAmount` 字段
|
||||
- [x] 2.3 更新 IoTCard 模型:新增 `AccumulatedRechargeBySeriesJSON` 和 `FirstRechargeTriggeredBySeriesJSON` 字段
|
||||
- [x] 2.4 更新 Device 模型:新增 `AccumulatedRechargeBySeriesJSON` 和 `FirstRechargeTriggeredBySeriesJSON` 字段
|
||||
- [x] 2.5 更新 PackageSeries 模型:新增 `OneTimeCommissionConfigJSON` 字段
|
||||
- [x] 2.6 创建 OneTimeCommissionConfig 结构体(含 Enable, TriggerType, Threshold, CommissionType, ValidityType 等字段)
|
||||
- [x] 2.7 更新 ShopSeriesOneTimeCommissionTier 模型:新增 `StatScope` 字段
|
||||
- [x] 2.8 更新 ShopSeriesAllocation 模型:移除一次性佣金配置字段,新增 `OneTimeCommissionAmount` 字段
|
||||
- [x] 2.9 为 IoTCard/Device 添加累计充值和首充状态的 getter/setter 辅助方法
|
||||
- [x] 2.10 运行 `lsp_diagnostics` 验证 Model 层无编译错误
|
||||
|
||||
## 3. DTO 层更新
|
||||
|
||||
- [x] 3.1 更新 CreatePackageRequest:移除 `price`/`data_type`/`data_amount_mb`,新增 `enable_virtual_data`
|
||||
- [x] 3.2 更新 UpdatePackageRequest:同上调整字段
|
||||
- [x] 3.3 更新 PackageResponse:移除废弃字段,新增 `enable_virtual_data`、`one_time_commission_amount`
|
||||
- [x] 3.4 更新 CreateShopPackageAllocationRequest:新增 `one_time_commission_amount` 字段
|
||||
- [x] 3.5 更新 ShopPackageAllocationResponse:新增 `one_time_commission_amount` 字段
|
||||
- [x] 3.6 更新 CreatePackageSeriesRequest:新增 `one_time_commission_config` 嵌套结构
|
||||
- [x] 3.7 更新 PackageSeriesResponse:返回一次性佣金规则配置
|
||||
- [x] 3.8 简化 CreateShopSeriesAllocationRequest:移除完整一次性佣金配置字段,改为 `one_time_commission_amount`
|
||||
- [x] 3.9 简化 ShopSeriesAllocationResponse:移除完整一次性佣金配置字段
|
||||
- [x] 3.10 运行 `lsp_diagnostics` 验证 DTO 层无编译错误
|
||||
|
||||
## 4. Store 层更新
|
||||
|
||||
- [x] 4.1 更新 PackageStore:适配新字段的 CRUD 操作(GORM Save 自动处理)
|
||||
- [x] 4.2 更新 ShopPackageAllocationStore:支持 `one_time_commission_amount` 字段(GORM Save 自动处理)
|
||||
- [x] 4.3 更新 PackageSeriesStore:支持 `one_time_commission_config` JSON 字段的读写(GORM Save 自动处理)
|
||||
- [x] 4.4 更新 ShopSeriesAllocationStore:移除完整一次性佣金配置字段的处理(Model 层已移除字段)
|
||||
- [x] 4.5 新增 IoTCardStore 方法:UpdateRechargeTrackingFields
|
||||
- [x] 4.6 新增 IoTCardStore 方法:(通过 Model 层 getter/setter 辅助方法实现)
|
||||
- [x] 4.7 新增 IoTCardStore 方法:(通过 Model 层 getter/setter 辅助方法实现)
|
||||
- [x] 4.8 新增 DeviceStore 方法:UpdateRechargeTrackingFields
|
||||
- [x] 4.9 运行 `lsp_diagnostics` 验证 Store 层无编译错误
|
||||
|
||||
## 5. Service 层更新 - 套餐管理
|
||||
|
||||
- [x] 5.1 更新 PackageService.Create:校验虚流量配置(启用时必填且 ≤ 真流量)
|
||||
- [x] 5.2 更新 PackageService.Update:同上校验逻辑
|
||||
- [x] 5.3 更新 PackageService.List:根据用户类型返回不同视角的成本价和一次性佣金金额
|
||||
- [x] 5.4 新增辅助方法:获取代理的套餐分配关系并填充视角数据
|
||||
- [x] 5.5 编写 PackageService 单元测试:虚流量配置校验场景
|
||||
- [ ] 5.6 编写 PackageService 单元测试:代理视角套餐列表场景(需要完整测试环境)
|
||||
|
||||
## 6. Service 层更新 - 套餐分配
|
||||
|
||||
- [x] 6.1 更新 ShopPackageAllocationService.Create:支持设置一次性佣金金额
|
||||
- [x] 6.2 新增校验逻辑:一次性佣金金额 ≤ 上级能拿到的金额
|
||||
- [x] 6.3 新增校验逻辑:一次性佣金金额 ≥ 0
|
||||
- [x] 6.4 更新 ShopPackageAllocationService.Update:支持修改一次性佣金金额
|
||||
- [x] 6.5 编写 ShopPackageAllocationService 单元测试:一次性佣金金额校验场景
|
||||
|
||||
## 7. Service 层更新 - 系列管理与分配
|
||||
|
||||
- [x] 7.1 更新 PackageSeriesService:支持一次性佣金规则配置的 CRUD
|
||||
- [x] 7.2 简化 ShopSeriesAllocationService.Create:移除完整一次性佣金配置的处理
|
||||
- [x] 7.3 简化 ShopSeriesAllocationService.Update:同上
|
||||
- [x] 7.4 更新验证逻辑:从套餐系列获取一次性佣金规则进行校验
|
||||
- [ ] 7.5 编写单元测试
|
||||
|
||||
## 8. Service 层更新 - 佣金计算
|
||||
|
||||
- [x] 8.1 重构一次性佣金触发逻辑:支持按系列追踪首充和累计充值状态
|
||||
- [x] 8.2 实现链式分配计算逻辑:沿代理链向上计算各级代理分得的佣金
|
||||
- [x] 8.3 更新累计充值逻辑:只有充值操作累计,直接购买不累计
|
||||
- [x] 8.4 更新首充判断逻辑:从 `single_recharge` 改为 `first_recharge`
|
||||
- [x] 8.5 实现一次性佣金时效检查:过期规则不触发返佣
|
||||
- [x] 8.6 更新梯度佣金计算:支持 `stat_scope` 配置
|
||||
- [x] 8.7 编写佣金计算 Service 单元测试:链式分配场景
|
||||
- [x] 8.8 编写佣金计算 Service 单元测试:首充/累计充值场景
|
||||
- [x] 8.9 编写佣金计算 Service 单元测试:梯度升级场景(已实现时效检查测试)
|
||||
|
||||
## 9. Service 层更新 - 强充检查
|
||||
|
||||
- [x] 9.1 更新首充强充金额计算:max(首充要求, 套餐售价)
|
||||
- [x] 9.2 新增累计充值强充金额计算:支持固定/动态两种模式
|
||||
- [x] 9.3 更新强充流程:支持累计充值的累计逻辑
|
||||
- [ ] 9.4 编写强充检查 Service 单元测试
|
||||
|
||||
## 10. Handler 层更新
|
||||
|
||||
- [x] 10.1 更新 PackageHandler:适配新 DTO 结构
|
||||
- [x] 10.2 更新 PackageSeriesHandler:支持一次性佣金规则配置
|
||||
- [x] 10.3 更新 ShopPackageAllocationHandler:支持一次性佣金金额
|
||||
- [x] 10.4 更新 ShopSeriesAllocationHandler:简化请求/响应结构
|
||||
- [x] 10.5 更新文档生成器 cmd/api/docs.go 和 cmd/gendocs/main.go
|
||||
- [x] 10.6 运行 `lsp_diagnostics` 验证 Handler 层无编译错误
|
||||
|
||||
## 11. 集成测试
|
||||
|
||||
- [ ] 11.1 编写套餐 CRUD 集成测试:验证虚流量配置
|
||||
- [ ] 11.2 编写套餐分配集成测试:验证一次性佣金金额
|
||||
- [ ] 11.3 编写系列分配集成测试:验证简化后的配置
|
||||
- [ ] 11.4 编写代理视角套餐列表集成测试
|
||||
- [ ] 11.5 编写一次性佣金触发集成测试:首充场景
|
||||
- [ ] 11.6 编写一次性佣金触发集成测试:累计充值场景
|
||||
- [ ] 11.7 编写链式佣金分配集成测试
|
||||
- [x] 11.8 运行全部测试确保通过
|
||||
|
||||
## 12. 验收
|
||||
|
||||
- [x] 12.1 运行完整测试套件,确保全部通过
|
||||
- [x] 12.2 运行 `go build` 确保编译通过
|
||||
- [ ] 12.3 本地环境功能验证:套餐创建/修改流程
|
||||
- [ ] 12.4 本地环境功能验证:套餐分配流程
|
||||
- [ ] 12.5 本地环境功能验证:一次性佣金触发流程
|
||||
- [ ] 12.6 更新 OpenAPI 文档确认变更已反映
|
||||
@@ -0,0 +1,66 @@
|
||||
# 共识文档
|
||||
|
||||
**Change**: refactor-one-time-commission-allocation
|
||||
**确认时间**: 2026-02-04T11:50:00+08:00
|
||||
**确认人**: 用户(通过 Question_tool 逐条确认)
|
||||
|
||||
---
|
||||
|
||||
## 1. 要做什么
|
||||
|
||||
- [x] 创建 `tb_shop_series_allocation` 新表,专门管理系列分配和一次性佣金(已确认)
|
||||
- [x] 将 `one_time_commission_amount` 从 `ShopPackageAllocation` 移到新表(已确认)
|
||||
- [x] `ShopPackageAllocation` 精简为只管成本价,添加 `series_allocation_id` 关联(已确认)
|
||||
- [x] `PackageSeries` 添加 `enable_one_time_commission` 布尔字段(提升到顶层)(已确认)
|
||||
- [x] 梯度模式下也实现链式分配(代理能拿的金额 = min(梯度匹配金额, 上级给的上限))(已确认)
|
||||
- [x] 删除未使用的 `ShopSeriesOneTimeCommissionTier` 表和相关代码(已确认)
|
||||
- [x] 新增系列分配 API(CRUD)(已确认)
|
||||
- [x] 业务流程改造:必须先分配系列,再分配套餐(已确认)
|
||||
- [x] 佣金计算逻辑改为从系列分配获取佣金配置(已确认)
|
||||
|
||||
## 2. 不做什么
|
||||
|
||||
- [x] 不保留旧接口的兼容性(直接切换)(已确认)
|
||||
- [x] 不支持代理自定义梯度规则(所有代理使用平台统一规则)(已确认)
|
||||
- [x] 不在此次改造中修改前端交互流程(后续单独处理)(已确认)
|
||||
|
||||
## 3. 关键约束
|
||||
|
||||
- [x] 遵循项目技术栈规范(Handler → Service → Store → Model)(已确认)
|
||||
- [x] 删除代码前必须确认无调用(`ShopSeriesOneTimeCommissionTier` 相关)(已确认)
|
||||
- [x] `ShopSeriesCommissionStats` 的 `allocation_id` 需要重新关联到系列分配(已确认)
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
- [x] 同一 shop + series 只存在一条系列分配记录(唯一约束)(已确认)
|
||||
- [x] 触发佣金时直接查询系列分配,不再有"取第一个"的 hack(已确认)
|
||||
- [x] `enable_one_time_commission` 可通过 SQL WHERE 直接查询(已确认)
|
||||
- [x] 分配套餐前必须先分配系列,否则报错(已确认)
|
||||
- [x] 使用新工作流生成的验收测试和流程测试全部通过(已确认)
|
||||
|
||||
---
|
||||
|
||||
## 讨论背景
|
||||
|
||||
用户发现一次性佣金架构存在设计问题:
|
||||
|
||||
1. **概念与存储错位**:一次性佣金是"系列级"概念,但 `one_time_commission_amount` 存储在"套餐分配"(`ShopPackageAllocation`)中
|
||||
2. **数据冗余**:同一系列的多个套餐分配时,佣金配置需要重复设置
|
||||
3. **隐性假设**:代码靠"取第一个"(`GetByShopAndSeries` + `LIMIT 1`)来获取佣金,假设同系列配置相同但没有约束保证
|
||||
4. **`enable` 藏在 JSON 里**:判断是否启用一次性佣金需要解析 JSON,无法高效查询
|
||||
5. **废弃代码**:`ShopSeriesOneTimeCommissionTier` 表定义了但完全没有被使用
|
||||
|
||||
## 关键决策记录
|
||||
|
||||
| 决策点 | 选择 | 原因 |
|
||||
|--------|------|------|
|
||||
| 佣金存储位置 | 新建 `ShopSeriesAllocation` 表 | 职责分离:系列分配管佣金,套餐分配只管成本价 |
|
||||
| 梯度模式下的分配 | 链式分配(min(梯度匹配, 上级上限)) | 保持与固定模式一致的业务逻辑 |
|
||||
| 数据迁移 | 不做(开发阶段) | 现阶段无需迁移生产数据 |
|
||||
| 旧接口兼容 | 不保留 | 简化实现,直接切换 |
|
||||
| 代理自定义梯度 | 不支持 | 所有代理使用平台统一规则,简化配置 |
|
||||
| 测试策略 | 使用新工作流验收测试 | 替代原有意义不大的测试 |
|
||||
|
||||
---
|
||||
|
||||
**签字确认**: 用户已通过 Question_tool 逐条确认以上内容
|
||||
@@ -0,0 +1,274 @@
|
||||
# Design: refactor-one-time-commission-allocation
|
||||
|
||||
## Context
|
||||
|
||||
### 当前状态
|
||||
|
||||
一次性佣金配置分散在两个层级:
|
||||
|
||||
```
|
||||
tb_package_series
|
||||
├── one_time_commission_config (JSONB) ← 平台定义的规则(触发条件、梯度等)
|
||||
│ └── { enable: true, trigger_type: "first_recharge", ... }
|
||||
│
|
||||
tb_shop_package_allocation
|
||||
├── one_time_commission_amount ← 代理能拿的金额(但这是套餐级!)
|
||||
└── series_id ← 冗余字段
|
||||
```
|
||||
|
||||
问题:
|
||||
1. 一个系列有多个套餐时,每个套餐分配都要重复设置佣金
|
||||
2. 代码通过 `GetByShopAndSeries` + `LIMIT 1` 获取佣金,假设同系列配置相同
|
||||
3. `enable` 藏在 JSON 里,无法索引查询
|
||||
|
||||
### 目标状态
|
||||
|
||||
```
|
||||
tb_package_series
|
||||
├── enable_one_time_commission (bool) ← 提升到顶层,可索引
|
||||
├── one_time_commission_config (JSONB) ← 只存规则详情
|
||||
│
|
||||
tb_shop_series_allocation (新表)
|
||||
├── shop_id + series_id (唯一) ← 一个店铺+系列只有一条记录
|
||||
├── one_time_commission_amount ← 代理能拿的一次性佣金
|
||||
│
|
||||
tb_shop_package_allocation
|
||||
├── series_allocation_id ← 关联系列分配
|
||||
├── cost_price ← 只管成本价
|
||||
└── (移除 one_time_commission_amount, series_id)
|
||||
```
|
||||
|
||||
### 约束
|
||||
|
||||
- 遵循 Handler → Service → Store → Model 分层
|
||||
- 禁止外键约束,通过 ID 字段手动关联
|
||||
- 常量定义在 `pkg/constants/`
|
||||
- 开发阶段,无需数据迁移
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 职责分离:系列分配管一次性佣金,套餐分配只管成本价
|
||||
- 数据不冗余:一个 shop + series 只有一条佣金配置
|
||||
- 消除隐性假设:直接查询系列分配,无需"取第一个"
|
||||
- 查询高效:`enable_one_time_commission` 可索引
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不保留旧接口兼容性
|
||||
- 不支持代理自定义梯度规则
|
||||
- 不修改前端交互流程
|
||||
- 不做数据迁移
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 新建 `ShopSeriesAllocation` 表而非修改现有表
|
||||
|
||||
**选择**: 新建 `tb_shop_series_allocation` 表
|
||||
|
||||
**备选方案**:
|
||||
- A) 在 `ShopPackageAllocation` 中保留佣金,添加约束确保同系列一致
|
||||
- B) 新建 `ShopSeriesAllocation` 表,专门管理系列级配置
|
||||
|
||||
**选择 B 的理由**:
|
||||
- 职责单一:套餐分配只管成本价,系列分配只管佣金
|
||||
- 数据模型清晰:一个 shop + series 对应一条记录,符合业务概念
|
||||
- 避免复杂约束:方案 A 需要触发器或应用层约束保证一致性
|
||||
|
||||
### Decision 2: `enable_one_time_commission` 提升到顶层字段
|
||||
|
||||
**选择**: 在 `PackageSeries` 添加布尔字段 `enable_one_time_commission`
|
||||
|
||||
**备选方案**:
|
||||
- A) 保留在 JSON 中,查询时解析
|
||||
- B) 提升到顶层字段
|
||||
|
||||
**选择 B 的理由**:
|
||||
- 可建索引:`WHERE enable_one_time_commission = true`
|
||||
- 减少 JSON 解析开销
|
||||
- 语义清晰:开关是开关,配置是配置
|
||||
|
||||
### Decision 3: 梯度模式下的链式分配
|
||||
|
||||
**选择**: 代理能拿的金额 = min(梯度匹配金额, 上级给的上限)
|
||||
|
||||
**备选方案**:
|
||||
- A) 梯度模式下完全由销量决定,无上限约束
|
||||
- B) 固定模式和梯度模式统一使用链式分配
|
||||
|
||||
**选择 B 的理由**:
|
||||
- 业务一致性:不论哪种模式,上级都能控制下级的佣金上限
|
||||
- 防止佣金倒挂:下级不可能拿到比上级给的更多
|
||||
|
||||
### Decision 4: 套餐分配依赖系列分配
|
||||
|
||||
**选择**: 分配套餐前必须先分配对应的系列
|
||||
|
||||
**实现方式**:
|
||||
```go
|
||||
// ShopPackageAllocationService.Create
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateRequest) error {
|
||||
// 1. 获取套餐信息
|
||||
pkg, _ := s.packageStore.GetByID(ctx, req.PackageID)
|
||||
|
||||
// 2. 检查系列分配是否存在
|
||||
seriesAlloc, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, pkg.SeriesID)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInvalidParam, "请先分配该套餐所属的系列")
|
||||
}
|
||||
|
||||
// 3. 创建套餐分配,关联系列分配
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: req.ShopID,
|
||||
PackageID: req.PackageID,
|
||||
SeriesAllocationID: seriesAlloc.ID,
|
||||
CostPrice: req.CostPrice,
|
||||
// ...
|
||||
}
|
||||
return s.store.Create(ctx, allocation)
|
||||
}
|
||||
```
|
||||
|
||||
### Decision 5: 删除未使用的 `ShopSeriesOneTimeCommissionTier` 表
|
||||
|
||||
**选择**: 直接删除表和相关代码
|
||||
|
||||
**理由**:
|
||||
- 经代码搜索确认完全未使用
|
||||
- Store 方法未被调用
|
||||
- 保留会造成混淆
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
internal/
|
||||
├── model/
|
||||
│ ├── shop_series_allocation.go # 新增
|
||||
│ ├── shop_package_allocation.go # 修改
|
||||
│ ├── package.go # 修改 PackageSeries
|
||||
│ └── dto/
|
||||
│ ├── shop_series_allocation.go # 新增
|
||||
│ └── shop_package_allocation.go # 修改
|
||||
│
|
||||
├── store/postgres/
|
||||
│ ├── shop_series_allocation_store.go # 新增
|
||||
│ └── shop_package_allocation_store.go # 修改
|
||||
│
|
||||
├── service/
|
||||
│ ├── shop_series_allocation/ # 新增
|
||||
│ │ └── service.go
|
||||
│ ├── shop_package_allocation/ # 修改
|
||||
│ ├── commission_calculation/ # 修改
|
||||
│ ├── recharge/ # 修改
|
||||
│ └── order/ # 修改
|
||||
│
|
||||
├── handler/admin/
|
||||
│ ├── shop_series_allocation.go # 新增
|
||||
│ └── shop_package_allocation.go # 修改
|
||||
│
|
||||
└── bootstrap/
|
||||
├── stores.go # 添加新 store
|
||||
├── services.go # 添加新 service
|
||||
└── handlers.go # 添加新 handler
|
||||
```
|
||||
|
||||
### 依赖注入
|
||||
|
||||
```go
|
||||
// bootstrap/stores.go
|
||||
type Stores struct {
|
||||
// 新增
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
// ...
|
||||
}
|
||||
|
||||
// bootstrap/services.go
|
||||
type Services struct {
|
||||
// 新增
|
||||
ShopSeriesAllocation *shop_series_allocation.Service
|
||||
// ...
|
||||
}
|
||||
|
||||
// 依赖关系
|
||||
ShopSeriesAllocationService
|
||||
├── ShopSeriesAllocationStore
|
||||
├── PackageSeriesStore
|
||||
└── ShopStore (获取下级店铺列表)
|
||||
|
||||
ShopPackageAllocationService
|
||||
├── ShopPackageAllocationStore
|
||||
├── ShopSeriesAllocationStore ← 新增依赖
|
||||
├── PackageStore
|
||||
└── ShopStore
|
||||
```
|
||||
|
||||
### 事务处理
|
||||
|
||||
**删除系列分配时级联处理**:
|
||||
```go
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return s.store.Transaction(ctx, func(tx *gorm.DB) error {
|
||||
// 1. 检查是否有关联的套餐分配
|
||||
count, _ := s.packageAllocationStore.CountBySeriesAllocationID(ctx, id)
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "存在关联的套餐分配,无法删除")
|
||||
}
|
||||
|
||||
// 2. 删除系列分配
|
||||
return s.store.Delete(ctx, id)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 常量定义
|
||||
|
||||
```go
|
||||
// pkg/constants/redis.go
|
||||
func RedisShopSeriesAllocationKey(shopID, seriesID uint) string {
|
||||
return fmt.Sprintf("shop_series_alloc:%d:%d", shopID, seriesID)
|
||||
}
|
||||
|
||||
// pkg/constants/constants.go
|
||||
const (
|
||||
// 系列分配状态
|
||||
SeriesAllocationStatusEnabled = 1
|
||||
SeriesAllocationStatusDisabled = 2
|
||||
)
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### Risk 1: 套餐分配 API 破坏性变更
|
||||
|
||||
**风险**: 移除 `one_time_commission_amount` 参数会破坏现有 API 调用
|
||||
|
||||
**缓解**:
|
||||
- 开发阶段,无生产调用方
|
||||
- 前端后续单独处理
|
||||
|
||||
### Risk 2: 佣金计算逻辑改动涉及多个 Service
|
||||
|
||||
**风险**: `commission_calculation`、`recharge`、`order` 都需要修改
|
||||
|
||||
**缓解**:
|
||||
- 抽取公共方法:`GetSeriesAllocationForShop(shopID, seriesID)`
|
||||
- 统一在 `ShopSeriesAllocationStore` 提供查询接口
|
||||
|
||||
### Risk 3: 删除代码可能遗漏引用
|
||||
|
||||
**风险**: `ShopSeriesOneTimeCommissionTier` 相关代码可能有遗漏引用
|
||||
|
||||
**缓解**:
|
||||
- 删除前全局搜索确认
|
||||
- 编译验证无引用
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **批量分配交互**:批量分配套餐时,如果系列未分配,是自动创建还是报错?
|
||||
- 当前决定:报错,要求先分配系列
|
||||
|
||||
2. **系列分配的状态管理**:系列分配禁用后,已有的套餐分配如何处理?
|
||||
- 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配
|
||||
@@ -0,0 +1,91 @@
|
||||
# Proposal: refactor-one-time-commission-allocation
|
||||
|
||||
**Feature ID**: feature-012-refactor-one-time-commission-allocation
|
||||
|
||||
## Why
|
||||
|
||||
当前一次性佣金架构存在概念与存储错位的问题:一次性佣金是"系列级"概念(每张卡/设备在该系列下只触发一次),但 `one_time_commission_amount` 却存储在"套餐分配"(`ShopPackageAllocation`)中。这导致:
|
||||
|
||||
1. **数据冗余**:同一系列的多个套餐分配时,佣金配置需要重复设置
|
||||
2. **隐性假设**:代码靠"取第一个"(`GetByShopAndSeries` + `LIMIT 1`)获取佣金,假设同系列配置相同但无约束保证
|
||||
3. **查询低效**:`enable` 藏在 JSON 里,无法高效查询
|
||||
4. **废弃代码**:`ShopSeriesOneTimeCommissionTier` 表定义了但完全未使用
|
||||
|
||||
## What Changes
|
||||
|
||||
### 新增
|
||||
|
||||
- 创建 `tb_shop_series_allocation` 新表,专门管理系列分配和一次性佣金
|
||||
- 新增系列分配 API(CRUD):`/api/admin/shop-series-allocations`
|
||||
- `PackageSeries` 添加 `enable_one_time_commission` 布尔字段(从 JSON 提升到顶层)
|
||||
|
||||
### 修改
|
||||
|
||||
- **BREAKING** `ShopPackageAllocation` 移除 `one_time_commission_amount` 和 `series_id` 字段,添加 `series_allocation_id` 关联
|
||||
- 佣金计算逻辑改为从系列分配获取佣金配置
|
||||
- 梯度模式下实现链式分配:代理能拿的金额 = min(梯度匹配金额, 上级给的上限)
|
||||
- 业务流程改造:必须先分配系列,再分配套餐
|
||||
- `ShopSeriesCommissionStats` 的 `allocation_id` 重新关联到系列分配
|
||||
|
||||
### 删除
|
||||
|
||||
- 删除 `ShopSeriesOneTimeCommissionTier` 表和相关代码(从未使用)
|
||||
- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法("取第一个"hack)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `shop-series-allocation`: 店铺系列分配模块,管理店铺对套餐系列的分配关系和一次性佣金配置
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `commission-calculation`: 佣金计算改用系列分配获取一次性佣金配置,梯度模式实现链式分配
|
||||
- `commission-trigger`: 佣金触发时从系列分配读取佣金金额
|
||||
|
||||
## Impact
|
||||
|
||||
### 代码影响
|
||||
|
||||
| 模块 | 影响 |
|
||||
|------|------|
|
||||
| `internal/model/` | 新增 `ShopSeriesAllocation`,修改 `ShopPackageAllocation`、`PackageSeries` |
|
||||
| `internal/store/postgres/` | 新增 `shop_series_allocation_store.go`,修改套餐分配和佣金相关 store |
|
||||
| `internal/service/` | 新增 `shop_series_allocation/`,修改 `commission_calculation/`、`recharge/`、`order/` |
|
||||
| `internal/handler/admin/` | 新增 `shop_series_allocation.go`,修改套餐分配 handler |
|
||||
| `internal/router/` | 添加系列分配路由 |
|
||||
|
||||
### API 影响
|
||||
|
||||
| 类型 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| 新增 | `POST /api/admin/shop-series-allocations` | 创建系列分配 |
|
||||
| 新增 | `GET /api/admin/shop-series-allocations` | 查询系列分配列表 |
|
||||
| 新增 | `GET /api/admin/shop-series-allocations/:id` | 获取系列分配详情 |
|
||||
| 新增 | `PUT /api/admin/shop-series-allocations/:id` | 更新系列分配 |
|
||||
| 新增 | `DELETE /api/admin/shop-series-allocations/:id` | 删除系列分配 |
|
||||
| **BREAKING** | `POST /api/admin/shop-package-allocations` | 移除 `one_time_commission_amount` 参数 |
|
||||
| **BREAKING** | `PUT /api/admin/shop-package-allocations/:id` | 移除 `one_time_commission_amount` 参数 |
|
||||
|
||||
### 数据库影响
|
||||
|
||||
- 新增表:`tb_shop_series_allocation`
|
||||
- 修改表:`tb_shop_package_allocation`(删除列)、`tb_package_series`(新增列)
|
||||
- 删除表:`tb_shop_series_one_time_commission_tier`
|
||||
|
||||
### 技术栈
|
||||
|
||||
- 遵循 Handler → Service → Store → Model 分层架构
|
||||
- 使用 Fiber v2.x + GORM v1.25.x
|
||||
- 使用新工作流生成验收测试和流程测试
|
||||
|
||||
### 测试计划
|
||||
|
||||
- 使用 `/opsx:gen-tests` 从 Spec 生成验收测试
|
||||
- 覆盖系列分配 CRUD、佣金计算链式分配、业务流程约束
|
||||
- 删除原有相关测试,使用新工作流测试替代
|
||||
|
||||
### 性能考虑
|
||||
|
||||
- 系列分配查询:直接通过 `shop_id + series_id` 唯一索引查询,无需 LIMIT 1
|
||||
- `enable_one_time_commission` 字段可建索引,支持高效过滤
|
||||
@@ -0,0 +1,147 @@
|
||||
# Delta Spec: commission-calculation
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 一次性佣金触发检查
|
||||
|
||||
系统 SHALL 在更新累计充值金额后立即检查是否触发一次性佣金。**一次性佣金金额从系列分配获取,而非套餐分配**。
|
||||
|
||||
**关键变更**:
|
||||
- 原来从 `ShopPackageAllocation.one_time_commission_amount` 获取佣金金额
|
||||
- 现在从 `ShopSeriesAllocation.one_time_commission_amount` 获取佣金金额
|
||||
- 梯度模式下采用链式分配:实际金额 = min(梯度匹配金额, 系列分配的上限)
|
||||
|
||||
#### Scenario: 累计达到阈值触发佣金(固定模式)
|
||||
- **WHEN** 更新累计充值后,累计值 >= 配置阈值
|
||||
- **AND** 卡/设备的 first_commission_paid = false
|
||||
- **AND** 系列配置为固定模式(type = "fixed")
|
||||
- **THEN** 系统从该卡/设备销售代理的系列分配获取 one_time_commission_amount
|
||||
- **AND** 发放该金额作为一次性佣金
|
||||
- **AND** 标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 累计达到阈值触发佣金(梯度模式)
|
||||
- **WHEN** 更新累计充值后,累计值 >= 配置阈值
|
||||
- **AND** 卡/设备的 first_commission_paid = false
|
||||
- **AND** 系列配置为梯度模式(type = "tiered")
|
||||
- **AND** 代理销售数量为 50 张,梯度配置:1-30张80元,31-100张100元
|
||||
- **AND** 代理的系列分配 one_time_commission_amount = 9000(90元上限)
|
||||
- **THEN** 梯度匹配金额 = 100 元(50张落在31-100档)
|
||||
- **AND** 实际发放金额 = min(100, 90) = 90 元
|
||||
- **AND** 标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 累计未达到阈值不触发
|
||||
- **WHEN** 更新累计充值后,累计值 < 配置阈值
|
||||
- **THEN** 系统不发放一次性佣金
|
||||
- **AND** first_commission_paid 保持不变
|
||||
|
||||
#### Scenario: 已发放过不重复触发
|
||||
- **WHEN** 更新累计充值后,累计值 >= 配置阈值
|
||||
- **AND** 卡/设备的 first_commission_paid = true
|
||||
- **THEN** 系统不重复发放一次性佣金
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 一次性佣金链式分配
|
||||
|
||||
系统 SHALL 为代理链上的每一级代理计算一次性佣金差价收入。每级代理的收入 = 自己的分配上限 - 下级的分配上限。
|
||||
|
||||
#### Scenario: 单级代理
|
||||
- **WHEN** 一级代理销售卡,系列分配 one_time_commission_amount = 10000(100元)
|
||||
- **AND** 无下级(终端销售)
|
||||
- **THEN** 一级代理获得 100 元一次性佣金
|
||||
|
||||
#### Scenario: 多级代理链式分配
|
||||
- **WHEN** 三级代理销售卡,各级系列分配:
|
||||
- 平台给一级:one_time_commission_amount = 10000(100元)
|
||||
- 一级给二级:one_time_commission_amount = 8000(80元)
|
||||
- 二级给三级:one_time_commission_amount = 5000(50元)
|
||||
- **THEN** 三级获得 50 元
|
||||
- **AND** 二级获得 30 元(80 - 50)
|
||||
- **AND** 一级获得 20 元(100 - 80)
|
||||
|
||||
#### Scenario: 梯度模式下的链式分配
|
||||
- **WHEN** 系列配置为梯度模式,梯度匹配金额为 120 元
|
||||
- **AND** 三级代理的系列分配上限为 50 元,二级为 80 元,一级为 100 元
|
||||
- **THEN** 三级获得 min(120, 50) = 50 元
|
||||
- **AND** 二级获得 min(120, 80) - 50 = 30 元
|
||||
- **AND** 一级获得 min(120, 100) - 80 = 20 元
|
||||
- **AND** 平台获得 120 - 100 = 20 元
|
||||
|
||||
#### Scenario: 某级差价为零
|
||||
- **WHEN** 某级代理分配的上限等于下级
|
||||
- **THEN** 该级代理一次性佣金差价为 0,不创建佣金记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包充值触发一次性佣金
|
||||
|
||||
钱包充值成功后 SHALL 更新累计充值,并检查是否触发一次性佣金。**佣金金额从系列分配获取**。
|
||||
|
||||
#### Scenario: 充值成功更新累计充值
|
||||
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
|
||||
- **THEN** 系统更新卡的 accumulated_recharge 为 300 元
|
||||
|
||||
#### Scenario: 充值达到首次充值阈值
|
||||
- **WHEN** 卡配置为首次充值触发,阈值 100 元,充值 100 元成功,未发放过佣金
|
||||
- **THEN** 系统从该卡销售代理的系列分配获取 one_time_commission_amount
|
||||
- **AND** 触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 充值达到累计充值阈值
|
||||
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,充值后累计达到 1000 元,未发放过佣金
|
||||
- **THEN** 系统从该卡销售代理的系列分配获取 one_time_commission_amount
|
||||
- **AND** 触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 充值未达阈值不触发
|
||||
- **WHEN** 充值后累计充值未达到阈值
|
||||
- **THEN** 系统不触发一次性佣金计算
|
||||
|
||||
#### Scenario: 已发放过不重复触发
|
||||
- **WHEN** 卡的一次性佣金已发放过(first_commission_paid = true)
|
||||
- **THEN** 系统不触发一次性佣金计算
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 系列分配查询优化
|
||||
|
||||
系统 SHALL 提供通过店铺和系列直接查询系列分配的方法,替代原有的"取第一个"逻辑。
|
||||
|
||||
#### Scenario: 直接查询系列分配
|
||||
- **WHEN** 需要获取代理的一次性佣金配置
|
||||
- **THEN** 系统通过 shop_id + series_id 直接查询 ShopSeriesAllocation
|
||||
- **AND** 返回唯一匹配的记录(唯一索引保证)
|
||||
|
||||
#### Scenario: 系列分配不存在
|
||||
- **WHEN** 查询的 shop_id + series_id 组合不存在分配记录
|
||||
- **THEN** 系统返回 "未找到系列分配配置" 错误
|
||||
- **AND** 不发放一次性佣金(但不影响成本价差收入计算)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: enable_one_time_commission 字段查询
|
||||
|
||||
系统 SHALL 从 PackageSeries 的顶层字段读取一次性佣金开关状态,支持 SQL 索引查询。
|
||||
|
||||
#### Scenario: 检查系列是否启用一次性佣金
|
||||
- **WHEN** 需要判断是否触发一次性佣金
|
||||
- **THEN** 系统查询 PackageSeries.enable_one_time_commission 字段
|
||||
- **AND** 不再解析 one_time_commission_config JSON
|
||||
|
||||
#### Scenario: 批量查询启用一次性佣金的系列
|
||||
- **WHEN** 需要统计启用一次性佣金的系列数量
|
||||
- **THEN** 系统可使用 `WHERE enable_one_time_commission = true` 直接查询
|
||||
- **AND** 无需 JSON 解析
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 从套餐分配获取一次性佣金金额
|
||||
|
||||
**Reason**: 一次性佣金是系列级概念,应从系列分配获取,而非套餐分配
|
||||
|
||||
**Migration**:
|
||||
- 原查询路径:`ShopPackageAllocation.one_time_commission_amount`
|
||||
- 新查询路径:`ShopSeriesAllocation.one_time_commission_amount`
|
||||
- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法
|
||||
@@ -0,0 +1,81 @@
|
||||
# Delta Spec: commission-trigger
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 佣金计算任务幂等性
|
||||
|
||||
系统 SHALL 确保佣金计算任务可重复执行,不重复发放佣金。**一次性佣金从系列分配获取金额**。
|
||||
|
||||
**关键变更**:
|
||||
- 任务执行时,一次性佣金金额从 `ShopSeriesAllocation` 获取
|
||||
- 不再依赖 `ShopPackageAllocation.one_time_commission_amount`
|
||||
|
||||
#### Scenario: 任务重复执行跳过计算
|
||||
- **WHEN** 佣金计算任务执行时,订单 `commission_status` 已为 `calculated`
|
||||
- **THEN** 系统跳过佣金计算和钱包入账操作
|
||||
- **AND** 任务返回成功(避免 Asynq 重试)
|
||||
- **AND** 日志记录"订单佣金已计算,跳过执行"
|
||||
|
||||
#### Scenario: 并发任务只有一个成功
|
||||
- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行
|
||||
- **THEN** 第一个任务成功完成计算并更新状态为 `calculated`
|
||||
- **AND** 第二个任务检查到状态已为 `calculated`,跳过计算
|
||||
|
||||
#### Scenario: 任务失败可安全重试
|
||||
- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用)
|
||||
- **THEN** Asynq 自动重试任务
|
||||
- **AND** 重试时幂等检查确保不重复发放佣金
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 佣金计算时查询系列分配
|
||||
|
||||
系统 SHALL 在佣金计算任务中通过系列分配获取一次性佣金配置。
|
||||
|
||||
#### Scenario: 获取销售代理的系列分配
|
||||
- **WHEN** 佣金计算任务执行,需要计算一次性佣金
|
||||
- **THEN** 系统根据订单的卡/设备找到销售代理(shop_id)
|
||||
- **AND** 根据套餐找到系列(series_id)
|
||||
- **AND** 查询 ShopSeriesAllocation(shop_id, series_id) 获取 one_time_commission_amount
|
||||
|
||||
#### Scenario: 系列分配不存在时处理
|
||||
- **WHEN** 佣金计算任务执行,但找不到对应的系列分配
|
||||
- **THEN** 系统记录警告日志 "未找到系列分配,跳过一次性佣金"
|
||||
- **AND** 继续计算成本价差收入(不因此失败)
|
||||
- **AND** 订单 commission_status 正常更新为 calculated
|
||||
|
||||
#### Scenario: 获取代理链的系列分配
|
||||
- **WHEN** 需要计算一次性佣金的链式分配
|
||||
- **THEN** 系统沿代理链向上查询每级代理的系列分配
|
||||
- **AND** 计算每级的差价收入
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CommissionStats 关联系列分配
|
||||
|
||||
系统 SHALL 将 ShopSeriesCommissionStats 的 allocation_id 关联到系列分配,而非套餐分配。
|
||||
|
||||
#### Scenario: 创建佣金统计记录
|
||||
- **WHEN** 发放一次性佣金后更新统计
|
||||
- **THEN** CommissionStats.allocation_id 指向 ShopSeriesAllocation.id
|
||||
- **AND** 不再指向 ShopPackageAllocation.id
|
||||
|
||||
#### Scenario: 查询店铺的系列佣金统计
|
||||
- **WHEN** 查询某店铺在某系列的佣金统计
|
||||
- **THEN** 通过 ShopSeriesAllocation.id 关联查询
|
||||
- **AND** 统计包括该系列下所有套餐产生的一次性佣金
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 从套餐分配读取佣金金额
|
||||
|
||||
**Reason**: 一次性佣金配置迁移到系列分配
|
||||
|
||||
**Migration**:
|
||||
- 原逻辑:通过 `GetByShopAndSeries` 查询套餐分配,取第一条的 `one_time_commission_amount`
|
||||
- 新逻辑:直接查询 `ShopSeriesAllocation(shop_id, series_id)`
|
||||
- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法
|
||||
@@ -0,0 +1,166 @@
|
||||
# Delta Spec: shop-series-allocation
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 为下级店铺分配套餐系列
|
||||
|
||||
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定**一次性佣金金额上限**(代替原来的基础返佣配置),MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。
|
||||
|
||||
**关键变更**:
|
||||
- 移除 `commission_mode` 和 `commission_value` 字段(基础返佣配置)
|
||||
- 新增 `one_time_commission_amount` 字段:代理能拿的一次性佣金金额上限(分)
|
||||
- 一次性佣金计算采用**链式分配**:代理实际获得金额 = min(系列配置的梯度/固定金额, 上级给的上限)
|
||||
|
||||
**API 接口变更**:
|
||||
- 移除请求/响应中的 `commission_mode`、`commission_value` 字段
|
||||
- 新增 `one_time_commission_amount` 字段(分,必填)
|
||||
|
||||
#### Scenario: 成功分配套餐系列
|
||||
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置 one_time_commission_amount = 5000(50元)
|
||||
- **THEN** 系统创建分配记录,下级代理能拿的一次性佣金上限为 50 元
|
||||
|
||||
#### Scenario: 链式分配金额计算
|
||||
- **WHEN** 平台给一级代理分配系列,one_time_commission_amount = 10000(100元)
|
||||
- **AND** 一级代理给二级代理分配,one_time_commission_amount = 8000(80元)
|
||||
- **AND** 二级代理给三级代理分配,one_time_commission_amount = 5000(50元)
|
||||
- **THEN** 三级代理能拿的一次性佣金上限为 50 元
|
||||
- **AND** 二级代理差价 = 80 - 50 = 30 元
|
||||
- **AND** 一级代理差价 = 100 - 80 = 20 元
|
||||
|
||||
#### Scenario: 下级金额不能超过上级
|
||||
- **WHEN** 代理尝试为下级分配,设置的 one_time_commission_amount 超过自己被分配的金额
|
||||
- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限"
|
||||
|
||||
#### Scenario: 分配时启用一次性佣金和强充
|
||||
- **WHEN** 代理为下级分配系列,one_time_commission_amount = 5000,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元)
|
||||
- **THEN** 系统保存配置:one_time_commission_amount = 5000,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 包含 one_time_commission_amount 字段**。
|
||||
|
||||
#### Scenario: 查询所有分配
|
||||
- **WHEN** 代理查询分配列表,不带筛选条件
|
||||
- **THEN** 系统返回该代理创建的所有分配记录,每条记录包含 one_time_commission_amount 字段
|
||||
|
||||
#### Scenario: 按店铺筛选
|
||||
- **WHEN** 代理指定下级店铺 ID 筛选
|
||||
- **THEN** 系统只返回该店铺的分配记录,记录包含 one_time_commission_amount 字段
|
||||
|
||||
#### Scenario: 响应包含一次性佣金金额
|
||||
- **WHEN** 查询分配列表或详情
|
||||
- **THEN** 每条记录包含 one_time_commission_amount 字段(分)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 更新套餐系列分配
|
||||
|
||||
系统 SHALL 允许代理更新分配的一次性佣金金额、一次性佣金配置和强充配置。**API 请求 MUST 支持更新 one_time_commission_amount 字段**。
|
||||
|
||||
#### Scenario: 更新一次性佣金金额
|
||||
- **WHEN** 代理将 one_time_commission_amount 从 5000 改为 6000
|
||||
- **THEN** 系统更新分配记录,后续一次性佣金按新金额计算
|
||||
|
||||
#### Scenario: 更新金额不能超过上级上限
|
||||
- **WHEN** 代理尝试将 one_time_commission_amount 更新为超过自己被分配上限的值
|
||||
- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限"
|
||||
|
||||
#### Scenario: 更新强充配置
|
||||
- **WHEN** 代理将 enable_force_recharge 从 false 改为 true,设置 force_recharge_amount = 10000
|
||||
- **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求
|
||||
|
||||
#### Scenario: 更新不存在的分配
|
||||
- **WHEN** 代理更新不存在的分配 ID
|
||||
- **THEN** 系统返回 "分配记录不存在" 错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 平台分配套餐系列
|
||||
|
||||
平台管理员 SHALL 能够为一级代理分配套餐系列,指定一次性佣金金额上限。平台作为分配链顶端,其金额上限由系列配置决定。
|
||||
|
||||
#### Scenario: 平台为一级代理分配
|
||||
- **WHEN** 平台管理员为一级代理分配套餐系列,设置 one_time_commission_amount = 10000(100元)
|
||||
- **THEN** 系统创建分配记录,一级代理能拿的一次性佣金上限为 100 元
|
||||
|
||||
#### Scenario: 平台金额不能超过系列配置
|
||||
- **WHEN** 套餐系列配置的一次性佣金固定金额为 15000(150元)
|
||||
- **AND** 平台尝试为一级代理分配,one_time_commission_amount = 20000(200元)
|
||||
- **THEN** 系统返回错误 "一次性佣金金额不能超过系列配置上限"
|
||||
|
||||
#### Scenario: 平台配置强充要求
|
||||
- **WHEN** 平台为一级代理分配系列,启用强充,force_recharge_amount = 10000
|
||||
- **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐分配依赖系列分配
|
||||
|
||||
系统 SHALL 要求在分配套餐给下级店铺之前,必须先分配对应的套餐系列。
|
||||
|
||||
#### Scenario: 先分配系列再分配套餐
|
||||
- **WHEN** 代理尝试为下级分配套餐 A(属于系列 X)
|
||||
- **AND** 下级店铺已被分配系列 X
|
||||
- **THEN** 系统允许创建套餐分配,并关联到系列分配记录
|
||||
|
||||
#### Scenario: 未分配系列时分配套餐失败
|
||||
- **WHEN** 代理尝试为下级分配套餐 A(属于系列 X)
|
||||
- **AND** 下级店铺未被分配系列 X
|
||||
- **THEN** 系统返回错误 "请先分配该套餐所属的系列"
|
||||
|
||||
#### Scenario: 删除系列分配时检查套餐分配
|
||||
- **WHEN** 代理尝试删除系列分配
|
||||
- **AND** 存在依赖该系列分配的套餐分配
|
||||
- **THEN** 系统返回错误 "存在关联的套餐分配,无法删除"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐分配精简
|
||||
|
||||
套餐分配(ShopPackageAllocation)SHALL 只管理成本价,一次性佣金配置移到系列分配。
|
||||
|
||||
#### Scenario: 套餐分配只包含成本价
|
||||
- **WHEN** 创建套餐分配
|
||||
- **THEN** 请求/响应只包含 cost_price 字段
|
||||
- **AND** 不包含 one_time_commission_amount 字段
|
||||
|
||||
#### Scenario: 套餐分配关联系列分配
|
||||
- **WHEN** 创建套餐分配
|
||||
- **THEN** 系统自动关联对应的系列分配(通过 series_allocation_id)
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 梯度返佣配置
|
||||
|
||||
**Reason**: 已在之前版本移除,此处确认删除状态
|
||||
|
||||
**Migration**: 使用一次性佣金的梯度模式替代
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 基础返佣配置
|
||||
|
||||
**Reason**: commission_mode 和 commission_value 字段被 one_time_commission_amount 替代
|
||||
|
||||
**Migration**:
|
||||
- 旧字段 commission_mode(百分比/固定值)和 commission_value 移除
|
||||
- 新字段 one_time_commission_amount(分)表示代理能拿的一次性佣金上限
|
||||
- 成本价差收入的计算不变,仍从套餐分配的 cost_price 计算
|
||||
@@ -0,0 +1,174 @@
|
||||
# Tasks: refactor-one-time-commission-allocation
|
||||
|
||||
## 0. 测试准备(实现前执行)
|
||||
|
||||
- [x] 0.1 生成验收测试和流程测试
|
||||
- 运行 `/opsx:gen-tests refactor-one-time-commission-allocation`
|
||||
- 确认生成文件:`tests/acceptance/shop_series_allocation_acceptance_test.go`
|
||||
- 确认生成文件:`tests/acceptance/commission_calculation_acceptance_test.go`
|
||||
|
||||
- [x] 0.2 运行测试确认全部 FAIL
|
||||
- `source .env.local && go test -v ./tests/acceptance/... -run ShopSeriesAllocation`
|
||||
- `source .env.local && go test -v ./tests/acceptance/... -run CommissionCalculation`
|
||||
- 预期:全部 FAIL(功能未实现,证明测试有效)
|
||||
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 创建迁移:新增 `tb_shop_series_allocation` 表
|
||||
- 字段:id, created_at, updated_at, deleted_at, creator, updater
|
||||
- 字段:shop_id, series_id, allocator_shop_id, one_time_commission_amount, status
|
||||
- 字段:enable_one_time_commission, one_time_commission_trigger, one_time_commission_threshold
|
||||
- 字段:enable_force_recharge, force_recharge_amount, force_recharge_trigger_type
|
||||
- 唯一索引:(shop_id, series_id)
|
||||
|
||||
- [x] 1.2 创建迁移:修改 `tb_package_series` 表
|
||||
- 新增字段:enable_one_time_commission (bool, 默认 false)
|
||||
|
||||
- [x] 1.3 创建迁移:修改 `tb_shop_package_allocation` 表
|
||||
- 新增字段:series_allocation_id (uint)
|
||||
- 删除字段:one_time_commission_amount, series_id
|
||||
|
||||
- [x] 1.4 创建迁移:删除 `tb_shop_series_one_time_commission_tier` 表
|
||||
|
||||
- [x] 1.5 执行迁移并验证
|
||||
- `source .env.local && ./scripts/migrate.sh up`
|
||||
- 验证:表结构正确
|
||||
|
||||
## 2. Model 层
|
||||
|
||||
- [x] 2.1 创建 `internal/model/shop_series_allocation.go`
|
||||
- ShopSeriesAllocation 结构体 + TableName()
|
||||
|
||||
- [x] 2.2 创建 `internal/model/dto/shop_series_allocation.go`
|
||||
- Create/Update Request, Response, ListRequest
|
||||
|
||||
- [x] 2.3 修改 `internal/model/package.go`
|
||||
- PackageSeries 添加 EnableOneTimeCommission 字段
|
||||
|
||||
- [x] 2.4 修改 `internal/model/shop_package_allocation.go`
|
||||
- 删除 OneTimeCommissionAmount, SeriesID
|
||||
- 添加 SeriesAllocationID
|
||||
|
||||
- [x] 2.5 修改 `internal/model/dto/shop_package_allocation.go`
|
||||
- 删除 one_time_commission_amount 字段
|
||||
|
||||
- [x] 2.6 验证 Model 层
|
||||
- `lsp_diagnostics` 检查所有修改的文件
|
||||
- `go build ./internal/model/...`
|
||||
|
||||
## 3. 系列分配功能(完整功能单元)
|
||||
|
||||
- [x] 3.1 创建 `internal/store/postgres/shop_series_allocation_store.go`
|
||||
- Create, Update, Delete, GetByID
|
||||
- GetByShopAndSeries(shopID, seriesID)
|
||||
- List(支持筛选)
|
||||
- CountBySeriesID
|
||||
|
||||
- [x] 3.2 创建 `internal/service/shop_series_allocation/service.go`
|
||||
- Create: 验证上级分配、金额上限
|
||||
- Update: 验证金额上限
|
||||
- Delete: 检查套餐分配依赖
|
||||
- Get, List
|
||||
|
||||
- [x] 3.3 创建 `internal/handler/admin/shop_series_allocation.go`
|
||||
- POST /api/admin/shop-series-allocations
|
||||
- GET /api/admin/shop-series-allocations
|
||||
- GET /api/admin/shop-series-allocations/:id
|
||||
- PUT /api/admin/shop-series-allocations/:id
|
||||
- DELETE /api/admin/shop-series-allocations/:id
|
||||
|
||||
- [x] 3.4 添加路由和 Bootstrap
|
||||
- 路由注册
|
||||
- stores.go 添加 ShopSeriesAllocationStore
|
||||
- services.go 添加 ShopSeriesAllocationService
|
||||
- handlers.go 添加 ShopSeriesAllocationHandler
|
||||
|
||||
- [x] 3.5 更新文档生成器
|
||||
- pkg/openapi/handlers.go 添加 Handler
|
||||
|
||||
- [x] 3.6 **验证:系列分配验收测试 PASS**
|
||||
- `source .env.local && go test -v ./tests/acceptance/... -run ShopSeriesAllocation`
|
||||
- ✅ 所有系列分配 CRUD 相关测试全部 PASS
|
||||
|
||||
## 4. 套餐分配改造
|
||||
|
||||
- [x] 4.1 修改 `internal/store/postgres/shop_package_allocation_store.go`
|
||||
- 删除 GetByShopAndSeries 方法
|
||||
- 添加 CountBySeriesAllocationID 方法
|
||||
- 更新 Create/Update 适配新字段
|
||||
|
||||
- [x] 4.2 修改 `internal/service/shop_package_allocation/service.go`
|
||||
- Create: 添加系列分配依赖检查
|
||||
- Create: 自动关联 series_allocation_id
|
||||
- 移除 one_time_commission_amount 逻辑
|
||||
|
||||
- [x] 4.3 修改 `internal/handler/admin/shop_package_allocation.go`
|
||||
- 移除请求/响应中的 one_time_commission_amount
|
||||
|
||||
- [x] 4.4 **验证:套餐分配依赖检查测试 PASS**
|
||||
- `source .env.local && go test -v ./tests/acceptance/... -run ShopPackageAllocation_SeriesDependency`
|
||||
- ✅ 测试通过
|
||||
|
||||
## 5. 佣金计算改造
|
||||
|
||||
- [x] 5.1 修改 `internal/service/commission_calculation/service.go`
|
||||
- 一次性佣金从 ShopSeriesAllocation 获取
|
||||
- 实现链式分配计算
|
||||
- 梯度模式:min(梯度匹配金额, 分配上限)
|
||||
|
||||
- [x] 5.2 修改 `internal/service/recharge/service.go`
|
||||
- 充值触发一次性佣金从系列分配获取配置
|
||||
|
||||
- [x] 5.3 修改佣金统计相关代码
|
||||
- CommissionStats.allocation_id 关联到系列分配
|
||||
|
||||
- [x] 5.4 **验证:佣金计算验收测试 PASS**
|
||||
- `source .env.local && go test -v ./tests/acceptance/... -run CommissionCalculation`
|
||||
- ✅ 所有佣金计算测试通过
|
||||
|
||||
## 6. 清理废弃代码
|
||||
|
||||
- [x] 6.1 删除 ShopSeriesOneTimeCommissionTier 相关代码
|
||||
- 删除 model 文件
|
||||
- 删除 store 文件
|
||||
- 删除 bootstrap 中的引用
|
||||
|
||||
- [x] 6.2 全局搜索并清理遗留引用
|
||||
- 搜索 "one_time_commission_amount" 在 ShopPackageAllocation 的使用
|
||||
- 搜索 "GetByShopAndSeries" 的调用
|
||||
- 更新所有引用点
|
||||
|
||||
- [x] 6.3 验证清理完成
|
||||
- `go build ./...` 编译通过
|
||||
|
||||
## 7. 常量定义
|
||||
|
||||
- [x] 7.1 更新 `pkg/constants/redis.go`
|
||||
- 添加 RedisShopSeriesAllocationKey 函数
|
||||
|
||||
- [x] 7.2 更新 `pkg/constants/constants.go`
|
||||
- 使用已有的通用常量 StatusEnabled/StatusDisabled (值 1/0)
|
||||
|
||||
## 8. 最终验证
|
||||
|
||||
- [x] 8.1 运行所有验收测试
|
||||
- `source .env.local && go test -v ./tests/acceptance/...`
|
||||
- ✅ 全部 PASS
|
||||
|
||||
- [x] 8.2 运行流程测试
|
||||
- `source .env.local && go test -v ./tests/flows/...`
|
||||
- ✅ 全部 PASS
|
||||
|
||||
- [x] 8.3 运行完整测试套件
|
||||
- `source .env.local && go test -v ./...`
|
||||
- ✅ 验收测试和流程测试全部 PASS
|
||||
- ⚠️ 预先存在的失败(与本次重构无关):gateway/account/store 等测试
|
||||
|
||||
- [x] 8.4 编译和启动验证
|
||||
- `go build ./...` ✅ 编译通过
|
||||
- `source .env.local && go run cmd/api/main.go`
|
||||
- 验证:服务正常启动
|
||||
|
||||
- [x] 8.5 重新生成 OpenAPI 文档
|
||||
- `go run cmd/gendocs/main.go` ✅
|
||||
- 验证:文档包含新接口 ✅ `/api/admin/shop-series-allocations`
|
||||
@@ -146,3 +146,14 @@ func RedisCommissionStatsKey(allocationID uint, period string) string {
|
||||
func RedisCommissionStatsLockKey() string {
|
||||
return "commission:stats:sync:lock"
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 系列分配相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisShopSeriesAllocationKey 生成店铺系列分配缓存的 Redis 键
|
||||
// 用途:缓存店铺+系列的分配配置
|
||||
// 过期时间:30 分钟
|
||||
func RedisShopSeriesAllocationKey(shopID, seriesID uint) string {
|
||||
return fmt.Sprintf("shop_series_alloc:%d:%d", shopID, seriesID)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (h *Handler) registerDeviceImportHandler() {
|
||||
|
||||
func (h *Handler) registerCommissionStatsHandlers() {
|
||||
statsStore := postgres.NewShopSeriesCommissionStatsStore(h.db)
|
||||
allocationStore := postgres.NewShopSeriesAllocationStore(h.db)
|
||||
allocationStore := postgres.NewShopPackageAllocationStore(h.db)
|
||||
|
||||
updateHandler := task.NewCommissionStatsUpdateHandler(h.redis, statsStore, allocationStore, h.logger)
|
||||
syncHandler := task.NewCommissionStatsSyncHandler(h.db, h.redis, statsStore, h.logger)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package utils
|
||||
|
||||
import "github.com/break/junhong_cmp_fiber/internal/model"
|
||||
|
||||
func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
||||
if allocation.BaseCommissionMode == model.CommissionModeFixed {
|
||||
return orderAmount - allocation.BaseCommissionValue
|
||||
} else if allocation.BaseCommissionMode == model.CommissionModePercent {
|
||||
commission := orderAmount * allocation.BaseCommissionValue / 1000
|
||||
return orderAmount - commission
|
||||
}
|
||||
return orderAmount
|
||||
}
|
||||
322
tests/acceptance/README.md
Normal file
322
tests/acceptance/README.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# 验收测试 (Acceptance Tests)
|
||||
|
||||
验收测试验证单个 API 的契约:给定输入,期望输出。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **来源于 Spec**:每个测试用例对应 Spec 中的一个 Scenario
|
||||
2. **测试先于实现**:在功能实现前生成,预期全部 FAIL
|
||||
3. **契约验证**:验证 API 的输入输出契约,不测试内部实现
|
||||
4. **必须有破坏点**:每个测试必须注释说明什么代码变更会导致失败
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/acceptance/
|
||||
├── README.md # 本文件
|
||||
├── account_acceptance_test.go # 账号管理验收测试
|
||||
├── package_acceptance_test.go # 套餐管理验收测试
|
||||
├── shop_package_acceptance_test.go # 店铺套餐分配验收测试
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 测试模板
|
||||
|
||||
```go
|
||||
package acceptance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 验收测试:{功能名称}
|
||||
// 来源:openspec/changes/{change-name}/specs/{capability}/spec.md
|
||||
// ============================================================
|
||||
|
||||
func Test{Capability}_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: {场景名称}
|
||||
// GIVEN: {前置条件}
|
||||
// WHEN: {触发动作}
|
||||
// THEN: {预期结果}
|
||||
// AND: {额外验证}
|
||||
//
|
||||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_{场景名称}", func(t *testing.T) {
|
||||
// GIVEN: 设置前置条件
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: 执行操作
|
||||
body := map[string]interface{}{
|
||||
// 请求体
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/xxx", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 验证结果
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, int(result["code"].(float64)))
|
||||
|
||||
// AND: 额外验证(如数据库状态)
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 测试分类
|
||||
|
||||
### 正常场景 (Happy Path)
|
||||
|
||||
```go
|
||||
t.Run("Scenario_成功创建资源", func(t *testing.T) {
|
||||
// 测试正常流程
|
||||
})
|
||||
```
|
||||
|
||||
### 参数校验
|
||||
|
||||
```go
|
||||
t.Run("Scenario_参数缺失返回400", func(t *testing.T) {
|
||||
// 测试缺少必填参数
|
||||
})
|
||||
|
||||
t.Run("Scenario_参数格式错误返回400", func(t *testing.T) {
|
||||
// 测试参数格式不符合要求
|
||||
})
|
||||
```
|
||||
|
||||
### 权限校验
|
||||
|
||||
```go
|
||||
t.Run("Scenario_无权限返回403", func(t *testing.T) {
|
||||
// 测试权限不足的情况
|
||||
})
|
||||
|
||||
t.Run("Scenario_跨店铺访问返回403", func(t *testing.T) {
|
||||
// 测试越权访问
|
||||
})
|
||||
```
|
||||
|
||||
### 业务规则
|
||||
|
||||
```go
|
||||
t.Run("Scenario_重复创建返回409", func(t *testing.T) {
|
||||
// 测试业务规则冲突
|
||||
})
|
||||
|
||||
t.Run("Scenario_删除已使用资源返回400", func(t *testing.T) {
|
||||
// 测试业务规则限制
|
||||
})
|
||||
```
|
||||
|
||||
## 破坏点注释规范
|
||||
|
||||
每个测试必须包含"破坏点"注释,说明什么代码变更会导致测试失败:
|
||||
|
||||
```go
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
|
||||
// 破坏点:如果响应不包含创建的资源 ID,此测试将失败
|
||||
// 破坏点:如果删除权限检查中间件,此测试将失败
|
||||
```
|
||||
|
||||
**为什么需要破坏点**:
|
||||
1. 证明测试真正验证了功能
|
||||
2. 帮助理解测试意图
|
||||
3. 重构时快速定位影响
|
||||
|
||||
## Table-Driven 模式
|
||||
|
||||
对于同一 API 的多个场景,使用 table-driven 模式:
|
||||
|
||||
```go
|
||||
func TestPackage_Create_Validation(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]interface{}
|
||||
expectedStatus int
|
||||
expectedCode int
|
||||
breakpoint string
|
||||
}{
|
||||
{
|
||||
name: "名称为空",
|
||||
body: map[string]interface{}{
|
||||
"name": "",
|
||||
"price": 9900,
|
||||
},
|
||||
expectedStatus: 400,
|
||||
expectedCode: 4000, // CodeInvalidParam
|
||||
breakpoint: "移除 name 必填校验",
|
||||
},
|
||||
{
|
||||
name: "价格为负",
|
||||
body: map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"price": -100,
|
||||
},
|
||||
expectedStatus: 400,
|
||||
expectedCode: 4000,
|
||||
breakpoint: "移除 price >= 0 校验",
|
||||
},
|
||||
{
|
||||
name: "时长为0",
|
||||
body: map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"price": 9900,
|
||||
"duration": 0,
|
||||
},
|
||||
expectedStatus: 400,
|
||||
expectedCode: 4000,
|
||||
breakpoint: "移除 duration > 0 校验",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("Scenario_"+tt.name, func(t *testing.T) {
|
||||
// 破坏点: {tt.breakpoint}
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("POST", "/api/admin/packages", tt.body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedCode, int(result["code"].(float64)))
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有验收测试
|
||||
source .env.local && go test -v ./tests/acceptance/...
|
||||
|
||||
# 运行特定功能的验收测试
|
||||
source .env.local && go test -v ./tests/acceptance/... -run TestPackage
|
||||
|
||||
# 运行特定场景
|
||||
source .env.local && go test -v ./tests/acceptance/... -run "Scenario_成功创建"
|
||||
```
|
||||
|
||||
## 测试环境
|
||||
|
||||
验收测试使用 `IntegrationTestEnv`,提供:
|
||||
|
||||
- **事务隔离**:每个测试在独立事务中运行,自动回滚
|
||||
- **Redis 清理**:测试前自动清理相关 Redis 键
|
||||
- **身份切换**:支持不同角色的请求
|
||||
|
||||
```go
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 以超级管理员身份请求
|
||||
env.AsSuperAdmin().Request("GET", "/api/admin/xxx", nil)
|
||||
|
||||
// 以平台用户身份请求
|
||||
env.AsPlatformUser(accountID).Request("GET", "/api/admin/xxx", nil)
|
||||
|
||||
// 以代理商身份请求
|
||||
env.AsShopAgent(shopID).Request("GET", "/api/admin/xxx", nil)
|
||||
|
||||
// 以企业用户身份请求
|
||||
env.AsEnterprise(enterpriseID).Request("GET", "/api/admin/xxx", nil)
|
||||
```
|
||||
|
||||
## 与 Spec 的对应关系
|
||||
|
||||
```markdown
|
||||
# Spec 中的 Scenario
|
||||
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 201 和套餐详情
|
||||
- **AND** 数据库中存在该套餐记录
|
||||
```
|
||||
|
||||
对应测试:
|
||||
|
||||
```go
|
||||
// 直接从 Spec Scenario 转换
|
||||
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
resp, err := client.Request("POST", "/api/admin/packages", validBody)
|
||||
|
||||
// THEN: 返回 201 和套餐详情
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
// AND: 数据库中存在该套餐记录
|
||||
// 验证数据库状态
|
||||
})
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 验收测试和集成测试的区别?
|
||||
|
||||
| 方面 | 验收测试 | 集成测试 |
|
||||
|------|---------|---------|
|
||||
| 来源 | Spec Scenario | 开发者编写 |
|
||||
| 目的 | 验证 API 契约 | 验证系统集成 |
|
||||
| 粒度 | 单 API | 可能涉及多 API |
|
||||
| 时机 | 实现前生成 | 实现后编写 |
|
||||
|
||||
### Q: 测试 PASS 了但功能还没实现?
|
||||
|
||||
说明测试写得太弱。检查:
|
||||
1. 是否验证了响应状态码
|
||||
2. 是否验证了响应体结构
|
||||
3. 是否验证了数据库状态变化
|
||||
4. 破坏点是否准确
|
||||
|
||||
### Q: 如何处理需要前置数据的测试?
|
||||
|
||||
在 GIVEN 阶段创建必要的前置数据:
|
||||
|
||||
```go
|
||||
t.Run("Scenario_删除已分配的套餐失败", func(t *testing.T) {
|
||||
// GIVEN: 存在一个已分配给店铺的套餐
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// 创建套餐
|
||||
createResp, _ := client.Request("POST", "/api/admin/packages", packageBody)
|
||||
var createResult map[string]interface{}
|
||||
createResp.JSON(&createResult)
|
||||
packageID := uint(createResult["data"].(map[string]interface{})["id"].(float64))
|
||||
|
||||
// 分配给店铺
|
||||
client.Request("POST", "/api/admin/shop-packages", map[string]interface{}{
|
||||
"package_id": packageID,
|
||||
"shop_id": 1,
|
||||
})
|
||||
|
||||
// WHEN: 尝试删除套餐
|
||||
resp, _ := client.Request("DELETE", fmt.Sprintf("/api/admin/packages/%d", packageID), nil)
|
||||
|
||||
// THEN: 返回 400
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
```
|
||||
444
tests/acceptance/commission_calculation_acceptance_test.go
Normal file
444
tests/acceptance/commission_calculation_acceptance_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package acceptance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 验收测试:佣金计算重构
|
||||
// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/commission-calculation/spec.md
|
||||
// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/commission-trigger/spec.md
|
||||
// ============================================================
|
||||
|
||||
func TestCommissionCalculation_SeriesAllocationQuery_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createCommissionTestSeries(t, env, "佣金测试系列")
|
||||
|
||||
createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000)
|
||||
createSeriesAllocationForCommission(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 直接查询系列分配
|
||||
// GIVEN: 存在 shop_id + series_id 的系列分配记录
|
||||
// WHEN: 通过 shop_id 和 series_id 查询
|
||||
// THEN: 返回唯一匹配的记录,包含 one_time_commission_amount
|
||||
//
|
||||
// 破坏点:如果查询 API 不支持 series_id 筛选,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_直接查询系列分配", func(t *testing.T) {
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d",
|
||||
childShop.ID, series.ID)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
require.Len(t, items, 1, "应返回唯一匹配记录")
|
||||
|
||||
allocation := items[0].(map[string]interface{})
|
||||
assert.Equal(t, float64(5000), allocation["one_time_commission_amount"],
|
||||
"佣金金额应为 5000 分")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 系列分配不存在
|
||||
// GIVEN: shop_id + series_id 组合不存在分配记录
|
||||
// WHEN: 查询该组合
|
||||
// THEN: 返回空列表
|
||||
//
|
||||
// 破坏点:如果查询不正确处理空结果,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_系列分配不存在", func(t *testing.T) {
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=99999",
|
||||
childShop.ID)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
list := data["items"].([]interface{})
|
||||
assert.Empty(t, list, "不存在的组合应返回空列表")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommissionCalculation_EnableOneTimeCommission_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
series := createCommissionTestSeriesWithConfig(t, env, "启用佣金系列", true)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 检查系列是否启用一次性佣金
|
||||
// GIVEN: 系列配置 enable_one_time_commission = true
|
||||
// WHEN: 查询系列详情
|
||||
// THEN: 响应包含 enable_one_time_commission = true
|
||||
//
|
||||
// 破坏点:如果系列 API 不返回 enable_one_time_commission 字段,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_检查系列是否启用一次性佣金", func(t *testing.T) {
|
||||
path := fmt.Sprintf("/api/admin/package-series/%d", series.ID)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
enableOneTime, ok := data["enable_one_time_commission"]
|
||||
assert.True(t, ok, "响应应包含 enable_one_time_commission 字段")
|
||||
assert.Equal(t, true, enableOneTime, "应为 true")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 批量查询启用一次性佣金的系列
|
||||
// GIVEN: 存在多个系列,部分启用一次性佣金
|
||||
// WHEN: 查询系列列表并按 enable_one_time_commission 筛选
|
||||
// THEN: 返回符合条件的系列
|
||||
//
|
||||
// 破坏点:如果不支持 enable_one_time_commission 筛选,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_批量查询启用一次性佣金的系列", func(t *testing.T) {
|
||||
createCommissionTestSeriesWithConfig(t, env, "禁用佣金系列", false)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("GET", "/api/admin/package-series?enable_one_time_commission=true", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
list := data["items"].([]interface{})
|
||||
|
||||
for _, item := range list {
|
||||
seriesItem := item.(map[string]interface{})
|
||||
enableVal, hasField := seriesItem["enable_one_time_commission"]
|
||||
if hasField {
|
||||
assert.Equal(t, true, enableVal, "筛选结果应全部为启用状态")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommissionCalculation_ChainAllocation_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
level1Shop := env.CreateTestShop("一级代理", 1, nil)
|
||||
level2Shop := env.CreateTestShop("二级代理", 2, &level1Shop.ID)
|
||||
level3Shop := env.CreateTestShop("三级代理", 3, &level2Shop.ID)
|
||||
series := createCommissionTestSeries(t, env, "链式分配系列")
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 链式分配金额计算
|
||||
// GIVEN:
|
||||
// - 平台给一级:one_time_commission_amount = 10000(100元)
|
||||
// - 一级给二级:one_time_commission_amount = 8000(80元)
|
||||
// - 二级给三级:one_time_commission_amount = 5000(50元)
|
||||
// WHEN: 查询各级的系列分配
|
||||
// THEN: 各级金额正确
|
||||
//
|
||||
// 破坏点:如果分配金额不正确保存,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_链式分配金额计算", func(t *testing.T) {
|
||||
createPlatformSeriesAllocationForCommission(t, env, level1Shop.ID, series.ID, 10000)
|
||||
createSeriesAllocationForCommission(t, env, level1Shop.ID, level2Shop.ID, series.ID, 8000)
|
||||
createSeriesAllocationForCommission(t, env, level2Shop.ID, level3Shop.ID, series.ID, 5000)
|
||||
|
||||
verifyAllocationAmount(t, env, level1Shop.ID, series.ID, 10000)
|
||||
verifyAllocationAmount(t, env, level2Shop.ID, series.ID, 8000)
|
||||
verifyAllocationAmount(t, env, level3Shop.ID, series.ID, 5000)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 单级代理
|
||||
// GIVEN: 一级代理直接销售(无下级)
|
||||
// WHEN: 查询一级的系列分配
|
||||
// THEN: 一级获得完整的 one_time_commission_amount
|
||||
//
|
||||
// 破坏点:如果单级分配不生效,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_单级代理", func(t *testing.T) {
|
||||
singleShop := env.CreateTestShop("单级代理", 1, nil)
|
||||
singleSeries := createCommissionTestSeries(t, env, "单级系列")
|
||||
|
||||
createPlatformSeriesAllocationForCommission(t, env, singleShop.ID, singleSeries.ID, 10000)
|
||||
verifyAllocationAmount(t, env, singleShop.ID, singleSeries.ID, 10000)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommissionCalculation_TriggerConfig_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createCommissionTestSeries(t, env, "触发配置系列")
|
||||
|
||||
createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 累计达到阈值触发佣金配置
|
||||
// GIVEN: 系列分配设置为累计充值触发,阈值 1000 元
|
||||
// WHEN: 创建系列分配
|
||||
// THEN: 配置正确保存
|
||||
//
|
||||
// 破坏点:如果触发配置不保存,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_累计达到阈值触发佣金配置", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 5000,
|
||||
"enable_one_time_commission": true,
|
||||
"one_time_commission_trigger": "accumulated_recharge",
|
||||
"one_time_commission_threshold": 100000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, true, data["enable_one_time_commission"])
|
||||
assert.Equal(t, "accumulated_recharge", data["one_time_commission_trigger"])
|
||||
assert.Equal(t, float64(100000), data["one_time_commission_threshold"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 首次充值触发配置
|
||||
// GIVEN: 系列分配设置为首次充值触发,阈值 100 元
|
||||
// WHEN: 创建系列分配
|
||||
// THEN: 配置正确保存
|
||||
//
|
||||
// 破坏点:如果 first_recharge 触发类型不支持,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_首次充值触发配置", func(t *testing.T) {
|
||||
newChildShop := env.CreateTestShop("首充测试店铺", 2, &parentShop.ID)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": newChildShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 5000,
|
||||
"enable_one_time_commission": true,
|
||||
"one_time_commission_trigger": "first_recharge",
|
||||
"one_time_commission_threshold": 10000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, "first_recharge", data["one_time_commission_trigger"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommissionStats_Allocation_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
series := createCommissionTestSeries(t, env, "统计测试系列")
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 创建佣金统计记录关联系列分配
|
||||
// GIVEN: 存在系列分配记录
|
||||
// WHEN: 查询佣金统计
|
||||
// THEN: 统计记录的 allocation_id 指向 ShopSeriesAllocation.id
|
||||
//
|
||||
// 破坏点:如果统计不关联系列分配,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_佣金统计关联系列分配", func(t *testing.T) {
|
||||
allocation := createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000)
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-commission-stats?shop_id=%d&series_id=%d",
|
||||
parentShop.ID, series.ID)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
if result.Code == 0 && result.Data != nil {
|
||||
data := result.Data.(map[string]interface{})
|
||||
if list, ok := data["items"].([]interface{}); ok && len(list) > 0 {
|
||||
stats := list[0].(map[string]interface{})
|
||||
if allocationID, exists := stats["allocation_id"]; exists {
|
||||
assert.Equal(t, float64(allocation.ID), allocationID,
|
||||
"统计应关联到系列分配 ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
func createCommissionTestSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("COMM_SERIES_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err, "创建测试系列失败")
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createCommissionTestSeriesWithConfig(t *testing.T, env *integ.IntegrationTestEnv, name string, enableOneTime bool) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("COMM_SERIES_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
EnableOneTimeCommission: enableOneTime,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err, "创建测试系列失败")
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createPlatformSeriesAllocationForCommission(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shopID,
|
||||
SeriesID: seriesID,
|
||||
AllocatorShopID: 0,
|
||||
OneTimeCommissionAmount: amount,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建平台系列分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
func createSeriesAllocationForCommission(t *testing.T, env *integ.IntegrationTestEnv, allocatorShopID, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shopID,
|
||||
SeriesID: seriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
OneTimeCommissionAmount: amount,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建系列分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
func verifyAllocationAmount(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, expectedAmount int64) {
|
||||
t.Helper()
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d", shopID, seriesID)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
list := data["items"].([]interface{})
|
||||
require.NotEmpty(t, list, "应存在分配记录")
|
||||
|
||||
allocation := list[0].(map[string]interface{})
|
||||
assert.Equal(t, float64(expectedAmount), allocation["one_time_commission_amount"],
|
||||
"店铺 %d 系列 %d 的佣金金额应为 %d", shopID, seriesID, expectedAmount)
|
||||
}
|
||||
847
tests/acceptance/shop_series_allocation_acceptance_test.go
Normal file
847
tests/acceptance/shop_series_allocation_acceptance_test.go
Normal file
@@ -0,0 +1,847 @@
|
||||
package acceptance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 验收测试:套餐系列分配 (ShopSeriesAllocation)
|
||||
// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md
|
||||
// ============================================================
|
||||
|
||||
func TestShopSeriesAllocation_Create_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
// 准备测试数据:创建店铺层级和套餐系列
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createTestPackageSeries(t, env, "测试系列")
|
||||
|
||||
// 先为一级代理创建系列分配(平台分配)
|
||||
platformAllocation := createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 成功分配套餐系列
|
||||
// GIVEN: 代理已有该系列的分配权限
|
||||
// WHEN: POST /api/admin/shop-series-allocations 设置 one_time_commission_amount = 5000
|
||||
// THEN: 返回 200 和分配记录详情
|
||||
// AND: 下级代理的一次性佣金上限为 50 元
|
||||
//
|
||||
// 破坏点:如果 Handler 不调用 Service.Create,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_成功分配套餐系列", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 5000, // 50 元
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
|
||||
|
||||
// 验证响应包含 one_time_commission_amount
|
||||
data, ok := result.Data.(map[string]interface{})
|
||||
require.True(t, ok, "响应 data 应为对象")
|
||||
assert.Equal(t, float64(5000), data["one_time_commission_amount"], "一次性佣金金额应为 5000 分")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 下级金额不能超过上级
|
||||
// GIVEN: 上级分配金额为 10000 分(100 元)
|
||||
// WHEN: 尝试为下级分配 15000 分(150 元)
|
||||
// THEN: 返回 400 错误 "一次性佣金金额不能超过您的分配上限"
|
||||
//
|
||||
// 破坏点:如果移除金额上限校验,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_下级金额不能超过上级", func(t *testing.T) {
|
||||
newChildShop := env.CreateTestShop("新下级店铺", 2, &parentShop.ID)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": newChildShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 15000, // 超过上级的 10000
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.Code, "应返回错误")
|
||||
assert.Contains(t, result.Message, "超过", "错误消息应包含'超过'")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 分配时启用一次性佣金和强充
|
||||
// GIVEN: 代理有分配权限
|
||||
// WHEN: POST 创建分配,启用一次性佣金(累计充值触发,阈值 1000 元),启用强充(100 元)
|
||||
// THEN: 系统保存完整配置
|
||||
//
|
||||
// 破坏点:如果不保存 enable_one_time_commission 等字段,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_分配时启用一次性佣金和强充", func(t *testing.T) {
|
||||
newChildShop := env.CreateTestShop("新下级店铺2", 2, &parentShop.ID)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": newChildShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 5000,
|
||||
"enable_one_time_commission": true,
|
||||
"one_time_commission_trigger": "accumulated_recharge",
|
||||
"one_time_commission_threshold": 100000, // 1000 元
|
||||
"enable_force_recharge": true,
|
||||
"force_recharge_amount": 10000, // 100 元
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, true, data["enable_one_time_commission"])
|
||||
assert.Equal(t, "accumulated_recharge", data["one_time_commission_trigger"])
|
||||
assert.Equal(t, float64(100000), data["one_time_commission_threshold"])
|
||||
assert.Equal(t, true, data["enable_force_recharge"])
|
||||
assert.Equal(t, float64(10000), data["force_recharge_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 尝试分配未拥有的系列
|
||||
// GIVEN: 代理没有某系列的分配权限
|
||||
// WHEN: 尝试为下级分配该系列
|
||||
// THEN: 返回 403/400 "您没有该套餐系列的分配权限"
|
||||
//
|
||||
// 破坏点:如果不检查代理是否拥有系列权限,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_尝试分配未拥有的系列", func(t *testing.T) {
|
||||
unownedSeries := createTestPackageSeries(t, env, "未分配系列")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"series_id": unownedSeries.ID,
|
||||
"one_time_commission_amount": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 应返回 400 或 403
|
||||
assert.True(t, resp.StatusCode == 400 || resp.StatusCode == 403,
|
||||
"应返回 400 或 403,实际: %d", resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.Code)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 尝试分配给非直属下级
|
||||
// GIVEN: 店铺 A 是一级,店铺 B 是二级(A 的下级),店铺 C 是三级(B 的下级)
|
||||
// WHEN: 店铺 A 尝试直接分配给店铺 C
|
||||
// THEN: 返回 403 "只能为直属下级分配套餐"
|
||||
//
|
||||
// 破坏点:如果不检查是否为直属下级,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_尝试分配给非直属下级", func(t *testing.T) {
|
||||
grandChildShop := env.CreateTestShop("三级代理", 3, &childShop.ID)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": grandChildShop.ID, // 非直属下级
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == 400 || resp.StatusCode == 403)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.Code)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 重复分配同一系列
|
||||
// GIVEN: 已为下级店铺分配了某系列
|
||||
// WHEN: 再次尝试分配同一系列
|
||||
// THEN: 返回 409 "该店铺已分配此套餐系列"
|
||||
//
|
||||
// 破坏点:如果不检查唯一索引,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_重复分配同一系列", func(t *testing.T) {
|
||||
newChildShop := env.CreateTestShop("重复测试店铺", 2, &parentShop.ID)
|
||||
|
||||
// 第一次分配
|
||||
body := map[string]interface{}{
|
||||
"shop_id": newChildShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// 第二次分配(应失败)
|
||||
resp2, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
assert.True(t, resp2.StatusCode == 400 || resp2.StatusCode == 409,
|
||||
"重复分配应返回 400 或 409,实际: %d", resp2.StatusCode)
|
||||
})
|
||||
|
||||
_ = platformAllocation // 使用变量避免编译警告
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocation_List_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createTestPackageSeries(t, env, "列表测试系列")
|
||||
|
||||
// 创建分配记录
|
||||
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 查询所有分配
|
||||
// GIVEN: 存在多条分配记录
|
||||
// WHEN: GET /api/admin/shop-series-allocations 不带筛选条件
|
||||
// THEN: 返回该代理创建的所有分配记录
|
||||
// AND: 每条记录包含 one_time_commission_amount 字段
|
||||
//
|
||||
// 破坏点:如果 List API 不返回数据,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_查询所有分配", func(t *testing.T) {
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("GET", "/api/admin/shop-series-allocations", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
// 验证返回列表格式
|
||||
data := result.Data.(map[string]interface{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
require.True(t, ok, "响应应包含 items 字段")
|
||||
require.NotEmpty(t, items, "列表不应为空")
|
||||
|
||||
// 验证第一条记录包含 one_time_commission_amount
|
||||
firstItem := items[0].(map[string]interface{})
|
||||
_, hasAmount := firstItem["one_time_commission_amount"]
|
||||
assert.True(t, hasAmount, "记录应包含 one_time_commission_amount 字段")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 按店铺筛选
|
||||
// GIVEN: 存在多个店铺的分配记录
|
||||
// WHEN: GET /api/admin/shop-series-allocations?shop_id=xxx
|
||||
// THEN: 只返回该店铺的分配记录
|
||||
//
|
||||
// 破坏点:如果不支持 shop_id 筛选,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_按店铺筛选", func(t *testing.T) {
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d", childShop.ID)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
// 验证筛选结果
|
||||
data := result.Data.(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
record := item.(map[string]interface{})
|
||||
assert.Equal(t, float64(childShop.ID), record["shop_id"],
|
||||
"筛选结果应只包含指定店铺")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocation_Update_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createTestPackageSeries(t, env, "更新测试系列")
|
||||
|
||||
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
allocation := createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 更新一次性佣金金额
|
||||
// GIVEN: 存在一条分配记录,金额为 5000
|
||||
// WHEN: PUT /api/admin/shop-series-allocations/:id 将金额改为 6000
|
||||
// THEN: 更新成功,返回更新后的记录
|
||||
//
|
||||
// 破坏点:如果 Update API 不保存金额变更,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_更新一次性佣金金额", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"one_time_commission_amount": 6000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("PUT", path, jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, float64(6000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 更新金额不能超过上级上限
|
||||
// GIVEN: 上级分配金额上限为 10000
|
||||
// WHEN: 尝试将金额更新为 15000
|
||||
// THEN: 返回 400 错误
|
||||
//
|
||||
// 破坏点:如果更新时不检查金额上限,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_更新金额不能超过上级上限", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"one_time_commission_amount": 15000, // 超过上级的 10000
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("PUT", path, jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 更新强充配置
|
||||
// GIVEN: 分配记录存在
|
||||
// WHEN: PUT 启用强充,设置金额 100 元
|
||||
// THEN: 配置更新成功
|
||||
//
|
||||
// 破坏点:如果不保存强充配置,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_更新强充配置", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"enable_force_recharge": true,
|
||||
"force_recharge_amount": 10000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("PUT", path, jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, true, data["enable_force_recharge"])
|
||||
assert.Equal(t, float64(10000), data["force_recharge_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 更新不存在的分配
|
||||
// GIVEN: 分配 ID 不存在
|
||||
// WHEN: PUT /api/admin/shop-series-allocations/99999
|
||||
// THEN: 返回 404 "分配记录不存在"
|
||||
//
|
||||
// 破坏点:如果不检查记录是否存在,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_更新不存在的分配", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"one_time_commission_amount": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("PUT", "/api/admin/shop-series-allocations/99999", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocation_Delete_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createTestPackageSeries(t, env, "删除测试系列")
|
||||
|
||||
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 删除系列分配时检查套餐分配
|
||||
// GIVEN: 系列分配存在,且有依赖的套餐分配
|
||||
// WHEN: DELETE /api/admin/shop-series-allocations/:id
|
||||
// THEN: 返回 400 "存在关联的套餐分配,无法删除"
|
||||
//
|
||||
// 破坏点:如果不检查套餐分配依赖,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_删除系列分配时检查套餐分配", func(t *testing.T) {
|
||||
// 创建系列分配
|
||||
allocation := createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
|
||||
|
||||
// 创建依赖的套餐分配
|
||||
pkg := createTestPackage(t, env, series.ID, "测试套餐")
|
||||
createPackageAllocationWithSeriesAllocation(t, env, childShop.ID, pkg.ID, allocation.ID)
|
||||
|
||||
// 尝试删除系列分配
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("DELETE", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Message, "关联", "错误消息应提及关联")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 成功删除无依赖的系列分配
|
||||
// GIVEN: 系列分配存在,无套餐分配依赖
|
||||
// WHEN: DELETE /api/admin/shop-series-allocations/:id
|
||||
// THEN: 删除成功
|
||||
//
|
||||
// 破坏点:如果 Delete API 不工作,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_成功删除无依赖的系列分配", func(t *testing.T) {
|
||||
newChildShop := env.CreateTestShop("新下级", 2, &parentShop.ID)
|
||||
allocation := createSeriesAllocationDirectly(t, env, parentShop.ID, newChildShop.ID, series.ID, 5000)
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
|
||||
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
|
||||
Request("DELETE", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestShopSeriesAllocation_Platform_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
series := createTestPackageSeries(t, env, "平台分配测试系列")
|
||||
// 设置系列的一次性佣金上限(假设固定 150 元)
|
||||
setSeriesOneTimeCommissionLimit(t, env, series.ID, 15000)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 平台为一级代理分配
|
||||
// GIVEN: 平台管理员
|
||||
// WHEN: POST 为一级代理分配套餐系列,设置 one_time_commission_amount = 10000
|
||||
// THEN: 分配成功
|
||||
//
|
||||
// 破坏点:如果平台无法创建分配,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_平台为一级代理分配", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"shop_id": parentShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 10000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 平台可自由设定金额
|
||||
// GIVEN: 平台管理员
|
||||
// WHEN: 平台为一级代理分配任意金额(如 20000)
|
||||
// THEN: 分配成功(平台无上限限制)
|
||||
//
|
||||
// 破坏点:如果平台分配被上限限制,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_平台可自由设定金额", func(t *testing.T) {
|
||||
newShop := env.CreateTestShop("新一级代理", 1, nil)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": newShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 20000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 平台配置强充要求
|
||||
// GIVEN: 平台管理员
|
||||
// WHEN: POST 为一级代理分配系列,启用强充,force_recharge_amount = 10000
|
||||
// THEN: 配置保存成功
|
||||
//
|
||||
// 破坏点:如果不保存强充配置,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_平台配置强充要求", func(t *testing.T) {
|
||||
newShop := env.CreateTestShop("强充测试店铺", 1, nil)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": newShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 10000,
|
||||
"enable_force_recharge": true,
|
||||
"force_recharge_amount": 10000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, true, data["enable_force_recharge"])
|
||||
assert.Equal(t, float64(10000), data["force_recharge_amount"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestShopPackageAllocation_SeriesDependency_Acceptance(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级代理", 1, nil)
|
||||
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
|
||||
series := createTestPackageSeries(t, env, "依赖测试系列")
|
||||
pkg := createTestPackage(t, env, series.ID, "依赖测试套餐")
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 未分配系列时分配套餐失败
|
||||
// GIVEN: 下级店铺未被分配系列 X
|
||||
// WHEN: 代理尝试为下级分配套餐 A(属于系列 X)
|
||||
// THEN: 返回 400 "请先分配该套餐所属的系列"
|
||||
//
|
||||
// 破坏点:如果不检查系列分配依赖,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_未分配系列时分配套餐失败", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"package_id": pkg.ID,
|
||||
"cost_price": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-package-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Message, "系列", "错误消息应提及系列")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 先分配系列再分配套餐
|
||||
// GIVEN: 下级店铺已被分配系列 X
|
||||
// WHEN: 代理为下级分配套餐 A(属于系列 X)
|
||||
// THEN: 分配成功,套餐分配关联到系列分配记录
|
||||
//
|
||||
// 破坏点:如果不关联 series_allocation_id,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_先分配系列再分配套餐", func(t *testing.T) {
|
||||
// 先分配系列
|
||||
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
seriesAllocation := createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
|
||||
|
||||
// 再分配套餐
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"package_id": pkg.ID,
|
||||
"cost_price": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("POST", "/api/admin/shop-package-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
|
||||
// 验证关联
|
||||
data := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, float64(seriesAllocation.ID), data["series_allocation_id"],
|
||||
"套餐分配应关联到系列分配")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 套餐分配只包含成本价
|
||||
// GIVEN: 套餐分配 API
|
||||
// WHEN: 创建或查询套餐分配
|
||||
// THEN: 请求/响应只包含 cost_price,不包含 one_time_commission_amount
|
||||
//
|
||||
// 破坏点:如果响应包含 one_time_commission_amount,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_套餐分配只包含成本价", func(t *testing.T) {
|
||||
// 查询已创建的套餐分配
|
||||
resp, err := env.AsSuperAdmin().
|
||||
Request("GET", fmt.Sprintf("/api/admin/shop-package-allocations?shop_id=%d", childShop.ID), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) > 0 {
|
||||
firstItem := items[0].(map[string]interface{})
|
||||
_, hasCostPrice := firstItem["cost_price"]
|
||||
_, hasOneTimeCommission := firstItem["one_time_commission_amount"]
|
||||
|
||||
assert.True(t, hasCostPrice, "应包含 cost_price")
|
||||
assert.False(t, hasOneTimeCommission, "不应包含 one_time_commission_amount")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
func createTestPackageSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err, "创建测试套餐系列失败")
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createTestPackage(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, name string) *model.Package {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
pkg := &model.Package{
|
||||
PackageCode: fmt.Sprintf("PKG_%d", timestamp),
|
||||
PackageName: name,
|
||||
SeriesID: seriesID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
CostPrice: 5000,
|
||||
SuggestedRetailPrice: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(pkg).Error
|
||||
require.NoError(t, err, "创建测试套餐失败")
|
||||
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createTestAgentAccount(t *testing.T, env *integ.IntegrationTestEnv, shopID uint) *model.Account {
|
||||
t.Helper()
|
||||
return env.CreateTestAccount("agent", "password123", constants.UserTypeAgent, &shopID, nil)
|
||||
}
|
||||
|
||||
// createPlatformSeriesAllocation 模拟平台为一级代理创建的系列分配
|
||||
// 注意:由于 ShopSeriesAllocation 模型可能尚未创建,这里直接通过数据库操作模拟
|
||||
func createPlatformSeriesAllocation(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shopID,
|
||||
SeriesID: seriesID,
|
||||
AllocatorShopID: 0, // 平台分配
|
||||
OneTimeCommissionAmount: amount,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建平台系列分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
// createSeriesAllocationDirectly 直接在数据库创建系列分配记录
|
||||
func createSeriesAllocationDirectly(t *testing.T, env *integ.IntegrationTestEnv, allocatorShopID, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shopID,
|
||||
SeriesID: seriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
OneTimeCommissionAmount: amount,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建系列分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
// createPackageAllocationWithSeriesAllocation 创建关联系列分配的套餐分配
|
||||
func createPackageAllocationWithSeriesAllocation(t *testing.T, env *integ.IntegrationTestEnv, shopID, packageID, seriesAllocationID uint) *model.ShopPackageAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: shopID,
|
||||
PackageID: packageID,
|
||||
SeriesAllocationID: &seriesAllocationID,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建套餐分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
// setSeriesOneTimeCommissionLimit 设置系列的一次性佣金上限(假设在 PackageSeries 或配置中)
|
||||
func setSeriesOneTimeCommissionLimit(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, limit int64) {
|
||||
t.Helper()
|
||||
|
||||
// 更新系列配置
|
||||
err := env.TX.Model(&model.PackageSeries{}).Where("id = ?", seriesID).Updates(map[string]interface{}{
|
||||
"enable_one_time_commission": true,
|
||||
// 假设有 one_time_commission_config 字段存储配置
|
||||
}).Error
|
||||
require.NoError(t, err, "设置系列佣金上限失败")
|
||||
}
|
||||
541
tests/flows/README.md
Normal file
541
tests/flows/README.md
Normal file
@@ -0,0 +1,541 @@
|
||||
# 业务流程测试 (Flow Tests)
|
||||
|
||||
流程测试验证多个 API 组合的完整业务场景,确保端到端流程正确。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **来源于 Spec Business Flow**:每个测试对应 Spec 中的一个 Business Flow
|
||||
2. **跨 API 验证**:验证多个 API 调用的组合行为
|
||||
3. **状态共享**:流程中的数据(如 ID)在 steps 之间传递
|
||||
4. **角色切换**:不同 step 可能由不同角色执行
|
||||
5. **必须有破坏点和依赖声明**
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/flows/
|
||||
├── README.md # 本文件
|
||||
├── package_lifecycle_flow_test.go # 套餐完整生命周期
|
||||
├── order_purchase_flow_test.go # 订单购买流程
|
||||
├── commission_settlement_flow_test.go # 佣金结算流程
|
||||
├── iot_card_import_activate_flow_test.go # IoT 卡导入激活流程
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 测试模板
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:{流程名称}
|
||||
// 来源:openspec/changes/{change-name}/specs/{capability}/spec.md
|
||||
// 参与者:{角色1}, {角色2}, ...
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_{FlowName}(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ========================================================
|
||||
// 流程级共享状态
|
||||
// 在 steps 之间传递的数据
|
||||
// ========================================================
|
||||
var (
|
||||
resourceID uint
|
||||
orderID uint
|
||||
// 其他需要共享的状态...
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: {步骤名称}
|
||||
// 角色: {执行角色}
|
||||
// 调用: {HTTP Method} {Path}
|
||||
// 预期: {预期结果}
|
||||
//
|
||||
// 依赖: 无(首个步骤)
|
||||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_{步骤名称}", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin() // 或其他角色
|
||||
|
||||
body := map[string]interface{}{
|
||||
// 请求体
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/xxx", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提取共享状态
|
||||
data := result["data"].(map[string]interface{})
|
||||
resourceID = uint(data["id"].(float64))
|
||||
require.NotZero(t, resourceID, "资源 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: {步骤名称}
|
||||
// 角色: {执行角色}
|
||||
// 调用: {HTTP Method} {Path}
|
||||
// 预期: {预期结果}
|
||||
//
|
||||
// 依赖: Step 1 的 resourceID
|
||||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_{步骤名称}", func(t *testing.T) {
|
||||
if resourceID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 resourceID")
|
||||
}
|
||||
|
||||
client := env.AsShopAgent(1) // 切换到代理商角色
|
||||
|
||||
resp, err := client.Request("GET", fmt.Sprintf("/api/admin/xxx/%d", resourceID), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// 验证和提取数据...
|
||||
})
|
||||
|
||||
// 更多 steps...
|
||||
}
|
||||
```
|
||||
|
||||
## 流程测试 vs 验收测试
|
||||
|
||||
| 方面 | 验收测试 | 流程测试 |
|
||||
|------|---------|---------|
|
||||
| 来源 | Spec Scenario | Spec Business Flow |
|
||||
| 粒度 | 单 API | 多 API 组合 |
|
||||
| 状态 | 独立 | steps 之间共享 |
|
||||
| 角色 | 通常单一 | 可能多角色 |
|
||||
| 目的 | 验证 API 契约 | 验证业务场景 |
|
||||
|
||||
## 状态共享模式
|
||||
|
||||
### 使用包级变量
|
||||
|
||||
```go
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
packageID uint
|
||||
allocationID uint
|
||||
orderID uint
|
||||
)
|
||||
|
||||
t.Run("Step1_创建套餐", func(t *testing.T) {
|
||||
// ... 创建套餐
|
||||
packageID = extractedID
|
||||
})
|
||||
|
||||
t.Run("Step2_分配套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1")
|
||||
}
|
||||
// 使用 packageID
|
||||
allocationID = extractedID
|
||||
})
|
||||
|
||||
t.Run("Step3_创建订单", func(t *testing.T) {
|
||||
if allocationID == 0 {
|
||||
t.Skip("依赖 Step 2")
|
||||
}
|
||||
// 使用 allocationID
|
||||
orderID = extractedID
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 依赖声明规范
|
||||
|
||||
每个 step 必须声明依赖:
|
||||
|
||||
```go
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 代理商查看可售套餐
|
||||
// 角色: 代理商
|
||||
// 调用: GET /api/admin/shop-packages
|
||||
// 预期: 列表包含刚分配的套餐
|
||||
//
|
||||
// 依赖: Step 1 的 packageID, Step 2 的分配操作
|
||||
// 破坏点:如果查询不按 shop_id 过滤,代理商会看到其他店铺的套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
## 多角色流程
|
||||
|
||||
```go
|
||||
func TestFlow_CrossRoleWorkflow(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var (
|
||||
resourceID uint
|
||||
shopID uint = 1
|
||||
)
|
||||
|
||||
// Step 1: 平台管理员创建资源
|
||||
t.Run("Step1_平台创建资源", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
// ...
|
||||
resourceID = extractedID
|
||||
})
|
||||
|
||||
// Step 2: 平台管理员分配给代理商
|
||||
t.Run("Step2_分配给代理商", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
// ...
|
||||
})
|
||||
|
||||
// Step 3: 代理商查看资源(角色切换!)
|
||||
t.Run("Step3_代理商查看", func(t *testing.T) {
|
||||
client := env.AsShopAgent(shopID) // 切换到代理商
|
||||
// ...
|
||||
})
|
||||
|
||||
// Step 4: 代理商创建订单
|
||||
t.Run("Step4_代理商创建订单", func(t *testing.T) {
|
||||
client := env.AsShopAgent(shopID)
|
||||
// ...
|
||||
})
|
||||
|
||||
// Step 5: 平台管理员查看统计(再次切换)
|
||||
t.Run("Step5_平台查看统计", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 破坏点注释规范
|
||||
|
||||
流程测试的破坏点更侧重于跨 API 的影响:
|
||||
|
||||
```go
|
||||
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||||
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||||
// 破坏点:如果代理商查询不过滤 shop_id,会看到其他店铺的数据
|
||||
// 破坏点:如果订单创建不验证套餐有效期,可能购买过期套餐
|
||||
// 破坏点:如果佣金计算不在事务中,可能导致数据不一致
|
||||
```
|
||||
|
||||
## 异常流程测试
|
||||
|
||||
流程测试也应覆盖异常场景:
|
||||
|
||||
```go
|
||||
func TestFlow_PackageLifecycle_Exceptions(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:尝试删除已分配的套餐
|
||||
// 预期:删除失败,返回业务错误
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_删除已分配套餐", func(t *testing.T) {
|
||||
// Step 1: 创建套餐
|
||||
// Step 2: 分配给店铺
|
||||
// Step 3: 尝试删除(预期失败)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:代理商访问其他店铺的套餐
|
||||
// 预期:访问被拒绝
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_跨店铺访问", func(t *testing.T) {
|
||||
// Step 1: 平台创建并分配给店铺 A
|
||||
// Step 2: 店铺 B 的代理商尝试访问(预期 403)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有流程测试
|
||||
source .env.local && go test -v ./tests/flows/...
|
||||
|
||||
# 运行特定流程
|
||||
source .env.local && go test -v ./tests/flows/... -run TestFlow_PackageLifecycle
|
||||
|
||||
# 运行特定步骤
|
||||
source .env.local && go test -v ./tests/flows/... -run "Step3"
|
||||
```
|
||||
|
||||
## 与 Spec Business Flow 的对应关系
|
||||
|
||||
```markdown
|
||||
# Spec 中的 Business Flow
|
||||
|
||||
### Flow: 套餐完整生命周期
|
||||
|
||||
**参与者**: 平台管理员, 代理商
|
||||
|
||||
**流程步骤**:
|
||||
|
||||
1. **创建套餐**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/packages
|
||||
- 预期: 返回套餐 ID
|
||||
|
||||
2. **分配给代理商**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/shop-packages
|
||||
- 输入: 套餐 ID + 店铺 ID
|
||||
- 预期: 分配成功
|
||||
|
||||
3. **代理商查看可售套餐**
|
||||
- 角色: 代理商
|
||||
- 调用: GET /api/admin/shop-packages
|
||||
- 预期: 列表包含刚分配的套餐
|
||||
```
|
||||
|
||||
直接转换为测试代码:
|
||||
|
||||
```go
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var packageID uint
|
||||
|
||||
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||||
// POST /api/admin/packages
|
||||
})
|
||||
|
||||
t.Run("Step2_分配给代理商", func(t *testing.T) {
|
||||
// POST /api/admin/shop-packages
|
||||
})
|
||||
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
// GET /api/admin/shop-packages
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:IoT 卡导入到激活完整流程
|
||||
// 来源:openspec/changes/iot-card-management/specs/iot-card/spec.md
|
||||
// 参与者:平台管理员, 系统
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_IotCardImportActivate(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
taskID string
|
||||
cardICCIDs []string
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 上传 CSV 文件
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/iot-cards/import
|
||||
// 预期: 返回导入任务 ID
|
||||
//
|
||||
// 依赖: 无
|
||||
// 破坏点:如果文件上传不创建异步任务,后续无法追踪进度
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_上传CSV文件", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// 创建测试 CSV 内容
|
||||
csvContent := "iccid,msisdn,operator\n" +
|
||||
"89860000000000000001,13800000001,中国移动\n" +
|
||||
"89860000000000000002,13800000002,中国移动\n"
|
||||
|
||||
resp, err := client.UploadFile("POST", "/api/admin/iot-cards/import",
|
||||
"file", "cards.csv", []byte(csvContent))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
taskID = data["task_id"].(string)
|
||||
require.NotEmpty(t, taskID, "任务 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 查询导入任务状态
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/iot-cards/import/{taskID}
|
||||
// 预期: 任务状态为 completed,导入成功数量 = 2
|
||||
//
|
||||
// 依赖: Step 1 的 taskID
|
||||
// 破坏点:如果异步任务不更新状态,查询会一直返回 pending
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_查询导入状态", func(t *testing.T) {
|
||||
if taskID == "" {
|
||||
t.Skip("依赖 Step 1 创建的 taskID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// 轮询等待任务完成(最多等待 30 秒)
|
||||
var status string
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := client.Request("GET",
|
||||
fmt.Sprintf("/api/admin/iot-cards/import/%s", taskID), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
resp.JSON(&result)
|
||||
data := result["data"].(map[string]interface{})
|
||||
status = data["status"].(string)
|
||||
|
||||
if status == "completed" || status == "failed" {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
require.Equal(t, "completed", status, "导入任务应该成功完成")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 验证卡片已入库
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/iot-cards
|
||||
// 预期: 能查询到导入的卡片
|
||||
//
|
||||
// 依赖: Step 2 确认任务完成
|
||||
// 破坏点:如果导入任务不写入数据库,查询不到卡片
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_验证卡片入库", func(t *testing.T) {
|
||||
if taskID == "" {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("GET", "/api/admin/iot-cards", map[string]interface{}{
|
||||
"iccid": "89860000000000000001",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
resp.JSON(&result)
|
||||
data := result["data"].(map[string]interface{})
|
||||
list := data["list"].([]interface{})
|
||||
|
||||
require.Len(t, list, 1, "应该能查询到导入的卡片")
|
||||
|
||||
card := list[0].(map[string]interface{})
|
||||
cardICCIDs = append(cardICCIDs, card["iccid"].(string))
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 4: 激活卡片
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/iot-cards/{iccid}/activate
|
||||
// 预期: 卡片状态变为 active
|
||||
//
|
||||
// 依赖: Step 3 获取的 cardICCIDs
|
||||
// 破坏点:如果激活 API 不调用运营商接口,状态不会真正变化
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step4_激活卡片", func(t *testing.T) {
|
||||
if len(cardICCIDs) == 0 {
|
||||
t.Skip("依赖 Step 3 获取的卡片 ICCID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("POST",
|
||||
fmt.Sprintf("/api/admin/iot-cards/%s/activate", cardICCIDs[0]), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 5: 验证卡片状态
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/iot-cards/{iccid}
|
||||
// 预期: 卡片状态为 active
|
||||
//
|
||||
// 依赖: Step 4 激活操作
|
||||
// 破坏点:如果激活后不更新数据库状态,查询还是旧状态
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step5_验证激活状态", func(t *testing.T) {
|
||||
if len(cardICCIDs) == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("GET",
|
||||
fmt.Sprintf("/api/admin/iot-cards/%s", cardICCIDs[0]), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
resp.JSON(&result)
|
||||
data := result["data"].(map[string]interface{})
|
||||
|
||||
assert.Equal(t, "active", data["status"], "卡片状态应该是 active")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: Step 之间必须按顺序执行吗?
|
||||
|
||||
是的。Go 的 t.Run 保证同一个父测试内的子测试按顺序执行。如果前置 step 失败,后续 step 会因为依赖检查而 skip。
|
||||
|
||||
### Q: 如何处理异步操作?
|
||||
|
||||
使用轮询等待:
|
||||
|
||||
```go
|
||||
// 等待异步任务完成
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
status := checkStatus()
|
||||
if status == "completed" {
|
||||
break
|
||||
}
|
||||
time.Sleep(interval)
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 流程测试太慢怎么办?
|
||||
|
||||
1. 使用 `t.Parallel()` 让不同流程并行(注意数据隔离)
|
||||
2. 减少 sleep 时间,增加轮询频率
|
||||
3. 考虑将部分验证移到验收测试
|
||||
496
tests/flows/one_time_commission_chain_flow_test.go
Normal file
496
tests/flows/one_time_commission_chain_flow_test.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package flows
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:一次性佣金链式分配
|
||||
// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md
|
||||
// 参与者:平台管理员, 一级代理, 二级代理, 三级代理
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_OneTimeCommissionChainAllocation(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
// ========================================================
|
||||
// 流程级共享状态
|
||||
// ========================================================
|
||||
var (
|
||||
seriesID uint
|
||||
level1ShopID uint
|
||||
level2ShopID uint
|
||||
level3ShopID uint
|
||||
level1AllocationID uint
|
||||
level2AllocationID uint
|
||||
level3AllocationID uint
|
||||
packageID uint
|
||||
level3PackageAllocID uint
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 平台创建套餐系列并启用一次性佣金
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/package-series
|
||||
// 预期: 返回系列 ID,enable_one_time_commission = true
|
||||
//
|
||||
// 依赖: 无
|
||||
// 破坏点:如果系列创建不支持 enable_one_time_commission,后续分配无法启用
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_平台创建套餐系列", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"series_code": fmt.Sprintf("CHAIN_SERIES_%d", time.Now().UnixNano()),
|
||||
"series_name": "链式分配测试系列",
|
||||
"description": "测试一次性佣金链式分配",
|
||||
"status": 1,
|
||||
"enable_one_time_commission": true,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/package-series", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code, "创建系列失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
seriesID = uint(data["id"].(float64))
|
||||
require.NotZero(t, seriesID, "系列 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 创建三级店铺层级
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shops (3次)
|
||||
// 预期: 创建一级、二级、三级店铺
|
||||
//
|
||||
// 依赖: 无
|
||||
// 破坏点:如果店铺层级关系不正确,后续分配权限检查会失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_创建三级店铺层级", func(t *testing.T) {
|
||||
level1Shop := env.CreateTestShop("一级代理_链式", 1, nil)
|
||||
level1ShopID = level1Shop.ID
|
||||
require.NotZero(t, level1ShopID)
|
||||
|
||||
level2Shop := env.CreateTestShop("二级代理_链式", 2, &level1ShopID)
|
||||
level2ShopID = level2Shop.ID
|
||||
require.NotZero(t, level2ShopID)
|
||||
|
||||
level3Shop := env.CreateTestShop("三级代理_链式", 3, &level2ShopID)
|
||||
level3ShopID = level3Shop.ID
|
||||
require.NotZero(t, level3ShopID)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 平台为一级代理分配系列(金额上限 100 元)
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-series-allocations
|
||||
// 预期: 分配成功,one_time_commission_amount = 10000
|
||||
//
|
||||
// 依赖: Step 1 的 seriesID, Step 2 的 level1ShopID
|
||||
// 破坏点:如果平台无法分配系列,链式分配无法开始
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_平台为一级代理分配系列", func(t *testing.T) {
|
||||
if seriesID == 0 || level1ShopID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level1ShopID,
|
||||
"series_id": seriesID,
|
||||
"one_time_commission_amount": 10000,
|
||||
"enable_one_time_commission": true,
|
||||
"one_time_commission_trigger": "accumulated_recharge",
|
||||
"one_time_commission_threshold": 100000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code, "平台分配失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level1AllocationID = uint(data["id"].(float64))
|
||||
assert.Equal(t, float64(10000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 4: 一级代理为二级代理分配系列(金额上限 80 元)
|
||||
// 角色: 一级代理
|
||||
// 调用: POST /api/admin/shop-series-allocations
|
||||
// 预期: 分配成功,one_time_commission_amount = 8000
|
||||
//
|
||||
// 依赖: Step 3 的 level1AllocationID
|
||||
// 破坏点:如果一级无法为下级分配,链式传递中断
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step4_一级为二级分配系列", func(t *testing.T) {
|
||||
if level1AllocationID == 0 {
|
||||
t.Skip("依赖 Step 3")
|
||||
}
|
||||
|
||||
level1Account := env.CreateTestAccount("level1_agent", "password123", constants.UserTypeAgent, &level1ShopID, nil)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level2ShopID,
|
||||
"series_id": seriesID,
|
||||
"one_time_commission_amount": 8000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(level1Account).Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code, "一级分配给二级失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level2AllocationID = uint(data["id"].(float64))
|
||||
assert.Equal(t, float64(8000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 5: 二级代理为三级代理分配系列(金额上限 50 元)
|
||||
// 角色: 二级代理
|
||||
// 调用: POST /api/admin/shop-series-allocations
|
||||
// 预期: 分配成功,one_time_commission_amount = 5000
|
||||
//
|
||||
// 依赖: Step 4 的 level2AllocationID
|
||||
// 破坏点:如果二级无法为下级分配,链式传递中断
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step5_二级为三级分配系列", func(t *testing.T) {
|
||||
if level2AllocationID == 0 {
|
||||
t.Skip("依赖 Step 4")
|
||||
}
|
||||
|
||||
level2Account := env.CreateTestAccount("level2_agent", "password123", constants.UserTypeAgent, &level2ShopID, nil)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level3ShopID,
|
||||
"series_id": seriesID,
|
||||
"one_time_commission_amount": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(level2Account).Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code, "二级分配给三级失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level3AllocationID = uint(data["id"].(float64))
|
||||
assert.Equal(t, float64(5000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 6: 验证链式分配金额正确
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/shop-series-allocations?shop_id=xxx&series_id=xxx (3次)
|
||||
// 预期: 一级 10000,二级 8000,三级 5000
|
||||
//
|
||||
// 依赖: Step 3-5 的分配记录
|
||||
// 破坏点:如果金额查询不正确,佣金计算会出错
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step6_验证链式分配金额", func(t *testing.T) {
|
||||
if level3AllocationID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
verifyChainAllocationAmount(t, env, level1ShopID, seriesID, 10000)
|
||||
verifyChainAllocationAmount(t, env, level2ShopID, seriesID, 8000)
|
||||
verifyChainAllocationAmount(t, env, level3ShopID, seriesID, 5000)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 7: 平台创建套餐并关联系列
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/packages
|
||||
// 预期: 返回套餐 ID,series_id 正确关联
|
||||
//
|
||||
// 依赖: Step 1 的 seriesID
|
||||
// 破坏点:如果套餐不关联系列,佣金计算无法找到配置
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step7_创建套餐", func(t *testing.T) {
|
||||
if seriesID == 0 {
|
||||
t.Skip("依赖 Step 1")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"package_code": fmt.Sprintf("CHAIN_PKG_%d", time.Now().UnixNano()),
|
||||
"package_name": "链式分配测试套餐",
|
||||
"series_id": seriesID,
|
||||
"package_type": "formal",
|
||||
"duration_months": 1,
|
||||
"cost_price": 5000,
|
||||
"suggested_retail_price": 9900,
|
||||
"status": 1,
|
||||
"shelf_status": 1,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/packages", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code, "创建套餐失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
packageID = uint(data["id"].(float64))
|
||||
require.NotZero(t, packageID)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 8: 为三级代理分配套餐(需先有系列分配)
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-package-allocations
|
||||
// 预期: 分配成功,series_allocation_id 关联到系列分配
|
||||
//
|
||||
// 依赖: Step 5 的 level3AllocationID, Step 7 的 packageID
|
||||
// 破坏点:如果套餐分配不检查系列依赖,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step8_为三级代理分配套餐", func(t *testing.T) {
|
||||
if level3AllocationID == 0 || packageID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level3ShopID,
|
||||
"package_id": packageID,
|
||||
"cost_price": 6000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-package-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code, "套餐分配失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level3PackageAllocID = uint(data["id"].(float64))
|
||||
|
||||
if allocID, ok := data["series_allocation_id"]; ok && allocID != nil {
|
||||
assert.Equal(t, float64(level3AllocationID), allocID,
|
||||
"套餐分配应关联到系列分配")
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 9: 验证完整分配链路
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET APIs
|
||||
// 预期: 所有分配记录正确关联
|
||||
//
|
||||
// 依赖: 所有前置步骤
|
||||
// 破坏点:如果任何环节数据不一致,此验证将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step9_验证完整分配链路", func(t *testing.T) {
|
||||
if level3PackageAllocID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
assert.NotZero(t, seriesID, "系列已创建")
|
||||
assert.NotZero(t, level1AllocationID, "一级系列分配已创建")
|
||||
assert.NotZero(t, level2AllocationID, "二级系列分配已创建")
|
||||
assert.NotZero(t, level3AllocationID, "三级系列分配已创建")
|
||||
assert.NotZero(t, packageID, "套餐已创建")
|
||||
assert.NotZero(t, level3PackageAllocID, "三级套餐分配已创建")
|
||||
})
|
||||
|
||||
_ = level1AllocationID
|
||||
_ = level3PackageAllocID
|
||||
}
|
||||
|
||||
func TestFlow_OneTimeCommissionChainAllocation_Exceptions(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:下级金额超过上级上限
|
||||
// 预期:分配失败,返回错误
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_下级金额超过上级", func(t *testing.T) {
|
||||
parentShop := env.CreateTestShop("超限测试_父级", 1, nil)
|
||||
childShop := env.CreateTestShop("超限测试_子级", 2, &parentShop.ID)
|
||||
series := createFlowTestSeries(t, env, "超限测试系列")
|
||||
|
||||
createFlowPlatformAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 15000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
parentAccount := env.CreateTestAccount("parent_agent", "password123", constants.UserTypeAgent, &parentShop.ID, nil)
|
||||
|
||||
resp, err := env.AsUser(parentAccount).Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == 400 || resp.StatusCode == 403,
|
||||
"超限分配应返回 400 或 403,实际: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:未分配系列就分配套餐
|
||||
// 预期:套餐分配失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_未分配系列就分配套餐", func(t *testing.T) {
|
||||
shop := env.CreateTestShop("无系列分配店铺", 1, nil)
|
||||
series := createFlowTestSeries(t, env, "未分配系列")
|
||||
pkg := createFlowTestPackage(t, env, series.ID, "未分配测试套餐")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": shop.ID,
|
||||
"package_id": pkg.ID,
|
||||
"cost_price": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-package-allocations", jsonBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 400, resp.StatusCode, "未分配系列时分配套餐应失败")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
func verifyChainAllocationAmount(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, expectedAmount int64) {
|
||||
t.Helper()
|
||||
|
||||
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d", shopID, seriesID)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, result.Code)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
require.NotEmpty(t, items, "店铺 %d 应存在系列 %d 的分配记录", shopID, seriesID)
|
||||
|
||||
allocation := items[0].(map[string]interface{})
|
||||
assert.Equal(t, float64(expectedAmount), allocation["one_time_commission_amount"],
|
||||
"店铺 %d 的佣金金额应为 %d", shopID, expectedAmount)
|
||||
}
|
||||
|
||||
func createFlowTestSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("FLOW_SERIES_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
EnableOneTimeCommission: true,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createFlowTestPackage(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, name string) *model.Package {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
pkg := &model.Package{
|
||||
PackageCode: fmt.Sprintf("FLOW_PKG_%d", timestamp),
|
||||
PackageName: name,
|
||||
SeriesID: seriesID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
CostPrice: 5000,
|
||||
SuggestedRetailPrice: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(pkg).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createFlowPlatformAllocation(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shopID,
|
||||
SeriesID: seriesID,
|
||||
AllocatorShopID: 0,
|
||||
OneTimeCommissionAmount: amount,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return allocation
|
||||
}
|
||||
@@ -377,7 +377,6 @@ func TestDevice_BatchSetSeriesBinding(t *testing.T) {
|
||||
t.Run("设置禁用的系列-应失败", func(t *testing.T) {
|
||||
disabledSeries := createTestPackageSeries(t, env, "禁用系列")
|
||||
env.TX.Model(&model.PackageSeries{}).Where("id = ?", disabledSeries.ID).Update("status", constants.StatusDisabled)
|
||||
env.TX.Model(&model.ShopSeriesAllocation{}).Where("id = ?", disabledSeries.ID).Update("status", constants.StatusDisabled)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"device_ids": []uint{devices[2].ID},
|
||||
|
||||
@@ -672,7 +672,6 @@ func TestIotCard_BatchSetSeriesBinding(t *testing.T) {
|
||||
// 创建一个禁用的分配
|
||||
disabledSeries := createTestPackageSeries(t, env, "禁用系列")
|
||||
env.TX.Model(&model.PackageSeries{}).Where("id = ?", disabledSeries.ID).Update("status", constants.StatusDisabled)
|
||||
env.TX.Model(&model.ShopSeriesAllocation{}).Where("id = ?", disabledSeries.ID).Update("status", constants.StatusDisabled)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"iccids": []string{cards[2].ICCID},
|
||||
@@ -799,3 +798,64 @@ func TestIotCard_BatchSetSeriesBinding(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createTestPackageSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err, "创建测试套餐系列失败")
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createTestAllocation(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID, allocatorShopID uint) *model.ShopPackageAllocation {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
pkg := &model.Package{
|
||||
PackageCode: fmt.Sprintf("PKG_%d", timestamp),
|
||||
PackageName: "测试套餐",
|
||||
SeriesID: seriesID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
RealDataMB: 1024,
|
||||
CostPrice: 5000,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
err := env.TX.Create(pkg).Error
|
||||
require.NoError(t, err, "创建测试套餐失败")
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
ShopID: shopID,
|
||||
PackageID: pkg.ID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: 5000,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err = env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建测试分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMyPackageAPI_ListMyPackages(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级店铺", 1, nil)
|
||||
childShop := env.CreateTestShop("二级店铺", 2, &parentShop.ID)
|
||||
agentAccount := env.CreateTestAccount("agent_my_pkg", "password123", constants.UserTypeAgent, &childShop.ID, nil)
|
||||
|
||||
series := createTestPackageSeriesForMyPkg(t, env, "测试系列")
|
||||
pkg := createTestPackageForMyPkg(t, env, series.ID, "测试套餐")
|
||||
|
||||
createTestAllocationForMyPkg(t, env, parentShop.ID, series.ID, 0)
|
||||
createTestAllocationForMyPkg(t, env, childShop.ID, series.ID, parentShop.ID)
|
||||
|
||||
t.Run("代理查看可售套餐列表", func(t *testing.T) {
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", "/api/admin/my-packages?page=1&page_size=20", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
|
||||
|
||||
t.Logf("ListMyPackages response: %+v", result.Data)
|
||||
})
|
||||
|
||||
t.Run("按系列ID筛选", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/api/admin/my-packages?series_id=%d", series.ID)
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", url, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
})
|
||||
|
||||
t.Run("按套餐类型筛选", func(t *testing.T) {
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", "/api/admin/my-packages?package_type=formal", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
})
|
||||
|
||||
_ = pkg
|
||||
}
|
||||
|
||||
func TestMyPackageAPI_GetMyPackage(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级店铺", 1, nil)
|
||||
childShop := env.CreateTestShop("二级店铺", 2, &parentShop.ID)
|
||||
agentAccount := env.CreateTestAccount("agent_get_pkg", "password123", constants.UserTypeAgent, &childShop.ID, nil)
|
||||
|
||||
series := createTestPackageSeriesForMyPkg(t, env, "测试系列")
|
||||
pkg := createTestPackageForMyPkg(t, env, series.ID, "测试套餐")
|
||||
|
||||
createTestAllocationForMyPkg(t, env, parentShop.ID, series.ID, 0)
|
||||
createTestAllocationForMyPkg(t, env, childShop.ID, series.ID, parentShop.ID)
|
||||
|
||||
t.Run("获取可售套餐详情", func(t *testing.T) {
|
||||
url := fmt.Sprintf("/api/admin/my-packages/%d", pkg.ID)
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", url, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
|
||||
|
||||
if result.Data != nil {
|
||||
dataMap := result.Data.(map[string]interface{})
|
||||
assert.Equal(t, float64(pkg.ID), dataMap["id"])
|
||||
t.Logf("套餐详情: %+v", dataMap)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("获取不存在的套餐", func(t *testing.T) {
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", "/api/admin/my-packages/999999", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.Code, "应返回错误码")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMyPackageAPI_ListMySeriesAllocations(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
parentShop := env.CreateTestShop("一级店铺", 1, nil)
|
||||
childShop := env.CreateTestShop("二级店铺", 2, &parentShop.ID)
|
||||
agentAccount := env.CreateTestAccount("agent_series_alloc", "password123", constants.UserTypeAgent, &childShop.ID, nil)
|
||||
|
||||
series1 := createTestPackageSeriesForMyPkg(t, env, "系列1")
|
||||
series2 := createTestPackageSeriesForMyPkg(t, env, "系列2")
|
||||
|
||||
createTestAllocationForMyPkg(t, env, parentShop.ID, series1.ID, 0)
|
||||
createTestAllocationForMyPkg(t, env, childShop.ID, series1.ID, parentShop.ID)
|
||||
createTestAllocationForMyPkg(t, env, parentShop.ID, series2.ID, 0)
|
||||
createTestAllocationForMyPkg(t, env, childShop.ID, series2.ID, parentShop.ID)
|
||||
|
||||
t.Run("获取被分配的系列列表", func(t *testing.T) {
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", "/api/admin/my-series-allocations?page=1&page_size=20", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
|
||||
|
||||
t.Logf("ListMySeriesAllocations response: %+v", result.Data)
|
||||
})
|
||||
|
||||
t.Run("分页参数生效", func(t *testing.T) {
|
||||
resp, err := env.AsUser(agentAccount).Request("GET", "/api/admin/my-series-allocations?page=1&page_size=1", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, result.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMyPackageAPI_Auth(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
t.Run("未认证请求应返回错误", func(t *testing.T) {
|
||||
resp, err := env.ClearAuth().Request("GET", "/api/admin/my-packages", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码")
|
||||
})
|
||||
|
||||
t.Run("未认证访问系列分配列表", func(t *testing.T) {
|
||||
resp, err := env.ClearAuth().Request("GET", "/api/admin/my-series-allocations", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result response.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码")
|
||||
})
|
||||
}
|
||||
|
||||
func createTestPackageSeriesForMyPkg(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES_MY_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err, "创建测试套餐系列失败")
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createTestPackageForMyPkg(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, name string) *model.Package {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
pkg := &model.Package{
|
||||
PackageCode: fmt.Sprintf("PKG_%d", timestamp),
|
||||
PackageName: name,
|
||||
SeriesID: seriesID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataType: "real",
|
||||
RealDataMB: 1024,
|
||||
DataAmountMB: 1024,
|
||||
Price: 9900,
|
||||
SuggestedRetailPrice: 12800,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 1,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(pkg).Error
|
||||
require.NoError(t, err, "创建测试套餐失败")
|
||||
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createTestAllocationForMyPkg(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID, allocatorShopID uint) *model.ShopSeriesAllocation {
|
||||
t.Helper()
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shopID,
|
||||
SeriesID: seriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
BaseCommissionMode: "fixed",
|
||||
BaseCommissionValue: 500,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(allocation).Error
|
||||
require.NoError(t, err, "创建测试分配失败")
|
||||
|
||||
return allocation
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user