refactor: 一次性佣金配置从套餐级别提升到系列级别
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:
2026-02-04 14:28:44 +08:00
parent fba8e9e76b
commit b18ecfeb55
106 changed files with 9899 additions and 6608 deletions

View 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`

View File

@@ -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.

View File

@@ -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

View 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
```

View 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 的验收标准生成测试骨架 |

View File

@@ -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 转换
- ❌ 配置读取
- ❌ 重复测试同一逻辑
### ⚠️ 测试真实性原则(严格遵守)

View File

@@ -215,7 +215,7 @@ default:
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、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和 H5Agent、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)

View File

@@ -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: 分配者店铺ID0表示平台分配
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: 分配者店铺ID0表示平台分配
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:

View 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
分配配置:
平台给A20元
A给A18元
A1给A25元
触发首充时:
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/)

View 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 类型定义
---
## 九、联系方式
如有疑问,请联系后端开发团队。

View 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#测试要求)

View File

@@ -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),
}
}

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 更新套餐聚合参数

View File

@@ -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 更新套餐系列聚合参数

View File

@@ -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:"分配者店铺ID0表示平台分配"`
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

View File

@@ -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:"跳过数量(已存在)"`

View File

@@ -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:"分配者店铺ID0表示平台分配"`
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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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:分配者店铺ID0表示平台分配" 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 指定表名

View File

@@ -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:分配者店铺ID0表示平台分配" 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"
)

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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")
}
})
}
}

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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) {

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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{

View 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;

View 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 '一次性佣金规则配置JSONenable, 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 '给被分配店铺的一次性佣金金额(分)';

View 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 '分配者店铺ID0表示平台分配';
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;

View 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 '分配者店铺ID0表示平台分配';
-- ============================================
-- 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;

View File

@@ -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;

View File

@@ -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 '分配者店铺ID0表示平台分配';
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;

View File

@@ -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;

View File

@@ -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);

View File

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

View File

@@ -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成本价: 130A分配给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. **前端发版协调**:是否需要灰度发布?
- 取决于前端改动量,建议同步上线

View File

@@ -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` 一次性佣金配置需要迁移到新结构

View File

@@ -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 不再触发一次性佣金

View File

@@ -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

View File

@@ -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元

View File

@@ -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元

View File

@@ -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元

View File

@@ -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`(首充)

View File

@@ -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 从零开始

View File

@@ -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`(虚流量)明确区分

View File

@@ -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** 返回完整的一次性佣金规则配置

View File

@@ -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

View File

@@ -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` 字段

View File

@@ -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` 迁移到新字段

View File

@@ -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 文档确认变更已反映

View File

@@ -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] 新增系列分配 APICRUD已确认
- [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 逐条确认以上内容

View File

@@ -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. **系列分配的状态管理**:系列分配禁用后,已有的套餐分配如何处理?
- 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配

View File

@@ -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` 新表,专门管理系列分配和一次性佣金
- 新增系列分配 APICRUD`/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` 字段可建索引,支持高效过滤

View File

@@ -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 = 900090元上限
- **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 = 10000100元
- **AND** 无下级(终端销售)
- **THEN** 一级代理获得 100 元一次性佣金
#### Scenario: 多级代理链式分配
- **WHEN** 三级代理销售卡,各级系列分配:
- 平台给一级one_time_commission_amount = 10000100元
- 一级给二级one_time_commission_amount = 800080元
- 二级给三级one_time_commission_amount = 500050元
- **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` 方法

View File

@@ -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` 方法

View File

@@ -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 = 500050元
- **THEN** 系统创建分配记录,下级代理能拿的一次性佣金上限为 50 元
#### Scenario: 链式分配金额计算
- **WHEN** 平台给一级代理分配系列one_time_commission_amount = 10000100元
- **AND** 一级代理给二级代理分配one_time_commission_amount = 800080元
- **AND** 二级代理给三级代理分配one_time_commission_amount = 500050元
- **THEN** 三级代理能拿的一次性佣金上限为 50 元
- **AND** 二级代理差价 = 80 - 50 = 30 元
- **AND** 一级代理差价 = 100 - 80 = 20 元
#### Scenario: 下级金额不能超过上级
- **WHEN** 代理尝试为下级分配,设置的 one_time_commission_amount 超过自己被分配的金额
- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限"
#### Scenario: 分配时启用一次性佣金和强充
- **WHEN** 代理为下级分配系列one_time_commission_amount = 5000启用一次性佣金触发类型为累计充值阈值 1000001000元启用强充强充金额 10000100元
- **THEN** 系统保存配置one_time_commission_amount = 5000enable_one_time_commission = truetrigger = "accumulated_recharge"threshold = 100000enable_force_recharge = trueforce_recharge_amount = 10000
#### Scenario: 尝试分配未拥有的系列
- **WHEN** 代理尝试分配自己未被分配的套餐系列
- **THEN** 系统返回错误 "您没有该套餐系列的分配权限"
#### Scenario: 尝试分配给非直属下级
- **WHEN** 代理尝试分配给非直属下级店铺
- **THEN** 系统返回错误 "只能为直属下级分配套餐"
#### Scenario: 重复分配同一系列
- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列
- **THEN** 系统返回错误 "该店铺已分配此套餐系列"
---
### Requirement: 查询套餐系列分配列表
系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。**响应 MUST 包含 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 = 10000100元
- **THEN** 系统创建分配记录,一级代理能拿的一次性佣金上限为 100 元
#### Scenario: 平台金额不能超过系列配置
- **WHEN** 套餐系列配置的一次性佣金固定金额为 15000150元
- **AND** 平台尝试为一级代理分配one_time_commission_amount = 20000200元
- **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: 套餐分配精简
套餐分配ShopPackageAllocationSHALL 只管理成本价,一次性佣金配置移到系列分配。
#### 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 计算

View File

@@ -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`

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
View 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)
})
```

View 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 = 10000100元
// - 一级给二级one_time_commission_amount = 800080元
// - 二级给三级one_time_commission_amount = 500050元
// 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)
}

View 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
View 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. 考虑将部分验证移到验收测试

View 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
// 预期: 返回系列 IDenable_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
// 预期: 返回套餐 IDseries_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
}

View File

@@ -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},

View File

@@ -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
}

View File

@@ -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