From b18ecfeb553781da4cfeb9f554624af0cf03c50d Mon Sep 17 00:00:00 2001 From: huang Date: Wed, 4 Feb 2026 14:28:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=B8=80=E6=AC=A1=E6=80=A7?= =?UTF-8?q?=E4=BD=A3=E9=87=91=E9=85=8D=E7=BD=AE=E4=BB=8E=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E7=BA=A7=E5=88=AB=E6=8F=90=E5=8D=87=E5=88=B0=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E7=BA=A7=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 新增 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) - 删除过时的单元测试(已被验收测试覆盖) --- .opencode/command/opsx-gen-tests.md | 133 ++ .../skills/openspec-continue-change/SKILL.md | 30 +- .opencode/skills/openspec-explore/SKILL.md | 18 + .../SKILL.md | 442 +++++ .../skills/openspec-lock-consensus/SKILL.md | 281 ++++ AGENTS.md | 65 +- README.md | 2 +- docs/admin-openapi.yaml | 449 ++--- docs/commission-package-model.md | 351 ++++ .../前端接口迁移指南.md | 395 +++++ docs/workflow-optimization/方案总览.md | 386 +++++ internal/bootstrap/services.go | 22 +- internal/bootstrap/stores.go | 4 - .../handler/admin/shop_series_allocation.go | 24 +- internal/model/device.go | 136 +- internal/model/dto/package_dto.go | 70 +- internal/model/dto/package_series_dto.go | 60 +- internal/model/dto/shop_package_allocation.go | 53 +- .../dto/shop_package_batch_allocation_dto.go | 9 +- internal/model/dto/shop_series_allocation.go | 117 +- internal/model/iot_card.go | 154 +- internal/model/package.go | 84 +- internal/model/shop_package_allocation.go | 16 +- internal/model/shop_series_allocation.go | 61 +- .../model/shop_series_allocation_config.go | 25 - .../shop_series_one_time_commission_tier.go | 36 - internal/routes/shop_package_allocation.go | 14 +- internal/routes/shop_series_allocation.go | 29 +- .../service/commission_calculation/service.go | 486 +++--- .../commission_calculation/service_test.go | 369 ---- internal/service/device/service.go | 38 +- internal/service/device/service_test.go | 150 -- internal/service/iot_card/service.go | 32 +- internal/service/iot_card/service_test.go | 148 -- internal/service/order/service.go | 193 ++- internal/service/order/service_test.go | 1088 ------------ internal/service/package/service.go | 153 +- internal/service/package/service_test.go | 146 +- internal/service/package_series/service.go | 114 +- .../service/purchase_validation/service.go | 20 +- .../purchase_validation/service_test.go | 179 -- internal/service/recharge/service.go | 187 ++- internal/service/recharge/service_test.go | 1487 ----------------- .../shop_package_allocation/service.go | 81 +- .../shop_package_batch_allocation/service.go | 66 +- .../service/shop_series_allocation/service.go | 486 ++---- internal/store/postgres/device_store.go | 9 + internal/store/postgres/iot_card_store.go | 9 + .../store/postgres/package_series_store.go | 3 + internal/store/postgres/package_store_test.go | 133 +- .../postgres/shop_package_allocation_store.go | 40 +- .../shop_package_allocation_store_test.go | 76 +- .../shop_series_allocation_config_store.go | 65 - .../postgres/shop_series_allocation_store.go | 57 +- .../shop_series_allocation_store_test.go | 114 -- .../shop_series_commission_stats_store.go | 26 + ...p_series_one_time_commission_tier_store.go | 61 - internal/task/commission_stats_update.go | 4 +- ...refactor_commission_package_model.down.sql | 95 ++ ...2_refactor_commission_package_model.up.sql | 126 ++ ...43_simplify_commission_allocation.down.sql | 68 + ...0043_simplify_commission_allocation.up.sql | 61 + ...or_one_time_commission_allocation.down.sql | 54 + ...ctor_one_time_commission_allocation.up.sql | 77 + ...time_commission_to_package_series.down.sql | 4 + ...e_time_commission_to_package_series.up.sql | 10 + .../.openspec.yaml | 2 + .../design.md | 462 +++++ .../proposal.md | 99 ++ .../accumulated-recharge-tracking/spec.md | 70 + .../specs/agent-available-packages/spec.md | 58 + .../specs/commission-calculation/spec.md | 72 + .../commission-chain-distribution/spec.md | 61 + .../specs/force-recharge-check/spec.md | 72 + .../specs/one-time-commission-trigger/spec.md | 99 ++ .../one-time-commission-validity/spec.md | 54 + .../specs/package-management/spec.md | 74 + .../specs/package-series-management/spec.md | 55 + .../specs/package-virtual-data/spec.md | 62 + .../specs/shop-commission-tier/spec.md | 53 + .../specs/shop-series-allocation/spec.md | 71 + .../tasks.md | 125 ++ .../consensus.md | 66 + .../design.md | 274 +++ .../proposal.md | 91 + .../specs/commission-calculation/spec.md | 147 ++ .../specs/commission-trigger/spec.md | 81 + .../specs/shop-series-allocation/spec.md | 166 ++ .../tasks.md | 174 ++ pkg/constants/redis.go | 11 + pkg/queue/handler.go | 2 +- pkg/utils/commission.go | 13 - tests/acceptance/README.md | 322 ++++ .../commission_calculation_acceptance_test.go | 444 +++++ .../shop_series_allocation_acceptance_test.go | 847 ++++++++++ tests/flows/README.md | 541 ++++++ .../one_time_commission_chain_flow_test.go | 496 ++++++ tests/integration/device_test.go | 1 - tests/integration/iot_card_test.go | 62 +- tests/integration/my_package_test.go | 253 --- tests/integration/package_test.go | 5 - .../shop_package_batch_allocation_test.go | 24 +- .../shop_package_batch_pricing_test.go | 27 +- .../shop_series_allocation_test.go | 579 ------- tests/testutils/db.go | 1 - .../commission_calculation_service_test.go | 410 ----- 106 files changed, 9899 insertions(+), 6608 deletions(-) create mode 100644 .opencode/command/opsx-gen-tests.md create mode 100644 .opencode/skills/openspec-generate-acceptance-tests/SKILL.md create mode 100644 .opencode/skills/openspec-lock-consensus/SKILL.md create mode 100644 docs/commission-package-model.md create mode 100644 docs/refactor-commission-package-model/前端接口迁移指南.md create mode 100644 docs/workflow-optimization/方案总览.md delete mode 100644 internal/model/shop_series_allocation_config.go delete mode 100644 internal/model/shop_series_one_time_commission_tier.go delete mode 100644 internal/service/commission_calculation/service_test.go delete mode 100644 internal/service/device/service_test.go delete mode 100644 internal/service/iot_card/service_test.go delete mode 100644 internal/service/order/service_test.go delete mode 100644 internal/service/purchase_validation/service_test.go delete mode 100644 internal/service/recharge/service_test.go delete mode 100644 internal/store/postgres/shop_series_allocation_config_store.go delete mode 100644 internal/store/postgres/shop_series_allocation_store_test.go delete mode 100644 internal/store/postgres/shop_series_one_time_commission_tier_store.go create mode 100644 migrations/000042_refactor_commission_package_model.down.sql create mode 100644 migrations/000042_refactor_commission_package_model.up.sql create mode 100644 migrations/000043_simplify_commission_allocation.down.sql create mode 100644 migrations/000043_simplify_commission_allocation.up.sql create mode 100644 migrations/000044_refactor_one_time_commission_allocation.down.sql create mode 100644 migrations/000044_refactor_one_time_commission_allocation.up.sql create mode 100644 migrations/000045_add_enable_one_time_commission_to_package_series.down.sql create mode 100644 migrations/000045_add_enable_one_time_commission_to_package_series.up.sql create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/design.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/proposal.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/accumulated-recharge-tracking/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/agent-available-packages/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-calculation/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-chain-distribution/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/force-recharge-check/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-trigger/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-validity/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-management/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-series-management/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-virtual-data/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-commission-tier/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-series-allocation/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-commission-package-model/tasks.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/consensus.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/design.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/proposal.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-calculation/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-trigger/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md create mode 100644 openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/tasks.md delete mode 100644 pkg/utils/commission.go create mode 100644 tests/acceptance/README.md create mode 100644 tests/acceptance/commission_calculation_acceptance_test.go create mode 100644 tests/acceptance/shop_series_allocation_acceptance_test.go create mode 100644 tests/flows/README.md create mode 100644 tests/flows/one_time_commission_chain_flow_test.go delete mode 100644 tests/integration/my_package_test.go delete mode 100644 tests/integration/shop_series_allocation_test.go delete mode 100644 tests/unit/commission_calculation_service_test.go diff --git a/.opencode/command/opsx-gen-tests.md b/.opencode/command/opsx-gen-tests.md new file mode 100644 index 0000000..62172c9 --- /dev/null +++ b/.opencode/command/opsx-gen-tests.md @@ -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 "" --json + ``` + 确认 specs artifact 已完成(`status: "done"`) + +3. **读取 spec 文件** + + 读取 `openspec/changes//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/_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/__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:** +**来源:** specs//spec.md + +### 生成的测试文件 + +**验收测试** (tests/acceptance/): +- _acceptance_test.go + - Scenario_xxx + - Scenario_yyy + +**流程测试** (tests/flows/): +- __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` diff --git a/.opencode/skills/openspec-continue-change/SKILL.md b/.opencode/skills/openspec-continue-change/SKILL.md index 79aaac4..dc7d250 100644 --- a/.opencode/skills/openspec-continue-change/SKILL.md +++ b/.opencode/skills/openspec-continue-change/SKILL.md @@ -102,7 +102,35 @@ Common artifact patterns: - The Capabilities section is critical - each capability listed will need a spec file. - **specs//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. diff --git a/.opencode/skills/openspec-explore/SKILL.md b/.opencode/skills/openspec-explore/SKILL.md index 49d051d..3dc8303 100644 --- a/.opencode/skills/openspec-explore/SKILL.md +++ b/.opencode/skills/openspec-explore/SKILL.md @@ -252,11 +252,28 @@ You: That changes everything. There's no required ending. Discovery might: +- **Lock consensus first**: "讨论已经比较清晰了,要锁定共识吗?" → `/opsx:lock ` - **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 (推荐先锁定) - Create a change: /opsx:new - Fast-forward to tasks: /opsx:ff - Keep exploring: just keep talking diff --git a/.opencode/skills/openspec-generate-acceptance-tests/SKILL.md b/.opencode/skills/openspec-generate-acceptance-tests/SKILL.md new file mode 100644 index 0000000..4833adf --- /dev/null +++ b/.opencode/skills/openspec-generate-acceptance-tests/SKILL.md @@ -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//specs//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/_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/__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/_acceptance_test.go` | +| Spec Business Flows | `tests/flows/__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 +``` diff --git a/.opencode/skills/openspec-lock-consensus/SKILL.md b/.opencode/skills/openspec-lock-consensus/SKILL.md new file mode 100644 index 0000000..a7d532b --- /dev/null +++ b/.opencode/skills/openspec-lock-consensus/SKILL.md @@ -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 +``` + +或在探索结束后,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 + +# 写入 consensus.md +``` + +**文件路径**: `openspec/changes//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**: +**确认时间**: +**确认人**: 用户 + +--- + +## 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 的验收标准生成测试骨架 | diff --git a/AGENTS.md b/AGENTS.md index f499382..7664288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,10 +134,67 @@ Handler → Service → Store → Model ## 测试要求 -- 核心业务逻辑(Service 层)测试覆盖率 ≥ 90% -- 所有 API 端点必须有集成测试 -- 使用 table-driven tests -- 单元测试 < 100ms,集成测试 < 1s +### 测试金字塔(新) + +``` + ┌─────────────┐ + │ E2E 测试 │ ← 手动/自动化 UI(很少) + ─┴─────────────┴─ + ┌─────────────────┐ + │ 业务流程测试 │ ← 15%:多 API 组合验证 + │ tests/flows/ │ 来源:Spec Business Flow + ─┴─────────────────┴─ + ┌─────────────────────┐ + │ 验收测试 │ ← 30%:单 API 契约验证 + │ tests/acceptance/ │ 来源:Spec Scenario + ─┴─────────────────────┴─ + ┌───────────────────────────┐ + │ 集成测试 │ ← 25%:组件集成 + ─┴───────────────────────────┴─ + ┌─────────────────────────────────┐ + │ 单元测试(精简) │ ← 30%:仅复杂逻辑 + └─────────────────────────────────┘ +``` + +### 三层测试体系 + +| 层级 | 测试类型 | 来源 | 验证什么 | 位置 | +|------|---------|------|---------|------| +| **L1** | 验收测试 | Spec Scenario | 单 API 契约 | `tests/acceptance/` | +| **L2** | 流程测试 | Spec Business Flow | 业务场景完整性 | `tests/flows/` | +| **L3** | 单元测试 | 复杂逻辑 | 算法/规则正确性 | 模块内 `*_test.go` | + +### 验收测试规范 + +- **来源于 Spec**:每个 Scenario 对应一个测试用例 +- **测试先于实现**:在功能实现前生成,预期全部 FAIL +- **必须有破坏点**:每个测试注释说明什么代码变更会导致失败 +- **使用 IntegrationTestEnv**:不要 mock 依赖 + +详见:[tests/acceptance/README.md](tests/acceptance/README.md) + +### 流程测试规范 + +- **来源于 Spec Business Flow**:每个 Flow 对应一个测试 +- **跨 API 验证**:多个 API 调用的组合行为 +- **状态共享**:流程中的数据在 steps 之间传递 +- **依赖声明**:每个 step 声明依赖哪些前置 step + +详见:[tests/flows/README.md](tests/flows/README.md) + +### 单元测试精简规则 + +**保留**: +- ✅ 纯函数(计费计算、分佣算法) +- ✅ 状态机(订单状态流转) +- ✅ 复杂业务规则(层级校验、权限计算) +- ✅ 边界条件(时间、金额、精度) + +**删除/不再写**: +- ❌ 简单 CRUD(已被验收测试覆盖) +- ❌ DTO 转换 +- ❌ 配置读取 +- ❌ 重复测试同一逻辑 ### ⚠️ 测试真实性原则(严格遵守) diff --git a/README.md b/README.md index cb1ebb8..e1efe13 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ default: - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) - **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户 -- **代理商体系**:层级管理和分佣结算 +- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md) - **批量同步**:卡状态、实名状态、流量使用情况 - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 8580ea5..f868533 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -74,6 +74,9 @@ components: minimum: 0 nullable: true type: integer + enterprise_name: + description: 企业名称 + type: string id: description: 账号ID minimum: 0 @@ -86,6 +89,9 @@ components: minimum: 0 nullable: true type: integer + shop_name: + description: 店铺名称 + type: string status: description: 状态 (0:禁用, 1:启用) type: integer @@ -632,23 +638,13 @@ components: description: 设备号 type: string type: object - DtoBaseCommissionConfig: - properties: - mode: - description: 返佣模式 (fixed:固定金额, percent:百分比) - type: string - value: - description: 返佣值(分或千分比,如200=20%) - minimum: 0 - type: integer - required: - - mode - - value - type: object DtoBatchAllocatePackagesRequest: properties: - base_commission: - $ref: '#/components/schemas/DtoBaseCommissionConfig' + one_time_commission_amount: + description: 该代理能拿到的一次性佣金(分) + minimum: 0 + nullable: true + type: integer price_adjustment: $ref: '#/components/schemas/DtoPriceAdjustment' series_id: @@ -662,7 +658,6 @@ components: required: - shop_id - series_id - - base_commission type: object DtoBatchSetCardSeriesBindngRequest: properties: @@ -1119,20 +1114,18 @@ components: type: object DtoCreatePackageRequest: properties: - data_amount_mb: - description: 总流量额度(MB) + cost_price: + description: 成本价(分) minimum: 0 - nullable: true type: integer - data_type: - description: 流量类型 (real:真流量, virtual:虚流量) - nullable: true - type: string duration_months: description: 套餐时长(月数) maximum: 120 minimum: 1 type: integer + enable_virtual_data: + description: 是否启用虚流量 + type: boolean package_code: description: 套餐编码 maxLength: 100 @@ -1146,10 +1139,6 @@ components: package_type: description: 套餐类型 (formal:正式套餐, addon:附加套餐) type: string - price: - description: 套餐价格(分) - minimum: 0 - type: integer real_data_mb: description: 真流量额度(MB) minimum: 0 @@ -1160,11 +1149,6 @@ components: minimum: 0 nullable: true type: integer - suggested_cost_price: - description: 建议成本价(分) - minimum: 0 - nullable: true - type: integer suggested_retail_price: description: 建议售价(分) minimum: 0 @@ -1180,7 +1164,7 @@ components: - package_name - package_type - duration_months - - price + - cost_price type: object DtoCreatePackageSeriesRequest: properties: @@ -1188,6 +1172,8 @@ components: description: 描述 maxLength: 500 type: string + one_time_commission_config: + $ref: '#/components/schemas/DtoSeriesOneTimeCommissionConfigDTO' series_code: description: 系列编码 maxLength: 100 @@ -1279,7 +1265,7 @@ components: DtoCreateShopPackageAllocationRequest: properties: cost_price: - description: 覆盖的成本价(分) + description: 该代理的成本价(分) minimum: 0 type: integer package_id: @@ -1361,25 +1347,35 @@ components: type: object DtoCreateShopSeriesAllocationRequest: properties: - base_commission: - $ref: '#/components/schemas/DtoBaseCommissionConfig' enable_force_recharge: - description: 是否启用强充(累计充值强充) + description: 是否启用强制充值 nullable: true type: boolean enable_one_time_commission: description: 是否启用一次性佣金 + nullable: true type: boolean force_recharge_amount: - description: 强充金额(分,0表示使用阈值金额) + description: 强制充值金额(分) + minimum: 0 nullable: true type: integer force_recharge_trigger_type: - description: 强充触发类型(1:单次充值, 2:累计充值) + description: 强充触发类型 (1:单次充值, 2:累计充值) nullable: true type: integer - one_time_commission_config: - $ref: '#/components/schemas/DtoOneTimeCommissionConfig' + one_time_commission_amount: + description: 该代理能拿的一次性佣金金额上限(分) + minimum: 0 + type: integer + one_time_commission_threshold: + description: 一次性佣金触发阈值(分) + minimum: 0 + nullable: true + type: integer + one_time_commission_trigger: + description: 一次性佣金触发类型 (first_recharge:首次充值, accumulated_recharge:累计充值) + type: string series_id: description: 套餐系列ID minimum: 0 @@ -1391,7 +1387,7 @@ components: required: - shop_id - series_id - - base_commission + - one_time_commission_amount type: object DtoCreateWithdrawalSettingReq: properties: @@ -2205,9 +2201,6 @@ components: card_category: description: 卡业务类型 (normal:普通卡, industry:行业卡) type: string - card_type: - description: 卡类型 - type: string carrier_id: description: 运营商ID minimum: 0 @@ -2530,57 +2523,26 @@ components: description: 已提现佣金(分) type: integer type: object - DtoOneTimeCommissionConfig: + DtoOneTimeCommissionTierDTO: properties: - mode: - description: 返佣模式 (fixed:固定金额, percent:百分比) - 固定类型时必填 + amount: + description: 佣金金额(分) + minimum: 0 + type: integer + dimension: + description: 统计维度 (sales_count:销量, sales_amount:销售额) + type: string + stat_scope: + description: 统计范围 (self:仅自己, self_and_sub:自己+下级) type: string threshold: - description: 最低阈值(分) - minimum: 1 - type: integer - tiers: - description: 梯度档位列表 - 梯度类型时必填 - items: - $ref: '#/components/schemas/DtoOneTimeCommissionTierEntry' - nullable: true - type: array - trigger: - description: 触发条件 (single_recharge:单次充值, accumulated_recharge:累计充值) - type: string - type: - description: 一次性佣金类型 (fixed:固定, tiered:梯度) - type: string - value: - description: 佣金金额(分)或比例(千分比)- 固定类型时必填 - minimum: 1 + description: 达标阈值 + minimum: 0 type: integer required: - - type - - trigger + - dimension - threshold - type: object - DtoOneTimeCommissionTierEntry: - properties: - mode: - description: 返佣模式 (fixed:固定金额, percent:百分比) - type: string - threshold: - description: 梯度阈值(销量或销售额分) - minimum: 1 - type: integer - tier_type: - description: 梯度类型 (sales_count:销量, sales_amount:销售额) - type: string - value: - description: 返佣值(分或千分比) - minimum: 1 - type: integer - required: - - tier_type - - threshold - - mode - - value + - amount type: object DtoOrderItemResponse: properties: @@ -2720,8 +2682,7 @@ components: DtoPackageResponse: properties: cost_price: - description: 成本价(分,仅代理用户可见) - nullable: true + description: 成本价(分) type: integer created_at: description: 创建时间 @@ -2729,19 +2690,20 @@ components: current_commission_rate: description: 当前返佣比例(仅代理用户可见) type: string - data_amount_mb: - description: 总流量额度(MB) - type: integer - data_type: - description: 流量类型 (real:真流量, virtual:虚流量) - type: string duration_months: description: 套餐时长(月数) type: integer + enable_virtual_data: + description: 是否启用虚流量 + type: boolean id: description: 套餐ID minimum: 0 type: integer + one_time_commission_amount: + description: 一次性佣金金额(分,代理视角) + nullable: true + type: integer package_code: description: 套餐编码 type: string @@ -2751,9 +2713,6 @@ components: package_type: description: 套餐类型 (formal:正式套餐, addon:附加套餐) type: string - price: - description: 套餐价格(分) - type: integer profit_margin: description: 利润空间(分,仅代理用户可见) nullable: true @@ -2776,9 +2735,6 @@ components: status: description: 状态 (1:启用, 2:禁用) type: integer - suggested_cost_price: - description: 建议成本价(分) - type: integer suggested_retail_price: description: 建议售价(分) type: integer @@ -2820,10 +2776,15 @@ components: description: description: 描述 type: string + enable_one_time_commission: + description: 是否启用一次性佣金 + type: boolean id: description: 系列ID minimum: 0 type: integer + one_time_commission_config: + $ref: '#/components/schemas/DtoSeriesOneTimeCommissionConfigDTO' series_code: description: 系列编码 type: string @@ -3380,6 +3341,48 @@ components: minimum: 0 type: integer type: object + DtoSeriesOneTimeCommissionConfigDTO: + properties: + commission_amount: + description: 固定佣金金额(分),commission_type=fixed时使用 + minimum: 0 + type: integer + commission_type: + description: 佣金类型 (fixed:固定, tiered:梯度) + type: string + enable: + description: 是否启用一次性佣金 + type: boolean + enable_force_recharge: + description: 是否启用强充 + type: boolean + force_amount: + description: 强充金额(分) + minimum: 0 + type: integer + force_calc_type: + description: 强充计算类型 (fixed:固定, dynamic:动态) + type: string + threshold: + description: 触发阈值(分) + minimum: 0 + type: integer + tiers: + description: 梯度配置列表,commission_type=tiered时使用 + items: + $ref: '#/components/schemas/DtoOneTimeCommissionTierDTO' + nullable: true + type: array + trigger_type: + description: 触发类型 (first_recharge:首充, accumulated_recharge:累计充值) + type: string + validity_type: + description: 时效类型 (permanent:永久, fixed_date:固定日期, relative:相对时长) + type: string + validity_value: + description: 时效值(日期或月数) + type: string + type: object DtoSetSpeedLimitRequest: properties: download_speed: @@ -3554,15 +3557,15 @@ components: type: object DtoShopPackageAllocationResponse: properties: - allocation_id: - description: 关联的系列分配ID + allocator_shop_id: + description: 分配者店铺ID,0表示平台分配 minimum: 0 type: integer - calculated_cost_price: - description: 原计算成本价(分),供参考 - type: integer + allocator_shop_name: + description: 分配者店铺名称 + type: string cost_price: - description: 覆盖的成本价(分) + description: 该代理的成本价(分) type: integer created_at: description: 创建时间 @@ -3581,6 +3584,18 @@ components: package_name: description: 套餐名称 type: string + series_allocation_id: + description: 关联的系列分配ID + minimum: 0 + nullable: true + type: integer + series_id: + description: 套餐系列ID + minimum: 0 + type: integer + series_name: + description: 套餐系列名称 + type: string shop_id: description: 被分配的店铺ID minimum: 0 @@ -3718,35 +3733,43 @@ components: DtoShopSeriesAllocationResponse: properties: allocator_shop_id: - description: 分配者店铺ID + description: 分配者店铺ID,0表示平台分配 minimum: 0 type: integer allocator_shop_name: description: 分配者店铺名称 type: string - base_commission: - $ref: '#/components/schemas/DtoBaseCommissionConfig' created_at: description: 创建时间 type: string enable_force_recharge: - description: 是否启用强充 + description: 是否启用强制充值 type: boolean enable_one_time_commission: description: 是否启用一次性佣金 type: boolean force_recharge_amount: - description: 强充金额(分) + description: 强制充值金额(分) type: integer force_recharge_trigger_type: - description: 强充触发类型(1:单次充值, 2:累计充值) + description: 强充触发类型 (1:单次充值, 2:累计充值) type: integer id: description: 分配ID minimum: 0 type: integer - one_time_commission_config: - $ref: '#/components/schemas/DtoOneTimeCommissionConfig' + one_time_commission_amount: + description: 该代理能拿的一次性佣金金额上限(分) + type: integer + one_time_commission_threshold: + description: 一次性佣金触发阈值(分) + type: integer + one_time_commission_trigger: + description: 一次性佣金触发类型 + type: string + series_code: + description: 套餐系列编码 + type: string series_id: description: 套餐系列ID minimum: 0 @@ -3888,9 +3911,6 @@ components: card_category: description: 卡业务类型 (normal:普通卡, industry:行业卡) type: string - card_type: - description: 卡类型 - type: string carrier_id: description: 运营商ID minimum: 0 @@ -4110,21 +4130,21 @@ components: type: object DtoUpdatePackageParams: properties: - data_amount_mb: - description: 总流量额度(MB) + cost_price: + description: 成本价(分) minimum: 0 nullable: true type: integer - data_type: - description: 流量类型 (real:真流量, virtual:虚流量) - nullable: true - type: string duration_months: description: 套餐时长(月数) maximum: 120 minimum: 1 nullable: true type: integer + enable_virtual_data: + description: 是否启用虚流量 + nullable: true + type: boolean package_name: description: 套餐名称 maxLength: 255 @@ -4135,11 +4155,6 @@ components: description: 套餐类型 (formal:正式套餐, addon:附加套餐) nullable: true type: string - price: - description: 套餐价格(分) - minimum: 0 - nullable: true - type: integer real_data_mb: description: 真流量额度(MB) minimum: 0 @@ -4150,11 +4165,6 @@ components: minimum: 0 nullable: true type: integer - suggested_cost_price: - description: 建议成本价(分) - minimum: 0 - nullable: true - type: integer suggested_retail_price: description: 建议售价(分) minimum: 0 @@ -4173,6 +4183,8 @@ components: maxLength: 500 nullable: true type: string + one_time_commission_config: + $ref: '#/components/schemas/DtoSeriesOneTimeCommissionConfigDTO' series_name: description: 系列名称 maxLength: 255 @@ -4278,16 +4290,13 @@ components: properties: status: description: 状态 (0:禁用, 1:启用) - maximum: 1 - minimum: 0 + nullable: true type: integer - required: - - status type: object DtoUpdateShopPackageAllocationParams: properties: cost_price: - description: 覆盖的成本价(分) + description: 该代理的成本价(分) minimum: 0 nullable: true type: integer @@ -4333,10 +4342,8 @@ components: type: object DtoUpdateShopSeriesAllocationParams: properties: - base_commission: - $ref: '#/components/schemas/DtoBaseCommissionConfig' enable_force_recharge: - description: 是否启用强充(累计充值强充) + description: 是否启用强制充值 nullable: true type: boolean enable_one_time_commission: @@ -4344,15 +4351,32 @@ components: nullable: true type: boolean force_recharge_amount: - description: 强充金额(分,0表示使用阈值金额) + description: 强制充值金额(分) + minimum: 0 nullable: true type: integer force_recharge_trigger_type: - description: 强充触发类型(1:单次充值, 2:累计充值) + description: 强充触发类型 (1:单次充值, 2:累计充值) + nullable: true + type: integer + one_time_commission_amount: + description: 该代理能拿的一次性佣金金额上限(分) + minimum: 0 + nullable: true + type: integer + one_time_commission_threshold: + description: 一次性佣金触发阈值(分) + minimum: 0 + nullable: true + type: integer + one_time_commission_trigger: + description: 一次性佣金触发类型 + nullable: true + type: string + status: + description: 状态 (1:启用, 2:禁用) nullable: true type: integer - one_time_commission_config: - $ref: '#/components/schemas/DtoOneTimeCommissionConfig' type: object DtoUpdateStatusParams: properties: @@ -4853,6 +4877,22 @@ paths: minimum: 0 nullable: true type: integer + - description: 店铺ID筛选 + in: query + name: shop_id + schema: + description: 店铺ID筛选 + minimum: 1 + nullable: true + type: integer + - description: 企业ID筛选 + in: query + name: enterprise_id + schema: + description: 企业ID筛选 + minimum: 1 + nullable: true + type: integer responses: "200": content: @@ -11008,6 +11048,13 @@ paths: description: 状态 (1:启用, 2:禁用) nullable: true type: integer + - description: 是否启用一次性佣金 + in: query + name: enable_one_time_commission + schema: + description: 是否启用一次性佣金 + nullable: true + type: boolean responses: "200": content: @@ -12823,6 +12870,22 @@ paths: minimum: 0 nullable: true type: integer + - description: 系列分配ID + in: query + name: series_allocation_id + schema: + description: 系列分配ID + minimum: 0 + nullable: true + type: integer + - description: 分配者店铺ID + in: query + name: allocator_shop_id + schema: + description: 分配者店铺ID + minimum: 0 + nullable: true + type: integer - description: 状态 (1:启用, 2:禁用) in: query name: status @@ -12885,7 +12948,7 @@ paths: - BearerAuth: [] summary: 单套餐分配列表 tags: - - 单套餐分配 + - 套餐分配 post: requestBody: content: @@ -12947,7 +13010,7 @@ paths: - BearerAuth: [] summary: 创建单套餐分配 tags: - - 单套餐分配 + - 套餐分配 /api/admin/shop-package-allocations/{id}: delete: parameters: @@ -12988,7 +13051,7 @@ paths: - BearerAuth: [] summary: 删除单套餐分配 tags: - - 单套餐分配 + - 套餐分配 get: parameters: - description: ID @@ -13054,7 +13117,7 @@ paths: - BearerAuth: [] summary: 获取单套餐分配详情 tags: - - 单套餐分配 + - 套餐分配 put: parameters: - description: ID @@ -13125,7 +13188,7 @@ paths: - BearerAuth: [] summary: 更新单套餐分配 tags: - - 单套餐分配 + - 套餐分配 /api/admin/shop-package-allocations/{id}/cost-price: put: parameters: @@ -13192,7 +13255,7 @@ paths: - BearerAuth: [] summary: 更新单套餐分配成本价 tags: - - 单套餐分配 + - 套餐分配 /api/admin/shop-package-allocations/{id}/status: put: parameters: @@ -13238,7 +13301,7 @@ paths: - BearerAuth: [] summary: 更新单套餐分配状态 tags: - - 单套餐分配 + - 套餐分配 /api/admin/shop-package-batch-allocations: post: requestBody: @@ -13373,6 +13436,14 @@ paths: minimum: 0 nullable: true type: integer + - description: 分配者店铺ID + in: query + name: allocator_shop_id + schema: + description: 分配者店铺ID + minimum: 0 + nullable: true + type: integer - description: 状态 (1:启用, 2:禁用) in: query name: status @@ -13433,9 +13504,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 套餐系列分配列表 + summary: 系列分配列表 tags: - - 套餐系列分配 + - 套餐分配 post: requestBody: content: @@ -13495,9 +13566,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 创建套餐系列分配 + summary: 创建系列分配 tags: - - 套餐系列分配 + - 套餐分配 /api/admin/shop-series-allocations/{id}: delete: parameters: @@ -13536,9 +13607,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 删除套餐系列分配 + summary: 删除系列分配 tags: - - 套餐系列分配 + - 套餐分配 get: parameters: - description: ID @@ -13602,9 +13673,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 获取套餐系列分配详情 + summary: 获取系列分配详情 tags: - - 套餐系列分配 + - 套餐分配 put: parameters: - description: ID @@ -13673,55 +13744,9 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 更新套餐系列分配 + summary: 更新系列分配 tags: - - 套餐系列分配 - /api/admin/shop-series-allocations/{id}/status: - put: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoUpdateStatusParams' - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 更新套餐系列分配状态 - tags: - - 套餐系列分配 + - 套餐分配 /api/admin/shops: get: parameters: diff --git a/docs/commission-package-model.md b/docs/commission-package-model.md new file mode 100644 index 0000000..40a96a4 --- /dev/null +++ b/docs/commission-package-model.md @@ -0,0 +1,351 @@ +# 套餐与佣金业务模型 + +本文档定义了套餐、套餐系列、佣金的完整业务模型,作为系统改造的规范参考。 + +--- + +## 一、核心概念 + +### 1.1 两种佣金类型 + +系统只有两种佣金类型: + +| 佣金类型 | 触发时机 | 触发次数 | 计算方式 | +|---------|---------|---------|---------| +| **差价佣金** | 每笔订单 | 每单都触发 | 下级成本价 - 自己成本价 | +| **一次性佣金** | 首充/累计充值达标 | 每张卡/设备只触发一次 | 上级给的 - 给下级的 | + +### 1.2 实体关系 + +``` +┌─────────────────┐ +│ 套餐系列 │ +│ PackageSeries │ +├─────────────────┤ +│ • 系列名称 │ +│ • 一次性佣金规则 │ ← 可选配置 +└────────┬────────┘ + │ 1:N + ▼ +┌─────────────────┐ ┌─────────────────┐ +│ 套餐 │ │ 卡/设备 │ +│ Package │ │ IoT/Device │ +├─────────────────┤ ├─────────────────┤ +│ • 成本价 │ │ • 绑定系列ID │ +│ • 建议售价 │ │ • 累计充值金额 │ ← 按系列累计 +│ • 真流量(必填) │ │ • 是否已首充 │ ← 按系列记录 +│ • 虚流量(可选) │ └────────┬────────┘ +│ • 虚流量开关 │ │ +└────────┬────────┘ │ 分配 + │ ▼ + │ 分配 ┌─────────────────┐ + ▼ │ 店铺 │ +┌─────────────────┐ │ Shop │ +│ 套餐分配 │◀─────────┤ • 代理层级 │ +│ PkgAllocation │ │ • 上级店铺ID │ +├─────────────────┤ └─────────────────┘ +│ • 店铺ID │ +│ • 套餐ID │ +│ • 成本价(加价后)│ +│ • 一次性佣金额 │ ← 给该代理的金额 +└─────────────────┘ +``` + +--- + +## 二、套餐模型 + +### 2.1 字段定义 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `cost_price` | int64 | 是 | 成本价(平台设置的基础成本价,分) | +| `suggested_price` | int64 | 是 | 建议售价(给代理参考,分) | +| `real_data_mb` | int64 | 是 | 真实流量额度(MB) | +| `enable_virtual_data` | bool | 否 | 是否启用虚流量 | +| `virtual_data_mb` | int64 | 否 | 虚流量额度(启用时必填,≤ 真实流量,MB) | + +### 2.2 流量停机判断 + +``` +停机目标值 = enable_virtual_data ? virtual_data_mb : real_data_mb +``` + +### 2.3 不同用户视角 + +| 用户类型 | 看到的成本价 | 看到的一次性佣金 | +|---------|-------------|-----------------| +| 平台 | 基础成本价 | 完整规则 | +| 代理A | A的成本价(已加价) | A能拿到的金额 | +| 代理A1 | A1的成本价(再加价) | A1能拿到的金额 | + +--- + +## 三、差价佣金 + +### 3.1 计算规则 + +``` +平台设置基础成本价: 100 + │ + │ 分配给代理A,设置成本价: 120 + ▼ +代理A成本价: 120 + │ + │ 分配给代理A1,设置成本价: 130 + ▼ +代理A1成本价: 130 + │ + │ A1销售给客户,售价: 200 + ▼ + +结果: + • A1 收入 = 200 - 130 = 70元(销售利润,不是佣金) + • A 佣金 = 130 - 120 = 10元(差价佣金) + • 平台收入 = 120元 +``` + +### 3.2 关键区分 + +- **收入/利润**:末端代理的 `售价 - 自己成本价` +- **差价佣金**:上级代理的 `下级成本价 - 自己成本价` +- **平台收入**:一级代理的成本价 + +--- + +## 四、一次性佣金 + +### 4.1 触发条件 + +| 条件类型 | 说明 | 强充要求 | +|---------|------|---------| +| `first_recharge` | 首充:该卡/设备在该系列下的第一次充值 | 必须强充 | +| `accumulated_recharge` | 累计充值:累计充值金额达到阈值 | 可选强充 | + +### 4.2 规则配置(套餐系列层面) + +| 配置项 | 类型 | 说明 | +|--------|------|------| +| `enable` | bool | 是否启用一次性佣金 | +| `trigger_type` | string | 触发类型:`first_recharge` / `accumulated_recharge` | +| `threshold` | int64 | 触发阈值(分):首充要求金额 或 累计要求金额 | +| `commission_type` | string | 返佣类型:`fixed`(固定) / `tiered`(梯度) | +| `commission_amount` | int64 | 固定返佣金额(fixed类型时) | +| `tiers` | array | 梯度配置(tiered类型时) | +| `validity_type` | string | 时效类型:`permanent` / `fixed_date` / `relative` | +| `validity_value` | string | 时效值(到期日期 或 月数) | +| `enable_force_recharge` | bool | 是否启用强充 | +| `force_calc_type` | string | 强充金额计算:`fixed`(固定) / `dynamic`(动态差额) | +| `force_amount` | int64 | 强充金额(fixed类型时) | + +### 4.3 链式分配 + +一次性佣金在整条代理链上按约定分配: + +``` +系列规则:首充100返20 + +分配配置: + 平台给A:20元 + A给A1:8元 + A1给A2:5元 + +触发首充时: + A2 获得:5元 + A1 获得:8 - 5 = 3元 + A 获得:20 - 8 = 12元 + ───────────────────── + 合计:20元 ✓ +``` + +### 4.4 首充流程 + +``` +客户购买套餐 + │ + ▼ +预检:系列是否启用一次性佣金且为首充? + │ + 否 ───────────────────▶ 正常购买流程 + │ + 是 + │ + ▼ +该卡/设备在该系列下是否已首充过? + │ + 是 ───────────────────▶ 正常购买流程(不再返佣) + │ + 否 + │ + ▼ +计算强充金额 = max(首充要求, 套餐售价) + │ + ▼ +返回提示:"需要充值 xxx 元" + │ + ▼ +用户确认 → 创建充值订单(金额=强充金额) + │ + ▼ +用户支付 + │ + ▼ +支付成功: + 1. 钱进入钱包 + 2. 标记该卡/设备已首充 + 3. 自动创建套餐购买订单并完成 + 4. 扣款(套餐售价) + 5. 触发一次性佣金,链式分配 +``` + +### 4.5 累计充值流程 + +``` +客户充值(直接充值到钱包) + │ + ▼ +累计充值金额 += 本次充值金额 + │ + ▼ +该卡/设备是否已触发过累计充值返佣? + │ + 是 ───────────────────▶ 结束(不再返佣) + │ + 否 + │ + ▼ +累计金额 >= 累计要求? + │ + 否 ───────────────────▶ 结束(继续累计) + │ + 是 + │ + ▼ +触发一次性佣金,链式分配 +标记该卡/设备已触发累计充值返佣 +``` + +**累计规则**: + +| 操作类型 | 是否累计 | +|---------|---------| +| 直接充值到钱包 | ✅ 累计 | +| 直接购买套餐(不经过钱包) | ❌ 不累计 | +| 强充购买套餐(先充值再扣款) | ✅ 累计(充值部分) | + +--- + +## 五、梯度佣金 + +梯度佣金是一次性佣金的进阶版,根据代理销量/销售额动态调整返佣金额。 + +### 5.1 配置项 + +| 配置项 | 类型 | 说明 | +|--------|------|------| +| `tier_dimension` | string | 梯度维度:`sales_count`(销量) / `sales_amount`(销售额) | +| `stat_scope` | string | 统计范围:`self`(仅自己) / `self_and_sub`(自己+下级) | +| `tiers` | array | 梯度档位列表 | +| `tiers[].threshold` | int64 | 阈值(销量或销售额) | +| `tiers[].amount` | int64 | 返佣金额(分) | + +### 5.2 示例 + +``` +梯度规则(销量维度): +┌────────────────┬────────────────────────┐ +│ 销量区间 │ 首充100返佣金额 │ +├────────────────┼────────────────────────┤ +│ >= 0 │ 5元 │ +├────────────────┼────────────────────────┤ +│ >= 100 │ 10元 │ +├────────────────┼────────────────────────┤ +│ >= 200 │ 20元 │ +└────────────────┴────────────────────────┘ + +代理A当前销量150单 → 落在 [100, 200) 区间 → 首充返10元 +``` + +### 5.3 梯度升级 + +``` +初始状态: + 代理A 销量150(适用10元档),给A1设置5元 + + 触发时:A1得5元,A得10-5=5元 + +升级后(A销量达到210): + A 适用20元档,A1配置仍为5元 + + 触发时:A1得5元(不变),A得20-5=15元(增量归上级) +``` + +### 5.4 统计周期 + +- 统计周期与一次性佣金时效一致 +- 只统计该套餐系列下的销量/销售额 + +--- + +## 六、约束规则 + +### 6.1 套餐分配 + +1. 下级成本价 >= 自己成本价(不能亏本卖) +2. 只能分配自己有权限的套餐给下级 +3. 只能分配给直属下级(不能跨级) + +### 6.2 一次性佣金分配 + +4. 给下级的金额 <= 自己能拿到的金额 +5. 给下级的金额 >= 0(可以设为0,独吞全部) + +### 6.3 流量 + +6. 虚流量 <= 真实流量 + +### 6.4 配置修改 + +7. 修改配置只影响之后的新订单 +8. 代理只能修改"给下级多少钱",不能修改触发规则 +9. 平台修改系列规则不影响已分配的代理,需收回重新分配 + +### 6.5 触发限制 + +10. 一次性佣金每张卡/设备只触发一次 +11. "首充"指该卡/设备在该系列下的第一次充值 +12. 累计充值只统计"充值"操作,不统计"直接购买" + +--- + +## 七、操作流程 + +### 7.1 理想的线性流程 + +``` +1. 创建套餐系列 + └─▶ 可选:配置一次性佣金规则 + +2. 创建套餐 + └─▶ 归属到系列 + └─▶ 设置成本价、建议售价 + └─▶ 设置真流量(必填)、虚流量(可选) + +3. 分配套餐给代理 + └─▶ 设置代理成本价(加价) + └─▶ 如果系列启用一次性佣金:设置给代理的一次性佣金额度 + +4. 分配资产(卡/设备)给代理 + └─▶ 资产绑定的套餐系列自动跟着走 + +5. 代理销售 + └─▶ 客户购买套餐 + └─▶ 差价佣金自动计算并入账给上级 + └─▶ 满足一次性佣金条件时,按链式分配入账 +``` + +--- + +## 八、与现有代码的差异 + +详见改造提案:[refactor-commission-package-model](../openspec/changes/refactor-commission-package-model/) diff --git a/docs/refactor-commission-package-model/前端接口迁移指南.md b/docs/refactor-commission-package-model/前端接口迁移指南.md new file mode 100644 index 0000000..5d09c4d --- /dev/null +++ b/docs/refactor-commission-package-model/前端接口迁移指南.md @@ -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 类型定义 + +--- + +## 九、联系方式 + +如有疑问,请联系后端开发团队。 diff --git a/docs/workflow-optimization/方案总览.md b/docs/workflow-optimization/方案总览.md new file mode 100644 index 0000000..55921f8 --- /dev/null +++ b/docs/workflow-optimization/方案总览.md @@ -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//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 ` | **新** 锁定共识 | +| 3 | `/opsx:new ` | 创建 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/ +│ └── / +│ ├── consensus.md # 新增:共识确认单 +│ ├── proposal.md +│ ├── design.md +│ ├── tasks.md +│ └── specs/ +│ └── / +│ └── 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#测试要求) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 8adf715..d78168f 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 0299f5f..ac46ea9 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -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), diff --git a/internal/handler/admin/shop_series_allocation.go b/internal/handler/admin/shop_series_allocation.go index 1844728..7b926bf 100644 --- a/internal/handler/admin/shop_series_allocation.go +++ b/internal/handler/admin/shop_series_allocation.go @@ -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) -} diff --git a/internal/model/device.go b/internal/model/device.go index 57263c8..b2edf66 100644 --- a/internal/model/device.go +++ b/internal/model/device.go @@ -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) +} diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go index 75c11c2..bef9981 100644 --- a/internal/model/dto/package_dto.go +++ b/internal/model/dto/package_dto.go @@ -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 更新套餐聚合参数 diff --git a/internal/model/dto/package_series_dto.go b/internal/model/dto/package_series_dto.go index ee5a06a..a00a437 100644 --- a/internal/model/dto/package_series_dto.go +++ b/internal/model/dto/package_series_dto.go @@ -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 更新套餐系列聚合参数 diff --git a/internal/model/dto/shop_package_allocation.go b/internal/model/dto/shop_package_allocation.go index 1083d51..29b6ee3 100644 --- a/internal/model/dto/shop_package_allocation.go +++ b/internal/model/dto/shop_package_allocation.go @@ -1,24 +1,23 @@ package dto -// CreateShopPackageAllocationRequest 创建单套餐分配请求 type CreateShopPackageAllocationRequest struct { ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` PackageID uint `json:"package_id" validate:"required" required:"true" description:"套餐ID"` - CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"覆盖的成本价(分)"` + CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"该代理的成本价(分)"` } -// UpdateShopPackageAllocationRequest 更新单套餐分配请求 type UpdateShopPackageAllocationRequest struct { - CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"覆盖的成本价(分)"` + CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"该代理的成本价(分)"` } -// ShopPackageAllocationListRequest 单套餐分配列表请求 type ShopPackageAllocationListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"` - PackageID *uint `json:"package_id" query:"package_id" validate:"omitempty" description:"套餐ID"` - Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"` + PackageID *uint `json:"package_id" query:"package_id" validate:"omitempty" description:"套餐ID"` + SeriesAllocationID *uint `json:"series_allocation_id" query:"series_allocation_id" validate:"omitempty" description:"系列分配ID"` + AllocatorShopID *uint `json:"allocator_shop_id" query:"allocator_shop_id" validate:"omitempty" description:"分配者店铺ID"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"` } // UpdateShopPackageAllocationStatusRequest 更新单套餐分配状态请求 @@ -26,23 +25,25 @@ type UpdateShopPackageAllocationStatusRequest struct { Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"` } -// ShopPackageAllocationResponse 单套餐分配响应 type ShopPackageAllocationResponse struct { - ID uint `json:"id" description:"分配ID"` - ShopID uint `json:"shop_id" description:"被分配的店铺ID"` - ShopName string `json:"shop_name" description:"被分配的店铺名称"` - PackageID uint `json:"package_id" description:"套餐ID"` - PackageName string `json:"package_name" description:"套餐名称"` - PackageCode string `json:"package_code" description:"套餐编码"` - AllocationID uint `json:"allocation_id" description:"关联的系列分配ID"` - CostPrice int64 `json:"cost_price" description:"覆盖的成本价(分)"` - CalculatedCostPrice int64 `json:"calculated_cost_price" description:"原计算成本价(分),供参考"` - Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"分配ID"` + ShopID uint `json:"shop_id" description:"被分配的店铺ID"` + ShopName string `json:"shop_name" description:"被分配的店铺名称"` + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + PackageCode string `json:"package_code" description:"套餐编码"` + SeriesID uint `json:"series_id" description:"套餐系列ID"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + SeriesAllocationID *uint `json:"series_allocation_id" description:"关联的系列分配ID"` + AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID,0表示平台分配"` + AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` + CostPrice int64 `json:"cost_price" description:"该代理的成本价(分)"` + Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } -// ShopPackageAllocationPageResult 单套餐分配分页结果 +// ShopPackageAllocationPageResult 套餐分配分页结果 type ShopPackageAllocationPageResult struct { List []*ShopPackageAllocationResponse `json:"list" description:"分配列表"` Total int64 `json:"total" description:"总数"` @@ -51,13 +52,13 @@ type ShopPackageAllocationPageResult struct { TotalPages int `json:"total_pages" description:"总页数"` } -// UpdateShopPackageAllocationParams 更新单套餐分配聚合参数 +// UpdateShopPackageAllocationParams 更新套餐分配聚合参数 type UpdateShopPackageAllocationParams struct { IDReq UpdateShopPackageAllocationRequest } -// UpdateShopPackageAllocationStatusParams 更新单套餐分配状态聚合参数 +// UpdateShopPackageAllocationStatusParams 更新套餐分配状态聚合参数 type UpdateShopPackageAllocationStatusParams struct { IDReq UpdateShopPackageAllocationStatusRequest diff --git a/internal/model/dto/shop_package_batch_allocation_dto.go b/internal/model/dto/shop_package_batch_allocation_dto.go index b2473ce..a4724b2 100644 --- a/internal/model/dto/shop_package_batch_allocation_dto.go +++ b/internal/model/dto/shop_package_batch_allocation_dto.go @@ -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:"跳过数量(已存在)"` diff --git a/internal/model/dto/shop_series_allocation.go b/internal/model/dto/shop_series_allocation.go index 5b87dcb..ee2d490 100644 --- a/internal/model/dto/shop_series_allocation.go +++ b/internal/model/dto/shop_series_allocation.go @@ -1,86 +1,58 @@ package dto -// BaseCommissionConfig 基础返佣配置 -type BaseCommissionConfig struct { - Mode string `json:"mode" validate:"required,oneof=fixed percent" required:"true" description:"返佣模式 (fixed:固定金额, percent:百分比)"` - Value int64 `json:"value" validate:"required,min=0" required:"true" minimum:"0" description:"返佣值(分或千分比,如200=20%)"` -} - -// OneTimeCommissionConfig 一次性佣金配置 -type OneTimeCommissionConfig struct { - Type string `json:"type" validate:"required,oneof=fixed tiered" required:"true" description:"一次性佣金类型 (fixed:固定, tiered:梯度)"` - Trigger string `json:"trigger" validate:"required,oneof=single_recharge accumulated_recharge" required:"true" description:"触发条件 (single_recharge:单次充值, accumulated_recharge:累计充值)"` - Threshold int64 `json:"threshold" validate:"required,min=1" required:"true" minimum:"1" description:"最低阈值(分)"` - Mode string `json:"mode" validate:"omitempty,oneof=fixed percent" description:"返佣模式 (fixed:固定金额, percent:百分比) - 固定类型时必填"` - Value int64 `json:"value" validate:"omitempty,min=1" minimum:"1" description:"佣金金额(分)或比例(千分比)- 固定类型时必填"` - Tiers []OneTimeCommissionTierEntry `json:"tiers" validate:"omitempty,dive" description:"梯度档位列表 - 梯度类型时必填"` -} - -// OneTimeCommissionTierEntry 一次性佣金梯度档位条目 -type OneTimeCommissionTierEntry struct { - TierType string `json:"tier_type" validate:"required,oneof=sales_count sales_amount" required:"true" description:"梯度类型 (sales_count:销量, sales_amount:销售额)"` - Threshold int64 `json:"threshold" validate:"required,min=1" required:"true" minimum:"1" description:"梯度阈值(销量或销售额分)"` - Mode string `json:"mode" validate:"required,oneof=fixed percent" required:"true" description:"返佣模式 (fixed:固定金额, percent:百分比)"` - Value int64 `json:"value" validate:"required,min=1" required:"true" minimum:"1" description:"返佣值(分或千分比)"` -} - -// CreateShopSeriesAllocationRequest 创建套餐系列分配请求 type CreateShopSeriesAllocationRequest struct { - ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` - SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` - BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"` - EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` - OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置(启用一次性佣金时必填)"` - EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用强充(累计充值强充)"` - ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"强充金额(分,0表示使用阈值金额)"` - ForceRechargeTriggerType *int `json:"force_recharge_trigger_type,omitempty" description:"强充触发类型(1:单次充值, 2:累计充值)"` + ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` + SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` + OneTimeCommissionAmount int64 `json:"one_time_commission_amount" validate:"required,min=0" required:"true" minimum:"0" description:"该代理能拿的一次性佣金金额上限(分)"` + EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionTrigger string `json:"one_time_commission_trigger" validate:"omitempty,oneof=first_recharge accumulated_recharge" description:"一次性佣金触发类型 (first_recharge:首次充值, accumulated_recharge:累计充值)"` + OneTimeCommissionThreshold *int64 `json:"one_time_commission_threshold" validate:"omitempty,min=0" minimum:"0" description:"一次性佣金触发阈值(分)"` + EnableForceRecharge *bool `json:"enable_force_recharge" description:"是否启用强制充值"` + ForceRechargeAmount *int64 `json:"force_recharge_amount" validate:"omitempty,min=0" minimum:"0" description:"强制充值金额(分)"` + ForceRechargeTriggerType *int `json:"force_recharge_trigger_type" validate:"omitempty,oneof=1 2" description:"强充触发类型 (1:单次充值, 2:累计充值)"` } -// UpdateShopSeriesAllocationRequest 更新套餐系列分配请求 type UpdateShopSeriesAllocationRequest struct { - BaseCommission *BaseCommissionConfig `json:"base_commission" validate:"omitempty" description:"基础返佣配置"` - EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` - OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置"` - EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用强充(累计充值强充)"` - ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"强充金额(分,0表示使用阈值金额)"` - ForceRechargeTriggerType *int `json:"force_recharge_trigger_type,omitempty" description:"强充触发类型(1:单次充值, 2:累计充值)"` + OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" description:"该代理能拿的一次性佣金金额上限(分)"` + EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionTrigger *string `json:"one_time_commission_trigger" validate:"omitempty,oneof=first_recharge accumulated_recharge" description:"一次性佣金触发类型"` + OneTimeCommissionThreshold *int64 `json:"one_time_commission_threshold" validate:"omitempty,min=0" minimum:"0" description:"一次性佣金触发阈值(分)"` + EnableForceRecharge *bool `json:"enable_force_recharge" description:"是否启用强制充值"` + ForceRechargeAmount *int64 `json:"force_recharge_amount" validate:"omitempty,min=0" minimum:"0" description:"强制充值金额(分)"` + ForceRechargeTriggerType *int `json:"force_recharge_trigger_type" validate:"omitempty,oneof=1 2" description:"强充触发类型 (1:单次充值, 2:累计充值)"` + Status *int `json:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"` } -// ShopSeriesAllocationListRequest 套餐系列分配列表请求 type ShopSeriesAllocationListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"` - SeriesID *uint `json:"series_id" query:"series_id" validate:"omitempty" description:"套餐系列ID"` - Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"被分配的店铺ID"` + SeriesID *uint `json:"series_id" query:"series_id" validate:"omitempty" description:"套餐系列ID"` + AllocatorShopID *uint `json:"allocator_shop_id" query:"allocator_shop_id" validate:"omitempty" description:"分配者店铺ID"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:启用, 2:禁用)"` } -// UpdateShopSeriesAllocationStatusRequest 更新套餐系列分配状态请求 -type UpdateShopSeriesAllocationStatusRequest struct { - Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"` -} - -// ShopSeriesAllocationResponse 套餐系列分配响应 type ShopSeriesAllocationResponse struct { - ID uint `json:"id" description:"分配ID"` - ShopID uint `json:"shop_id" description:"被分配的店铺ID"` - ShopName string `json:"shop_name" description:"被分配的店铺名称"` - SeriesID uint `json:"series_id" description:"套餐系列ID"` - SeriesName string `json:"series_name" description:"套餐系列名称"` - AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"` - AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` - BaseCommission BaseCommissionConfig `json:"base_commission" description:"基础返佣配置"` - EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` - OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config,omitempty" description:"一次性佣金配置"` - EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强充"` - ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"` - ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型(1:单次充值, 2:累计充值)"` - Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"分配ID"` + ShopID uint `json:"shop_id" description:"被分配的店铺ID"` + ShopName string `json:"shop_name" description:"被分配的店铺名称"` + SeriesID uint `json:"series_id" description:"套餐系列ID"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + SeriesCode string `json:"series_code" description:"套餐系列编码"` + AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID,0表示平台分配"` + AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` + OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"该代理能拿的一次性佣金金额上限(分)"` + EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionTrigger string `json:"one_time_commission_trigger" description:"一次性佣金触发类型"` + OneTimeCommissionThreshold int64 `json:"one_time_commission_threshold" description:"一次性佣金触发阈值(分)"` + EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强制充值"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"` + ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型 (1:单次充值, 2:累计充值)"` + Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } -// ShopSeriesAllocationPageResult 套餐系列分配分页结果 type ShopSeriesAllocationPageResult struct { List []*ShopSeriesAllocationResponse `json:"list" description:"分配列表"` Total int64 `json:"total" description:"总数"` @@ -89,14 +61,7 @@ type ShopSeriesAllocationPageResult struct { TotalPages int `json:"total_pages" description:"总页数"` } -// UpdateShopSeriesAllocationParams 更新套餐系列分配聚合参数 type UpdateShopSeriesAllocationParams struct { IDReq UpdateShopSeriesAllocationRequest } - -// UpdateShopSeriesAllocationStatusParams 更新套餐系列分配状态聚合参数 -type UpdateShopSeriesAllocationStatusParams struct { - IDReq - UpdateShopSeriesAllocationStatusRequest -} diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index 4c6512d..530f79d 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -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) +} diff --git a/internal/model/package.go b/internal/model/package.go index b200b0b..1ed36e9 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -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 +} diff --git a/internal/model/shop_package_allocation.go b/internal/model/shop_package_allocation.go index 0bd24a1..bb49535 100644 --- a/internal/model/shop_package_allocation.go +++ b/internal/model/shop_package_allocation.go @@ -4,17 +4,15 @@ import ( "gorm.io/gorm" ) -// ShopPackageAllocation 店铺单套餐分配模型 -// 用于对单个套餐设置覆盖成本价,优先级高于系列级别的加价计算 -// 适用于特殊定价场景(如某个套餐给特定代理优惠价) type ShopPackageAllocation struct { gorm.Model - BaseModel `gorm:"embedded"` - ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"` - PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` - AllocationID uint `gorm:"column:allocation_id;index;not null;comment:关联的系列分配ID" json:"allocation_id"` - CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:覆盖的成本价(分)" json:"cost_price"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` + BaseModel `gorm:"embedded"` + ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"` + PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` + AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"` + CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:该代理的成本价(分)" json:"cost_price"` + SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` } // TableName 指定表名 diff --git a/internal/model/shop_series_allocation.go b/internal/model/shop_series_allocation.go index a1cac7c..00356f8 100644 --- a/internal/model/shop_series_allocation.go +++ b/internal/model/shop_series_allocation.go @@ -4,59 +4,22 @@ import ( "gorm.io/gorm" ) -// ShopSeriesAllocation 店铺套餐系列分配模型 -// 记录上级店铺为下级店铺分配的套餐系列,包含基础返佣配置和梯度返佣开关 -// 分配者只能分配自己已被分配的套餐系列,且只能分配给直属下级 type ShopSeriesAllocation struct { gorm.Model - BaseModel `gorm:"embedded"` - ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"` - SeriesID uint `gorm:"column:series_id;index;not null;comment:套餐系列ID" json:"series_id"` - AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;comment:分配者店铺ID(上级)" json:"allocator_shop_id"` - BaseCommissionMode string `gorm:"column:base_commission_mode;type:varchar(20);not null;default:percent;comment:基础返佣模式 fixed-固定金额 percent-百分比" json:"base_commission_mode"` - BaseCommissionValue int64 `gorm:"column:base_commission_value;type:bigint;not null;default:0;comment:基础返佣值(分或千分比,如200=20%)" json:"base_commission_value"` - - // 一次性佣金配置 - EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;type:boolean;not null;default:false;comment:是否启用一次性佣金" json:"enable_one_time_commission"` - OneTimeCommissionType string `gorm:"column:one_time_commission_type;type:varchar(20);comment:一次性佣金类型 fixed-固定 tiered-梯度" json:"one_time_commission_type"` - OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(30);comment:触发条件 single_recharge-单次充值 accumulated_recharge-累计充值" json:"one_time_commission_trigger"` - OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;comment:最低阈值(分)" json:"one_time_commission_threshold"` - OneTimeCommissionMode string `gorm:"column:one_time_commission_mode;type:varchar(20);comment:返佣模式 fixed-固定金额 percent-百分比" json:"one_time_commission_mode"` - OneTimeCommissionValue int64 `gorm:"column:one_time_commission_value;type:bigint;default:0;comment:佣金金额(分)或比例(千分比)" json:"one_time_commission_value"` - - // 强充配置 - EnableForceRecharge bool `gorm:"column:enable_force_recharge;type:boolean;default:false;comment:是否启用强充(累计充值时可选)" json:"enable_force_recharge"` - ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;comment:强充金额(分,0表示使用阈值金额)" json:"force_recharge_amount"` - ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;comment:强充触发类型(1:单次充值, 2:累计充值)" json:"force_recharge_trigger_type"` - - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` + BaseModel `gorm:"embedded"` + ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"` + SeriesID uint `gorm:"column:series_id;index;not null;comment:套餐系列ID" json:"series_id"` + AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"` + OneTimeCommissionAmount int64 `gorm:"column:one_time_commission_amount;type:bigint;default:0;not null;comment:该代理能拿的一次性佣金金额上限(分)" json:"one_time_commission_amount"` + EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;default:false;not null;comment:是否启用一次性佣金" json:"enable_one_time_commission"` + OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(50);comment:一次性佣金触发类型" json:"one_time_commission_trigger"` + OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;not null;comment:一次性佣金触发阈值(分)" json:"one_time_commission_threshold"` + EnableForceRecharge bool `gorm:"column:enable_force_recharge;default:false;not null;comment:是否启用强制充值" json:"enable_force_recharge"` + ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;not null;comment:强制充值金额(分)" json:"force_recharge_amount"` + ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;not null;comment:强充触发类型 1-单次充值 2-累计充值" json:"force_recharge_trigger_type"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` } -// TableName 指定表名 func (ShopSeriesAllocation) TableName() string { return "tb_shop_series_allocation" } - -// 返佣模式常量 -const ( - // CommissionModeFixed 固定金额返佣 - CommissionModeFixed = "fixed" - // CommissionModePercent 百分比返佣(千分比) - CommissionModePercent = "percent" -) - -// 一次性佣金类型常量 -const ( - // OneTimeCommissionTypeFixed 固定一次性佣金 - OneTimeCommissionTypeFixed = "fixed" - // OneTimeCommissionTypeTiered 梯度一次性佣金 - OneTimeCommissionTypeTiered = "tiered" -) - -// 一次性佣金触发类型常量 -const ( - // OneTimeCommissionTriggerSingleRecharge 单次充值触发 - OneTimeCommissionTriggerSingleRecharge = "single_recharge" - // OneTimeCommissionTriggerAccumulatedRecharge 累计充值触发 - OneTimeCommissionTriggerAccumulatedRecharge = "accumulated_recharge" -) diff --git a/internal/model/shop_series_allocation_config.go b/internal/model/shop_series_allocation_config.go deleted file mode 100644 index 28b4859..0000000 --- a/internal/model/shop_series_allocation_config.go +++ /dev/null @@ -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" -} diff --git a/internal/model/shop_series_one_time_commission_tier.go b/internal/model/shop_series_one_time_commission_tier.go deleted file mode 100644 index 8011876..0000000 --- a/internal/model/shop_series_one_time_commission_tier.go +++ /dev/null @@ -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" diff --git a/internal/routes/shop_package_allocation.go b/internal/routes/shop_package_allocation.go index 7ea74d2..225b7e4 100644 --- a/internal/routes/shop_package_allocation.go +++ b/internal/routes/shop_package_allocation.go @@ -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, diff --git a/internal/routes/shop_series_allocation.go b/internal/routes/shop_series_allocation.go index 53b19fa..95a7f06 100644 --- a/internal/routes/shop_series_allocation.go +++ b/internal/routes/shop_series_allocation.go @@ -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, - }) } diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index 86a03a7..b2dd435 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -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 { diff --git a/internal/service/commission_calculation/service_test.go b/internal/service/commission_calculation/service_test.go deleted file mode 100644 index 5989e21..0000000 --- a/internal/service/commission_calculation/service_test.go +++ /dev/null @@ -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") - } - }) - } -} diff --git a/internal/service/device/service.go b/internal/service/device/service.go index f1e496d..30d7424 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -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) diff --git a/internal/service/device/service_test.go b/internal/service/device/service_test.go deleted file mode 100644 index 76c5a5a..0000000 --- a/internal/service/device/service_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 14d4eff..ed21d6b 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -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) diff --git a/internal/service/iot_card/service_test.go b/internal/service/iot_card/service_test.go deleted file mode 100644 index d98c9b3..0000000 --- a/internal/service/iot_card/service_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 018cd1b..4cb2d1a 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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, diff --git a/internal/service/order/service_test.go b/internal/service/order/service_test.go deleted file mode 100644 index bbeea67..0000000 --- a/internal/service/order/service_test.go +++ /dev/null @@ -1,1088 +0,0 @@ -package order - -import ( - "context" - "testing" - - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/internal/model/dto" - "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" - "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/pkg/middleware" - "github.com/break/junhong_cmp_fiber/tests/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" -) - -type testEnv struct { - ctx context.Context - svc *Service - card *model.IotCard - device *model.Device - pkg *model.Package - shop *model.Shop - wallet *model.Wallet - allocation *model.ShopSeriesAllocation -} - -func setupOrderTestEnv(t *testing.T) *testEnv { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - packageStore := postgres.NewPackageStore(tx) - seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - packageSeriesStore := postgres.NewPackageSeriesStore(tx) - carrierStore := postgres.NewCarrierStore(tx) - shopStore := postgres.NewShopStore(tx, rdb) - orderStore := postgres.NewOrderStore(tx, rdb) - orderItemStore := postgres.NewOrderItemStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - - ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePlatform, - }) - - carrier := &model.Carrier{ - CarrierCode: "TEST_CARRIER_ORDER", - CarrierName: "测试运营商", - CarrierType: constants.CarrierTypeCMCC, - Status: constants.StatusEnabled, - } - require.NoError(t, carrierStore.Create(ctx, carrier)) - - shop := &model.Shop{ - ShopName: "测试店铺ORDER", - ShopCode: "TEST_SHOP_ORDER", - 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_ORDER", - 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_ORDER", - PackageName: "测试套餐", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 1, - DataAmountMB: 1024, - 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: "89860000000000000002", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card)) - - device := &model.Device{ - DeviceNo: "DEV_TEST_ORDER_001", - ShopID: shopIDPtr, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, deviceStore.Create(ctx, device)) - - wallet := &model.Wallet{ - ResourceType: "shop", - ResourceID: shop.ID, - WalletType: "main", - Balance: 100000, - Version: 1, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, tx.Create(wallet).Error) - - purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) - logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) - - userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypeAgent, - ShopID: shop.ID, - }) - - return &testEnv{ - ctx: userCtx, - svc: orderSvc, - card: card, - device: device, - pkg: pkg, - shop: shop, - wallet: wallet, - allocation: allocation, - } -} - -func TestOrderService_Create(t *testing.T) { - env := setupOrderTestEnv(t) - - t.Run("创建单卡订单成功", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - assert.NotZero(t, resp.ID) - assert.Contains(t, resp.OrderNo, "ORD") - assert.Equal(t, model.OrderTypeSingleCard, resp.OrderType) - assert.Equal(t, model.BuyerTypeAgent, resp.BuyerType) - assert.Equal(t, env.shop.ID, resp.BuyerID) - assert.Equal(t, env.pkg.SuggestedRetailPrice, resp.TotalAmount) - assert.Equal(t, model.PaymentStatusPending, resp.PaymentStatus) - assert.Len(t, resp.Items, 1) - }) - - t.Run("创建设备订单成功", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeDevice, - DeviceID: &env.device.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - assert.NotZero(t, resp.ID) - assert.Equal(t, model.OrderTypeDevice, resp.OrderType) - }) - - t.Run("单卡订单缺少卡ID", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - _, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeInvalidParam, appErr.Code) - }) - - t.Run("设备订单缺少设备ID", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeDevice, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - _, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - 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) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - assert.Equal(t, model.PaymentStatusPending, resp.PaymentStatus) - assert.False(t, resp.IsPurchaseOnBehalf) - }) - - t.Run("线下支付创建订单", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodOffline, - } - - platformCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePlatform, - }) - - resp, err := env.svc.Create(platformCtx, req, "", 0) - require.NoError(t, err) - assert.Equal(t, model.PaymentStatusPaid, resp.PaymentStatus) - assert.True(t, resp.IsPurchaseOnBehalf) - assert.NotNil(t, resp.PaidAt) - }) -} - -func TestOrderService_Get(t *testing.T) { - env := setupOrderTestEnv(t) - - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - t.Run("获取订单成功", func(t *testing.T) { - resp, err := env.svc.Get(env.ctx, created.ID) - require.NoError(t, err) - assert.Equal(t, created.OrderNo, resp.OrderNo) - assert.Len(t, resp.Items, 1) - }) - - t.Run("订单不存在", func(t *testing.T) { - _, err := env.svc.Get(env.ctx, 99999) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeNotFound, appErr.Code) - }) -} - -func TestOrderService_List(t *testing.T) { - env := setupOrderTestEnv(t) - - for i := 0; i < 3; i++ { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - _, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - } - - t.Run("列表查询", func(t *testing.T) { - listReq := &dto.OrderListRequest{ - Page: 1, - PageSize: 10, - } - resp, err := env.svc.List(env.ctx, listReq, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - assert.GreaterOrEqual(t, resp.Total, int64(3)) - assert.GreaterOrEqual(t, len(resp.List), 3) - }) - - t.Run("按支付状态过滤", func(t *testing.T) { - status := model.PaymentStatusPending - listReq := &dto.OrderListRequest{ - Page: 1, - PageSize: 10, - PaymentStatus: &status, - } - resp, err := env.svc.List(env.ctx, listReq, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - for _, o := range resp.List { - assert.Equal(t, model.PaymentStatusPending, o.PaymentStatus) - } - }) -} - -func TestOrderService_Cancel(t *testing.T) { - env := setupOrderTestEnv(t) - - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - t.Run("取消订单成功", func(t *testing.T) { - err := env.svc.Cancel(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - order, err := env.svc.Get(env.ctx, created.ID) - require.NoError(t, err) - assert.Equal(t, model.PaymentStatusCancelled, order.PaymentStatus) - }) - - t.Run("订单不存在", func(t *testing.T) { - err := env.svc.Cancel(env.ctx, 99999, model.BuyerTypeAgent, env.shop.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) { - newReq := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - newOrder, err := env.svc.Create(env.ctx, newReq, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.Cancel(env.ctx, newOrder.ID, model.BuyerTypeAgent, 99999) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeForbidden, appErr.Code) - }) -} - -func TestOrderService_WalletPay(t *testing.T) { - env := setupOrderTestEnv(t) - - t.Run("钱包支付成功", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - order, err := env.svc.Get(env.ctx, created.ID) - require.NoError(t, err) - assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus) - assert.Equal(t, model.PaymentMethodWallet, order.PaymentMethod) - assert.NotNil(t, order.PaidAt) - }) - - t.Run("订单不存在", func(t *testing.T) { - err := env.svc.WalletPay(env.ctx, 99999, model.BuyerTypeAgent, env.shop.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) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, 99999) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeForbidden, appErr.Code) - }) - - t.Run("重复支付-幂等成功", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - }) - - t.Run("已取消订单无法支付", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.Cancel(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeInvalidStatus, appErr.Code) - }) -} - -func TestOrderService_HandlePaymentCallback(t *testing.T) { - env := setupOrderTestEnv(t) - - t.Run("支付回调成功", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodWechat) - require.NoError(t, err) - - order, err := env.svc.Get(env.ctx, created.ID) - require.NoError(t, err) - assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus) - assert.Equal(t, model.PaymentMethodWechat, order.PaymentMethod) - }) - - t.Run("幂等处理-已支付订单", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) - require.NoError(t, err) - - err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodAlipay) - require.NoError(t, err) - - err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodAlipay) - require.NoError(t, err) - }) - - t.Run("订单不存在", func(t *testing.T) { - err := env.svc.HandlePaymentCallback(env.ctx, "NOT_EXISTS_ORDER_NO", model.PaymentMethodWechat) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeNotFound, appErr.Code) - }) -} - -func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - packageStore := postgres.NewPackageStore(tx) - seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - packageSeriesStore := postgres.NewPackageSeriesStore(tx) - carrierStore := postgres.NewCarrierStore(tx) - shopStore := postgres.NewShopStore(tx, rdb) - orderStore := postgres.NewOrderStore(tx, rdb) - orderItemStore := postgres.NewOrderItemStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - - ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePlatform, - }) - - carrier := &model.Carrier{ - CarrierCode: "TEST_CARRIER_IDEM", - CarrierName: "测试运营商幂等", - CarrierType: constants.CarrierTypeCMCC, - Status: constants.StatusEnabled, - } - require.NoError(t, carrierStore.Create(ctx, carrier)) - - shop := &model.Shop{ - ShopName: "测试店铺IDEM", - ShopCode: "TEST_SHOP_IDEM", - 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_IDEM", - 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_IDEM", - PackageName: "测试套餐幂等", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 1, - DataAmountMB: 1024, - 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: "89860000000000000099", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card)) - - wallet := &model.Wallet{ - ResourceType: "shop", - ResourceID: shop.ID, - WalletType: "main", - Balance: 1000000, - Version: 1, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, tx.Create(wallet).Error) - - purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) - logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) - - userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypeAgent, - ShopID: shop.ID, - }) - - t.Run("串行幂等支付测试", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.WalletPay(userCtx, created.ID, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.WalletPay(userCtx, created.ID, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.WalletPay(userCtx, created.ID, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - var usageCount int64 - err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", created.ID).Count(&usageCount).Error - require.NoError(t, err) - assert.Equal(t, int64(1), usageCount) - - order, err := orderSvc.Get(userCtx, created.ID) - require.NoError(t, err) - assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus) - }) - - t.Run("串行回调幂等测试", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat) - require.NoError(t, err) - - err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat) - require.NoError(t, err) - - err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat) - require.NoError(t, err) - - var usageCount int64 - err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", created.ID).Count(&usageCount).Error - require.NoError(t, err) - assert.Equal(t, int64(1), usageCount) - }) - - t.Run("已取消订单回调测试", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.Cancel(userCtx, created.ID, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeInvalidStatus, appErr.Code) - - var usageCount int64 - err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", created.ID).Count(&usageCount).Error - require.NoError(t, err) - assert.Equal(t, int64(0), usageCount) - }) -} - -func TestOrderService_ForceRechargeValidation(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - packageStore := postgres.NewPackageStore(tx) - seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - packageSeriesStore := postgres.NewPackageSeriesStore(tx) - carrierStore := postgres.NewCarrierStore(tx) - shopStore := postgres.NewShopStore(tx, rdb) - orderStore := postgres.NewOrderStore(tx, rdb) - orderItemStore := postgres.NewOrderItemStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - - ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePlatform, - }) - - carrier := &model.Carrier{ - CarrierCode: "TEST_CARRIER_FR", - CarrierName: "测试运营商强充", - CarrierType: constants.CarrierTypeCMCC, - Status: constants.StatusEnabled, - } - require.NoError(t, carrierStore.Create(ctx, carrier)) - - shop := &model.Shop{ - ShopName: "测试店铺FR", - ShopCode: "TEST_SHOP_FR", - Level: 1, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, shopStore.Create(ctx, shop)) - - series := &model.PackageSeries{ - SeriesCode: "TEST_SERIES_FR", - SeriesName: "测试套餐系列强充", - Description: "测试用", - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageSeriesStore.Create(ctx, series)) - - allocation := &model.ShopSeriesAllocation{ - ShopID: shop.ID, - SeriesID: series.ID, - Status: constants.StatusEnabled, - EnableOneTimeCommission: true, - OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge, - OneTimeCommissionThreshold: 20000, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) - - cheapPkg := &model.Package{ - PackageCode: "TEST_PKG_CHEAP", - PackageName: "便宜套餐", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 1, - DataAmountMB: 512, - SuggestedRetailPrice: 5000, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageStore.Create(ctx, cheapPkg)) - - expensivePkg := &model.Package{ - PackageCode: "TEST_PKG_EXP", - PackageName: "高价套餐", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 12, - DataAmountMB: 10240, - SuggestedRetailPrice: 25000, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageStore.Create(ctx, expensivePkg)) - - shopIDPtr := &shop.ID - card := &model.IotCard{ - ICCID: "89860000000000000FR1", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - FirstCommissionPaid: false, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card)) - - purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) - logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) - - userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypeAgent, - ShopID: shop.ID, - }) - - t.Run("强充验证-金额不足拒绝", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{cheapPkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - _, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeForceRechargeRequired, appErr.Code) - }) - - t.Run("强充验证-金额足够通过", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{expensivePkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - assert.NotZero(t, resp.ID) - assert.Equal(t, expensivePkg.SuggestedRetailPrice, resp.TotalAmount) - }) - - t.Run("已付佣金-跳过强充验证", func(t *testing.T) { - card2 := &model.IotCard{ - ICCID: "89860000000000000FR2", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - FirstCommissionPaid: true, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card2)) - - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card2.ID, - PackageIDs: []uint{cheapPkg.ID}, - PaymentMethod: model.PaymentMethodWallet, - } - - resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - assert.NotZero(t, resp.ID) - }) -} - -func TestOrderService_GetPurchaseCheck(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - packageStore := postgres.NewPackageStore(tx) - seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - packageSeriesStore := postgres.NewPackageSeriesStore(tx) - carrierStore := postgres.NewCarrierStore(tx) - shopStore := postgres.NewShopStore(tx, rdb) - orderStore := postgres.NewOrderStore(tx, rdb) - orderItemStore := postgres.NewOrderItemStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - - ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePlatform, - }) - - carrier := &model.Carrier{ - CarrierCode: "TEST_CARRIER_PC", - CarrierName: "测试运营商预检", - CarrierType: constants.CarrierTypeCMCC, - Status: constants.StatusEnabled, - } - require.NoError(t, carrierStore.Create(ctx, carrier)) - - shop := &model.Shop{ - ShopName: "测试店铺PC", - ShopCode: "TEST_SHOP_PC", - Level: 1, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, shopStore.Create(ctx, shop)) - - series := &model.PackageSeries{ - SeriesCode: "TEST_SERIES_PC", - SeriesName: "测试套餐系列预检", - Description: "测试用", - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageSeriesStore.Create(ctx, series)) - - allocation := &model.ShopSeriesAllocation{ - ShopID: shop.ID, - SeriesID: series.ID, - Status: constants.StatusEnabled, - EnableOneTimeCommission: true, - OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge, - OneTimeCommissionThreshold: 10000, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) - - pkg := &model.Package{ - PackageCode: "TEST_PKG_PC", - PackageName: "测试套餐预检", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 1, - DataAmountMB: 1024, - SuggestedRetailPrice: 5000, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageStore.Create(ctx, pkg)) - - shopIDPtr := &shop.ID - card := &model.IotCard{ - ICCID: "89860000000000000PC1", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - FirstCommissionPaid: false, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card)) - - purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) - logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) - - t.Run("预检-需要强充", func(t *testing.T) { - req := &dto.PurchaseCheckRequest{ - OrderType: model.OrderTypeSingleCard, - ResourceID: card.ID, - PackageIDs: []uint{pkg.ID}, - } - - resp, err := orderSvc.GetPurchaseCheck(ctx, req) - require.NoError(t, err) - assert.Equal(t, pkg.SuggestedRetailPrice, resp.TotalPackageAmount) - assert.True(t, resp.NeedForceRecharge) - assert.Equal(t, allocation.OneTimeCommissionThreshold, resp.ForceRechargeAmount) - assert.Equal(t, allocation.OneTimeCommissionThreshold, resp.ActualPayment) - assert.NotEmpty(t, resp.Message) - }) - - t.Run("预检-无需强充", func(t *testing.T) { - card2 := &model.IotCard{ - ICCID: "89860000000000000PC2", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - FirstCommissionPaid: true, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card2)) - - req := &dto.PurchaseCheckRequest{ - OrderType: model.OrderTypeSingleCard, - ResourceID: card2.ID, - PackageIDs: []uint{pkg.ID}, - } - - resp, err := orderSvc.GetPurchaseCheck(ctx, req) - require.NoError(t, err) - assert.Equal(t, pkg.SuggestedRetailPrice, resp.TotalPackageAmount) - assert.False(t, resp.NeedForceRecharge) - assert.Equal(t, pkg.SuggestedRetailPrice, resp.ActualPayment) - assert.Empty(t, resp.Message) - }) -} - -func TestOrderService_WalletPay_PurchaseOnBehalf(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - packageStore := postgres.NewPackageStore(tx) - seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - packageSeriesStore := postgres.NewPackageSeriesStore(tx) - carrierStore := postgres.NewCarrierStore(tx) - shopStore := postgres.NewShopStore(tx, rdb) - orderStore := postgres.NewOrderStore(tx, rdb) - orderItemStore := postgres.NewOrderItemStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - - ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePlatform, - }) - - carrier := &model.Carrier{ - CarrierCode: "TEST_CARRIER_WP", - CarrierName: "测试运营商WP", - CarrierType: constants.CarrierTypeCMCC, - Status: constants.StatusEnabled, - } - require.NoError(t, carrierStore.Create(ctx, carrier)) - - shop := &model.Shop{ - ShopName: "测试店铺WP", - ShopCode: "TEST_SHOP_WP", - Level: 1, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, shopStore.Create(ctx, shop)) - - series := &model.PackageSeries{ - SeriesCode: "TEST_SERIES_WP", - SeriesName: "测试套餐系列WP", - Description: "测试用", - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageSeriesStore.Create(ctx, series)) - - allocation := &model.ShopSeriesAllocation{ - ShopID: shop.ID, - SeriesID: series.ID, - AllocatorShopID: 0, - BaseCommissionMode: model.CommissionModePercent, - BaseCommissionValue: 100, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) - - pkg := &model.Package{ - PackageCode: "TEST_PKG_WP", - PackageName: "测试套餐WP", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 1, - DataAmountMB: 1024, - SuggestedRetailPrice: 10000, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageStore.Create(ctx, pkg)) - - wallet := &model.Wallet{ - ResourceType: "shop", - ResourceID: shop.ID, - WalletType: "main", - Balance: 100000, - Version: 1, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, tx.Create(wallet).Error) - - shopIDPtr := &shop.ID - card := &model.IotCard{ - ICCID: "89860000000000000WP1", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesID: &allocation.SeriesID, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card)) - - purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) - logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) - - t.Run("代购订单无法进行钱包支付", func(t *testing.T) { - req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, - PaymentMethod: model.PaymentMethodOffline, - } - - created, err := orderSvc.Create(ctx, req, model.BuyerTypeAgent, shop.ID) - require.NoError(t, err) - - err = orderSvc.WalletPay(ctx, created.ID, model.BuyerTypeAgent, shop.ID) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeInvalidStatus, appErr.Code) - }) -} diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 911c0a8..8546fcc 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -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 } diff --git a/internal/service/package/service_test.go b/internal/service/package/service_test.go index a9815e5..a50ce2d 100644 --- a/internal/service/package/service_test.go +++ b/internal/service/package/service_test.go @@ -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) diff --git a/internal/service/package_series/service.go b/internal/service/package_series/service.go index 7e86775..ad0443d 100644 --- a/internal/service/package_series/service.go +++ b/internal/service/package_series/service.go @@ -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, } } diff --git a/internal/service/purchase_validation/service.go b/internal/service/purchase_validation/service.go index 7012cf2..83e5ab2 100644 --- a/internal/service/purchase_validation/service.go +++ b/internal/service/purchase_validation/service.go @@ -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) { diff --git a/internal/service/purchase_validation/service_test.go b/internal/service/purchase_validation/service_test.go deleted file mode 100644 index ebffc4b..0000000 --- a/internal/service/purchase_validation/service_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/recharge/service.go b/internal/service/recharge/service.go index 15350c8..249a9c9 100644 --- a/internal/service/recharge/service.go +++ b/internal/service/recharge/service.go @@ -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 { diff --git a/internal/service/recharge/service_test.go b/internal/service/recharge/service_test.go deleted file mode 100644 index 1714dae..0000000 --- a/internal/service/recharge/service_test.go +++ /dev/null @@ -1,1487 +0,0 @@ -package recharge - -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/pkg/constants" - "github.com/break/junhong_cmp_fiber/tests/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - "gorm.io/gorm" -) - -// setupTestService 创建测试用的 Service 实例 -func setupTestService(t *testing.T) (*Service, *gorm.DB) { - t.Helper() - - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - logger, _ := zap.NewDevelopment() - - // 创建各个 Store - rechargeStore := postgres.NewRechargeStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - walletTransactionStore := postgres.NewWalletTransactionStore(tx, rdb) - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - commissionRecordStore := postgres.NewCommissionRecordStore(tx, rdb) - - service := New( - tx, - rechargeStore, - walletStore, - walletTransactionStore, - iotCardStore, - deviceStore, - shopSeriesAllocationStore, - commissionRecordStore, - logger, - ) - - return service, tx -} - -// createTestIotCard 创建测试用 IoT 卡 -func createTestIotCard(t *testing.T, tx *gorm.DB, shopID *uint, seriesAllocationID *uint) *model.IotCard { - t.Helper() - timestamp := time.Now().UnixNano() - card := &model.IotCard{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - ICCID: fmt.Sprintf("89860%014d", timestamp%100000000000000), - CardCategory: "normal", - CarrierID: 1, - CarrierType: "CMCC", - CarrierName: "中国移动", - Status: 1, - ShopID: shopID, - SeriesID: seriesAllocationID, - } - require.NoError(t, tx.Create(card).Error) - return card -} - -// createTestDevice 创建测试用设备 -func createTestDevice(t *testing.T, tx *gorm.DB, shopID *uint, seriesAllocationID *uint) *model.Device { - t.Helper() - timestamp := time.Now().UnixNano() - device := &model.Device{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - DeviceNo: fmt.Sprintf("DEV%014d", timestamp%100000000000000), - DeviceName: "测试设备", - DeviceType: "GPS", - Status: 1, - ShopID: shopID, - SeriesID: seriesAllocationID, - } - require.NoError(t, tx.Create(device).Error) - return device -} - -// createTestWallet 创建测试用钱包 -func createTestWallet(t *testing.T, tx *gorm.DB, resourceType string, resourceID uint, walletType string) *model.Wallet { - t.Helper() - wallet := &model.Wallet{ - ResourceType: resourceType, - ResourceID: resourceID, - WalletType: walletType, - Balance: 0, - Currency: "CNY", - Status: 1, - Version: 0, - } - require.NoError(t, tx.Create(wallet).Error) - return wallet -} - -// createTestShop 创建测试用店铺 -func createTestShop(t *testing.T, tx *gorm.DB) *model.Shop { - t.Helper() - timestamp := time.Now().UnixNano() - shop := &model.Shop{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - ShopName: fmt.Sprintf("测试店铺%d", timestamp%10000), - ShopCode: fmt.Sprintf("SHOP%d", timestamp%1000000), - Level: 1, - ContactName: "测试联系人", - ContactPhone: fmt.Sprintf("138%08d", timestamp%100000000), - Status: 1, - } - require.NoError(t, tx.Create(shop).Error) - return shop -} - -// createTestSeriesAllocation 创建测试用系列分配 -func createTestSeriesAllocation(t *testing.T, tx *gorm.DB, shopID uint, enableOneTime bool, trigger string, threshold int64, enableForceRecharge bool, forceAmount int64) *model.ShopSeriesAllocation { - t.Helper() - allocation := &model.ShopSeriesAllocation{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - ShopID: shopID, - SeriesID: 1, - AllocatorShopID: 0, - BaseCommissionMode: "percent", - BaseCommissionValue: 100, - EnableOneTimeCommission: enableOneTime, - OneTimeCommissionType: "fixed", - OneTimeCommissionTrigger: trigger, - OneTimeCommissionThreshold: threshold, - OneTimeCommissionMode: "fixed", - OneTimeCommissionValue: 5000, // 50元佣金 - EnableForceRecharge: enableForceRecharge, - ForceRechargeAmount: forceAmount, - Status: 1, - } - require.NoError(t, tx.Create(allocation).Error) - return allocation -} - -// TestService_Create 测试创建充值订单 -func TestService_Create(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("成功创建充值订单_iot_card", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建充值订单 - req := &dto.CreateRechargeRequest{ - ResourceType: "iot_card", - ResourceID: card.ID, - Amount: 10000, // 100元 - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(10000), resp.Amount) - assert.Equal(t, "wechat", resp.PaymentMethod) - assert.Equal(t, constants.RechargeStatusPending, resp.Status) - assert.True(t, len(resp.RechargeNo) > 0) - assert.Contains(t, resp.RechargeNo, "RCH") - }) - - t.Run("成功创建充值订单_device", func(t *testing.T) { - // 准备测试数据 - device := createTestDevice(t, tx, nil, nil) - createTestWallet(t, tx, "device", device.ID, "main") - - // 创建充值订单 - req := &dto.CreateRechargeRequest{ - ResourceType: "device", - ResourceID: device.ID, - Amount: 5000, // 50元 - PaymentMethod: "alipay", - } - - resp, err := service.Create(ctx, req, 1) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(5000), resp.Amount) - assert.Equal(t, "alipay", resp.PaymentMethod) - }) - - t.Run("金额低于最小值", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - createTestWallet(t, tx, "iot_card", card.ID, "main") - - req := &dto.CreateRechargeRequest{ - ResourceType: "iot_card", - ResourceID: card.ID, - Amount: 50, // 0.5元,低于1元 - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "充值金额不能低于1元") - }) - - t.Run("金额超过最大值", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - createTestWallet(t, tx, "iot_card", card.ID, "main") - - req := &dto.CreateRechargeRequest{ - ResourceType: "iot_card", - ResourceID: card.ID, - Amount: 20000000, // 200000元,超过100000元 - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "充值金额不能超过100000元") - }) - - t.Run("无效的资源类型", func(t *testing.T) { - req := &dto.CreateRechargeRequest{ - ResourceType: "invalid", - ResourceID: 1, - Amount: 10000, - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "无效的资源类型") - }) - - t.Run("钱包不存在", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - // 不创建钱包 - - req := &dto.CreateRechargeRequest{ - ResourceType: "iot_card", - ResourceID: card.ID, - Amount: 10000, - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "钱包不存在") - }) - - t.Run("强充金额不匹配_单次充值", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - createTestWallet(t, tx, "iot_card", card.ID, "main") - - req := &dto.CreateRechargeRequest{ - ResourceType: "iot_card", - ResourceID: card.ID, - Amount: 5000, // 50元,但需要100元 - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "强充要求") - }) - - t.Run("强充金额匹配成功_单次充值", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - createTestWallet(t, tx, "iot_card", card.ID, "main") - - req := &dto.CreateRechargeRequest{ - ResourceType: "iot_card", - ResourceID: card.ID, - Amount: 10000, // 100元,符合要求 - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(10000), resp.Amount) - }) -} - -// TestService_GetRechargeCheck 测试充值预检 -func TestService_GetRechargeCheck(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("无强充要求_无系列分配", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - - result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - assert.Equal(t, int64(constants.RechargeMinAmount), result.MinAmount) - assert.Equal(t, int64(constants.RechargeMaxAmount), result.MaxAmount) - }) - - t.Run("需要强充_单次充值触发", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(10000), result.ForceRechargeAmount) - assert.Equal(t, "single_recharge", result.TriggerType) - }) - - t.Run("需要强充_累计充值启用强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, true, 10000) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(10000), result.ForceRechargeAmount) // 使用配置的强充金额 - assert.Equal(t, "accumulated_recharge", result.TriggerType) - }) - - t.Run("无强充要求_累计充值未启用强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - assert.Contains(t, result.Message, "可自由充值") - }) - - t.Run("无效的资源类型", func(t *testing.T) { - result, err := service.GetRechargeCheck(ctx, "invalid", 1) - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "无效的资源类型") - }) - - t.Run("资源不存在", func(t *testing.T) { - result, err := service.GetRechargeCheck(ctx, "iot_card", 999999) - assert.Error(t, err) - assert.Nil(t, result) - }) -} - -// TestService_GetByID 测试根据ID查询充值订单 -func TestService_GetByID(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("成功查询", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建充值订单 - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d", timestamp), - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 查询 - resp, err := service.GetByID(ctx, recharge.ID, 1) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, recharge.ID, resp.ID) - assert.Equal(t, int64(10000), resp.Amount) - }) - - t.Run("订单不存在", func(t *testing.T) { - resp, err := service.GetByID(ctx, 999999, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "充值订单不存在") - }) - - t.Run("无权查看他人订单", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建用户1的订单 - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d", timestamp), - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 用户2尝试查询 - resp, err := service.GetByID(ctx, recharge.ID, 2) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "无权查看") - }) -} - -// TestService_List 测试查询充值订单列表 -func TestService_List(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("查询用户订单列表", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建多个订单 - for i := 0; i < 3; i++ { - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 100, - Updater: 100, - }, - UserID: 100, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d%d", timestamp, i), - Amount: int64((i + 1) * 1000), - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - } - - // 查询 - req := &dto.RechargeListRequest{ - Page: 1, - PageSize: 10, - } - - resp, err := service.List(ctx, req, 100) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(3), resp.Total) - assert.Len(t, resp.List, 3) - }) - - t.Run("状态筛选", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建不同状态的订单 - for i, status := range []int{constants.RechargeStatusPending, constants.RechargeStatusPaid, constants.RechargeStatusCompleted} { - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 101, - Updater: 101, - }, - UserID: 101, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d%d", timestamp, i), - Amount: 10000, - PaymentMethod: "wechat", - Status: status, - } - require.NoError(t, tx.Create(recharge).Error) - } - - // 筛选待支付 - pendingStatus := constants.RechargeStatusPending - req := &dto.RechargeListRequest{ - Page: 1, - PageSize: 10, - Status: &pendingStatus, - } - - resp, err := service.List(ctx, req, 101) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(1), resp.Total) - }) -} - -// TestService_HandlePaymentCallback 测试支付回调处理 -func TestService_HandlePaymentCallback(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("成功处理支付回调_无佣金", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证订单状态 - var updatedRecharge model.RechargeRecord - err = tx.First(&updatedRecharge, recharge.ID).Error - require.NoError(t, err) - assert.Equal(t, constants.RechargeStatusCompleted, updatedRecharge.Status) - - // 验证钱包余额 - var updatedWallet model.Wallet - err = tx.First(&updatedWallet, wallet.ID).Error - require.NoError(t, err) - assert.Equal(t, int64(10000), updatedWallet.Balance) - }) - - t.Run("幂等性_已支付订单重复回调", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - wallet.Balance = 10000 - tx.Save(wallet) - - // 创建已完成订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusCompleted, - } - require.NoError(t, tx.Create(recharge).Error) - - // 重复处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) // 应该成功(幂等) - - // 验证钱包余额没有变化 - var updatedWallet model.Wallet - err = tx.First(&updatedWallet, wallet.ID).Error - require.NoError(t, err) - assert.Equal(t, int64(10000), updatedWallet.Balance) // 余额不变 - }) - - t.Run("订单不存在", func(t *testing.T) { - err := service.HandlePaymentCallback(ctx, "RCH_NOT_EXISTS", "wechat", "TX123456") - assert.Error(t, err) - assert.Contains(t, err.Error(), "充值订单不存在") - }) - - t.Run("订单状态不允许支付", func(t *testing.T) { - // 准备测试数据 - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建已关闭订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusClosed, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - assert.Error(t, err) - assert.Contains(t, err.Error(), "订单状态不允许支付") - }) - - t.Run("成功处理支付回调_触发一次性佣金", func(t *testing.T) { - // 准备测试数据 - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - createTestWallet(t, tx, "shop", shop.ID, "commission") // 店铺佣金钱包 - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, // 符合阈值 - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证卡的 FirstCommissionPaid 已更新 - var updatedCard model.IotCard - err = tx.First(&updatedCard, card.ID).Error - require.NoError(t, err) - assert.True(t, updatedCard.FirstCommissionPaid) - - // 验证累计充值已更新 - assert.Equal(t, int64(10000), updatedCard.AccumulatedRecharge) - - // 验证佣金记录已创建 - var commissionRecords []model.CommissionRecord - err = tx.Where("iot_card_id = ?", card.ID).Find(&commissionRecords).Error - require.NoError(t, err) - assert.Len(t, commissionRecords, 1) - assert.Equal(t, int64(5000), commissionRecords[0].Amount) // 50元佣金 - - // 验证店铺佣金钱包余额 - var shopWallet model.Wallet - err = tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", shop.ID, "commission").First(&shopWallet).Error - require.NoError(t, err) - assert.Equal(t, int64(5000), shopWallet.Balance) - }) - - t.Run("累计充值触发佣金", func(t *testing.T) { - // 准备测试数据 - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 15000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - // 设置初始累计充值 - card.AccumulatedRecharge = 10000 - tx.Save(card) - - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - createTestWallet(t, tx, "shop", shop.ID, "commission") - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 5000, // 再充50元,累计150元,达到阈值 - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证累计充值已更新 - var updatedCard model.IotCard - err = tx.First(&updatedCard, card.ID).Error - require.NoError(t, err) - assert.Equal(t, int64(15000), updatedCard.AccumulatedRecharge) - assert.True(t, updatedCard.FirstCommissionPaid) - }) -} - -// TestService_generateRechargeNo 测试生成充值订单号 -func TestService_generateRechargeNo(t *testing.T) { - service, _ := setupTestService(t) - - t.Run("订单号格式正确", func(t *testing.T) { - rechargeNo := service.generateRechargeNo() - assert.True(t, len(rechargeNo) > 0) - assert.Contains(t, rechargeNo, "RCH") - // RCH + 14位时间戳 + 6位随机数 = 23位 - assert.Equal(t, 23, len(rechargeNo)) - }) - - t.Run("订单号唯一性", func(t *testing.T) { - nos := make(map[string]bool) - for i := 0; i < 100; i++ { - no := service.generateRechargeNo() - assert.False(t, nos[no], "订单号应唯一: %s", no) - nos[no] = true - } - }) -} - -// TestService_checkForceRechargeRequirement 测试强充验证逻辑 -func TestService_checkForceRechargeRequirement(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("一次性佣金已发放_无需强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - // 标记佣金已发放 - card.FirstCommissionPaid = true - tx.Save(card) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - assert.True(t, result.FirstCommissionPaid) - }) - - t.Run("一次性佣金未启用_无需强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, false, "", 0, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("设备资源类型", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(10000), result.ForceRechargeAmount) - }) -} - -// TestService_calculateOneTimeCommission 测试计算一次性佣金 -func TestService_calculateOneTimeCommission(t *testing.T) { - service, _ := setupTestService(t) - - t.Run("固定金额佣金", func(t *testing.T) { - allocation := &model.ShopSeriesAllocation{ - OneTimeCommissionType: "fixed", - OneTimeCommissionMode: "fixed", - OneTimeCommissionValue: 5000, // 50元 - } - - amount := service.calculateOneTimeCommission(allocation, 10000) - assert.Equal(t, int64(5000), amount) - }) - - t.Run("百分比佣金", func(t *testing.T) { - allocation := &model.ShopSeriesAllocation{ - OneTimeCommissionType: "fixed", - OneTimeCommissionMode: "percent", - OneTimeCommissionValue: 100, // 10% - } - - amount := service.calculateOneTimeCommission(allocation, 10000) - assert.Equal(t, int64(1000), amount) // 10000 * 100 / 1000 = 1000 - }) - - t.Run("梯度佣金不处理", func(t *testing.T) { - allocation := &model.ShopSeriesAllocation{ - OneTimeCommissionType: "tiered", - OneTimeCommissionMode: "fixed", - OneTimeCommissionValue: 5000, - } - - amount := service.calculateOneTimeCommission(allocation, 10000) - assert.Equal(t, int64(0), amount) // 梯度佣金返回0,由其他服务处理 - }) -} - -// TestService_GetRechargeCheck_Device 测试设备类型的充值预检 -func TestService_GetRechargeCheck_Device(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("设备资源_无强充要求", func(t *testing.T) { - device := createTestDevice(t, tx, nil, nil) - - result, err := service.GetRechargeCheck(ctx, "device", device.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - assert.Equal(t, int64(constants.RechargeMinAmount), result.MinAmount) - assert.Equal(t, int64(constants.RechargeMaxAmount), result.MaxAmount) - }) - - t.Run("设备资源_需要强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 20000, false, 0) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - - result, err := service.GetRechargeCheck(ctx, "device", device.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(20000), result.ForceRechargeAmount) - }) - - t.Run("设备资源不存在", func(t *testing.T) { - result, err := service.GetRechargeCheck(ctx, "device", 999999) - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "设备不存在") - }) -} - -// TestService_List_MoreFilters 测试更多列表筛选条件 -func TestService_List_MoreFilters(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("钱包ID筛选", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建订单 - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 200, - Updater: 200, - }, - UserID: 200, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d", timestamp), - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 按钱包ID筛选 - req := &dto.RechargeListRequest{ - Page: 1, - PageSize: 10, - WalletID: &wallet.ID, - } - - resp, err := service.List(ctx, req, 200) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(1), resp.Total) - }) - - t.Run("时间范围筛选", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建订单 - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 201, - Updater: 201, - }, - UserID: 201, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d", timestamp), - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 按时间范围筛选 - startTime := time.Now().Add(-1 * time.Hour) - endTime := time.Now().Add(1 * time.Hour) - req := &dto.RechargeListRequest{ - Page: 1, - PageSize: 10, - StartTime: &startTime, - EndTime: &endTime, - } - - resp, err := service.List(ctx, req, 201) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.GreaterOrEqual(t, resp.Total, int64(1)) - }) - - t.Run("默认分页参数", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - // 创建订单 - timestamp := time.Now().UnixNano() - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 202, - Updater: 202, - }, - UserID: 202, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d", timestamp), - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 不传分页参数 - req := &dto.RechargeListRequest{} - - resp, err := service.List(ctx, req, 202) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, 1, resp.Page) - assert.Equal(t, constants.DefaultPageSize, resp.PageSize) - }) -} - -// TestService_HandlePaymentCallback_Device 测试设备类型的支付回调 -func TestService_HandlePaymentCallback_Device(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("设备充值_触发佣金", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - wallet := createTestWallet(t, tx, "device", device.ID, "main") - createTestWallet(t, tx, "shop", shop.ID, "commission") - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证设备的 FirstCommissionPaid 已更新 - var updatedDevice model.Device - err = tx.First(&updatedDevice, device.ID).Error - require.NoError(t, err) - assert.True(t, updatedDevice.FirstCommissionPaid) - assert.Equal(t, int64(10000), updatedDevice.AccumulatedRecharge) - }) - - t.Run("设备充值_无店铺归属_跳过佣金", func(t *testing.T) { - // 创建无店铺归属的设备 - allocation := createTestSeriesAllocation(t, tx, 1, true, "single_recharge", 10000, false, 0) - device := createTestDevice(t, tx, nil, &allocation.ID) // 无店铺 - wallet := createTestWallet(t, tx, "device", device.ID, "main") - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - 应该成功但不发放佣金 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证钱包余额已更新 - var updatedWallet model.Wallet - err = tx.First(&updatedWallet, wallet.ID).Error - require.NoError(t, err) - assert.Equal(t, int64(10000), updatedWallet.Balance) - }) -} - -// TestService_buildRechargeResponse_AllStatus 测试所有状态的响应构建 -func TestService_buildRechargeResponse_AllStatus(t *testing.T) { - service, tx := setupTestService(t) - - testCases := []struct { - status int - statusText string - }{ - {constants.RechargeStatusPending, "待支付"}, - {constants.RechargeStatusPaid, "已支付"}, - {constants.RechargeStatusCompleted, "已完成"}, - {constants.RechargeStatusClosed, "已关闭"}, - {constants.RechargeStatusRefunded, "已退款"}, - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("状态_%s", tc.statusText), func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - now := time.Now() - paymentChannel := "jsapi" - paymentTxID := "TX123" - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: fmt.Sprintf("RCH%d%d", time.Now().UnixNano(), tc.status), - Amount: 10000, - PaymentMethod: "wechat", - PaymentChannel: &paymentChannel, - PaymentTransactionID: &paymentTxID, - Status: tc.status, - PaidAt: &now, - CompletedAt: &now, - } - require.NoError(t, tx.Create(recharge).Error) - - resp := service.buildRechargeResponse(recharge) - assert.Equal(t, tc.status, resp.Status) - assert.Equal(t, tc.statusText, resp.StatusText) - assert.Equal(t, "wechat", resp.PaymentMethod) - assert.NotNil(t, resp.PaymentChannel) - assert.Equal(t, "jsapi", *resp.PaymentChannel) - assert.NotNil(t, resp.PaymentTransactionID) - assert.Equal(t, "TX123", *resp.PaymentTransactionID) - assert.NotNil(t, resp.PaidAt) - assert.NotNil(t, resp.CompletedAt) - }) - } -} - -// TestService_Create_Device 测试设备类型的创建 -func TestService_Create_Device(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("设备钱包不存在", func(t *testing.T) { - device := createTestDevice(t, tx, nil, nil) - - req := &dto.CreateRechargeRequest{ - ResourceType: "device", - ResourceID: device.ID, - Amount: 10000, - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "钱包不存在") - }) - - t.Run("设备强充金额不匹配", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 20000, false, 0) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - createTestWallet(t, tx, "device", device.ID, "main") - - req := &dto.CreateRechargeRequest{ - ResourceType: "device", - ResourceID: device.ID, - Amount: 10000, - PaymentMethod: "wechat", - } - - resp, err := service.Create(ctx, req, 1) - assert.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "强充要求") - }) - - t.Run("设备成功创建充值订单", func(t *testing.T) { - device := createTestDevice(t, tx, nil, nil) - createTestWallet(t, tx, "device", device.ID, "main") - - req := &dto.CreateRechargeRequest{ - ResourceType: "device", - ResourceID: device.ID, - Amount: 10000, - PaymentMethod: "alipay", - } - - resp, err := service.Create(ctx, req, 1) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, int64(10000), resp.Amount) - assert.Equal(t, "alipay", resp.PaymentMethod) - }) -} - -// TestService_checkForceRechargeRequirement_MoreCases 测试更多强充验证场景 -func TestService_checkForceRechargeRequirement_MoreCases(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("无系列分配_无需强充", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("系列分配不存在_无需强充", func(t *testing.T) { - nonExistentID := uint(999999) - card := createTestIotCard(t, tx, nil, &nonExistentID) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("累计充值触发_启用强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, true, 15000) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(15000), result.ForceRechargeAmount) - assert.Equal(t, "accumulated_recharge", result.TriggerType) - }) - - t.Run("未知触发类型_无需强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := &model.ShopSeriesAllocation{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - ShopID: shop.ID, - SeriesID: 1, - AllocatorShopID: 0, - BaseCommissionMode: "percent", - BaseCommissionValue: 100, - EnableOneTimeCommission: true, - OneTimeCommissionType: "fixed", - OneTimeCommissionTrigger: "unknown_trigger", - OneTimeCommissionThreshold: 10000, - OneTimeCommissionMode: "fixed", - OneTimeCommissionValue: 5000, - EnableForceRecharge: false, - ForceRechargeAmount: 0, - Status: 1, - } - require.NoError(t, tx.Create(allocation).Error) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("设备_累计充值触发_启用强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, true, 15000) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(15000), result.ForceRechargeAmount) - }) - - t.Run("设备_佣金已发放_无需强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - device.FirstCommissionPaid = true - tx.Save(device) - - result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - assert.True(t, result.FirstCommissionPaid) - }) - - t.Run("设备_系列分配不存在_无需强充", func(t *testing.T) { - nonExistentID := uint(999999) - device := createTestDevice(t, tx, nil, &nonExistentID) - - result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("设备_无系列分配_无需强充", func(t *testing.T) { - device := createTestDevice(t, tx, nil, nil) - - result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("设备_一次性佣金未启用_无需强充", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, false, "", 0, false, 0) - device := createTestDevice(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) - require.NoError(t, err) - assert.False(t, result.NeedForceRecharge) - }) - - t.Run("累计充值触发_启用强充_无配置金额_使用阈值", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := &model.ShopSeriesAllocation{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - ShopID: shop.ID, - SeriesID: 1, - AllocatorShopID: 0, - BaseCommissionMode: "percent", - BaseCommissionValue: 100, - EnableOneTimeCommission: true, - OneTimeCommissionType: "fixed", - OneTimeCommissionTrigger: "accumulated_recharge", - OneTimeCommissionThreshold: 50000, - OneTimeCommissionMode: "fixed", - OneTimeCommissionValue: 5000, - EnableForceRecharge: true, - ForceRechargeAmount: 0, - Status: 1, - } - require.NoError(t, tx.Create(allocation).Error) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - - result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) - require.NoError(t, err) - assert.True(t, result.NeedForceRecharge) - assert.Equal(t, int64(50000), result.ForceRechargeAmount) - }) -} - -// TestService_HandlePaymentCallback_MoreCases 测试更多支付回调场景 -func TestService_HandlePaymentCallback_MoreCases(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("已支付状态_幂等返回成功", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPaid, - } - require.NoError(t, tx.Create(recharge).Error) - - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - }) - - t.Run("已退款状态_不允许支付", func(t *testing.T) { - card := createTestIotCard(t, tx, nil, nil) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusRefunded, - } - require.NoError(t, tx.Create(recharge).Error) - - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - assert.Error(t, err) - assert.Contains(t, err.Error(), "订单状态不允许支付") - }) -} - -// TestService_triggerOneTimeCommission_EdgeCases 测试一次性佣金触发的边界情况 -func TestService_triggerOneTimeCommission_EdgeCases(t *testing.T) { - service, tx := setupTestService(t) - ctx := context.Background() - - t.Run("佣金金额为0_不发放", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := &model.ShopSeriesAllocation{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - ShopID: shop.ID, - SeriesID: 1, - AllocatorShopID: 0, - BaseCommissionMode: "percent", - BaseCommissionValue: 100, - EnableOneTimeCommission: true, - OneTimeCommissionType: "fixed", - OneTimeCommissionTrigger: "single_recharge", - OneTimeCommissionThreshold: 10000, - OneTimeCommissionMode: "fixed", - OneTimeCommissionValue: 0, // 佣金金额为0 - EnableForceRecharge: false, - ForceRechargeAmount: 0, - Status: 1, - } - require.NoError(t, tx.Create(allocation).Error) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - createTestWallet(t, tx, "shop", shop.ID, "commission") - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证没有创建佣金记录 - var commissionRecords []model.CommissionRecord - err = tx.Where("iot_card_id = ?", card.ID).Find(&commissionRecords).Error - require.NoError(t, err) - assert.Len(t, commissionRecords, 0) - }) - - t.Run("未达到阈值_不触发佣金", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 20000, false, 0) // 阈值200元 - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - createTestWallet(t, tx, "shop", shop.ID, "commission") - - // 创建待支付订单(金额低于阈值) - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, // 100元,低于200元阈值 - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证没有创建佣金记录 - var commissionRecords []model.CommissionRecord - err = tx.Where("iot_card_id = ?", card.ID).Find(&commissionRecords).Error - require.NoError(t, err) - assert.Len(t, commissionRecords, 0) - - // 验证 FirstCommissionPaid 未更新 - var updatedCard model.IotCard - err = tx.First(&updatedCard, card.ID).Error - require.NoError(t, err) - assert.False(t, updatedCard.FirstCommissionPaid) - }) - - t.Run("店铺佣金钱包不存在_跳过佣金", func(t *testing.T) { - shop := createTestShop(t, tx) - allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) - card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) - wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") - // 不创建店铺佣金钱包 - - // 创建待支付订单 - rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) - recharge := &model.RechargeRecord{ - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - UserID: 1, - WalletID: wallet.ID, - RechargeNo: rechargeNo, - Amount: 10000, - PaymentMethod: "wechat", - Status: constants.RechargeStatusPending, - } - require.NoError(t, tx.Create(recharge).Error) - - // 处理回调 - 应该成功但不发放佣金 - err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") - require.NoError(t, err) - - // 验证钱包余额已更新 - var updatedWallet model.Wallet - err = tx.First(&updatedWallet, wallet.ID).Error - require.NoError(t, err) - assert.Equal(t, int64(10000), updatedWallet.Balance) - }) -} diff --git a/internal/service/shop_package_allocation/service.go b/internal/service/shop_package_allocation/service.go index 746eeb1..5cfd726 100644 --- a/internal/service/shop_package_allocation/service.go +++ b/internal/service/shop_package_allocation/service.go @@ -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 } diff --git a/internal/service/shop_package_batch_allocation/service.go b/internal/service/shop_package_batch_allocation/service.go index 145ee4f..378e2b6 100644 --- a/internal/service/shop_package_batch_allocation/service.go +++ b/internal/service/shop_package_batch_allocation/service.go @@ -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) } diff --git a/internal/service/shop_series_allocation/service.go b/internal/service/shop_series_allocation/service.go index 87c09c6..9df41b8 100644 --- a/internal/service/shop_series_allocation/service.go +++ b/internal/service/shop_series_allocation/service.go @@ -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) } diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index ef3e940..178bc72 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -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 +} diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index c21fce3..5c77b37 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -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 +} diff --git a/internal/store/postgres/package_series_store.go b/internal/store/postgres/package_series_store.go index 6125e22..3f85809 100644 --- a/internal/store/postgres/package_series_store.go +++ b/internal/store/postgres/package_series_store.go @@ -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 diff --git a/internal/store/postgres/package_store_test.go b/internal/store/postgres/package_store_test.go index 69e2937..57f43d8 100644 --- a/internal/store/postgres/package_store_test.go +++ b/internal/store/postgres/package_store_test.go @@ -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)) diff --git a/internal/store/postgres/shop_package_allocation_store.go b/internal/store/postgres/shop_package_allocation_store.go index 5393c46..db5e8d2 100644 --- a/internal/store/postgres/shop_package_allocation_store.go +++ b/internal/store/postgres/shop_package_allocation_store.go @@ -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 } diff --git a/internal/store/postgres/shop_package_allocation_store_test.go b/internal/store/postgres/shop_package_allocation_store_test.go index 3385aff..fa22180 100644 --- a/internal/store/postgres/shop_package_allocation_store_test.go +++ b/internal/store/postgres/shop_package_allocation_store_test.go @@ -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)) diff --git a/internal/store/postgres/shop_series_allocation_config_store.go b/internal/store/postgres/shop_series_allocation_config_store.go deleted file mode 100644 index 3dfd3f8..0000000 --- a/internal/store/postgres/shop_series_allocation_config_store.go +++ /dev/null @@ -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 -} diff --git a/internal/store/postgres/shop_series_allocation_store.go b/internal/store/postgres/shop_series_allocation_store.go index 75cab83..47f72ab 100644 --- a/internal/store/postgres/shop_series_allocation_store.go +++ b/internal/store/postgres/shop_series_allocation_store.go @@ -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 } diff --git a/internal/store/postgres/shop_series_allocation_store_test.go b/internal/store/postgres/shop_series_allocation_store_test.go deleted file mode 100644 index 36ccdd8..0000000 --- a/internal/store/postgres/shop_series_allocation_store_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/store/postgres/shop_series_commission_stats_store.go b/internal/store/postgres/shop_series_commission_stats_store.go index 7c6a6a6..a358191 100644 --- a/internal/store/postgres/shop_series_commission_stats_store.go +++ b/internal/store/postgres/shop_series_commission_stats_store.go @@ -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 +} diff --git a/internal/store/postgres/shop_series_one_time_commission_tier_store.go b/internal/store/postgres/shop_series_one_time_commission_tier_store.go deleted file mode 100644 index fbef1c9..0000000 --- a/internal/store/postgres/shop_series_one_time_commission_tier_store.go +++ /dev/null @@ -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 -} diff --git a/internal/task/commission_stats_update.go b/internal/task/commission_stats_update.go index f512949..f61dd89 100644 --- a/internal/task/commission_stats_update.go +++ b/internal/task/commission_stats_update.go @@ -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{ diff --git a/migrations/000042_refactor_commission_package_model.down.sql b/migrations/000042_refactor_commission_package_model.down.sql new file mode 100644 index 0000000..e9be475 --- /dev/null +++ b/migrations/000042_refactor_commission_package_model.down.sql @@ -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; diff --git a/migrations/000042_refactor_commission_package_model.up.sql b/migrations/000042_refactor_commission_package_model.up.sql new file mode 100644 index 0000000..b88a7ff --- /dev/null +++ b/migrations/000042_refactor_commission_package_model.up.sql @@ -0,0 +1,126 @@ +-- 套餐与佣金模型重构 +-- 重构说明: +-- 1. Package 表:移除废弃字段,新增虚流量开关 +-- 2. ShopPackageAllocation 表:新增一次性佣金金额字段 +-- 3. IoTCard/Device 表:新增按系列追踪的累计充值和首充状态字段 +-- 4. PackageSeries 表:新增一次性佣金规则配置字段 +-- 5. ShopSeriesOneTimeCommissionTier 表:新增统计范围字段 +-- 6. ShopSeriesAllocation 表:移除完整一次性佣金配置,改为只存金额 + +-- ============================================ +-- 1. Package 表调整 +-- ============================================ + +-- 移除废弃字段 +ALTER TABLE tb_package +DROP COLUMN IF EXISTS price, +DROP COLUMN IF EXISTS data_type, +DROP COLUMN IF EXISTS data_amount_mb; + +-- 新增虚流量开关字段 +ALTER TABLE tb_package +ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false NOT NULL; + +COMMENT ON COLUMN tb_package.enable_virtual_data IS '是否启用虚流量'; + +-- 重命名 suggested_cost_price 为 cost_price(如果存在的话,通过 RENAME 实现) +-- 注意:PostgreSQL 不支持条件性重命名,此处直接重命名 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tb_package' AND column_name = 'suggested_cost_price' + ) THEN + ALTER TABLE tb_package RENAME COLUMN suggested_cost_price TO cost_price; + END IF; +END $$; + +COMMENT ON COLUMN tb_package.cost_price IS '成本价(分为单位)'; + +-- ============================================ +-- 2. ShopPackageAllocation 表新增字段 +-- ============================================ + +ALTER TABLE tb_shop_package_allocation +ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0 NOT NULL; + +COMMENT ON COLUMN tb_shop_package_allocation.one_time_commission_amount IS '该代理能拿到的一次性佣金(分)'; + +-- ============================================ +-- 3. IoTCard 表新增追踪字段 +-- ============================================ + +-- 新增按系列追踪的累计充值字段(JSON Map: series_id -> amount) +ALTER TABLE tb_iot_card +ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}'; + +-- 新增按系列追踪的首充触发状态(JSON Map: series_id -> bool) +ALTER TABLE tb_iot_card +ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}'; + +COMMENT ON COLUMN tb_iot_card.accumulated_recharge_by_series IS '按套餐系列追踪的累计充值金额(JSON Map: series_id -> amount 分)'; +COMMENT ON COLUMN tb_iot_card.first_recharge_triggered_by_series IS '按套餐系列追踪的首充触发状态(JSON Map: series_id -> bool)'; + +-- ============================================ +-- 4. Device 表新增追踪字段 +-- ============================================ + +-- 新增按系列追踪的累计充值字段 +ALTER TABLE tb_device +ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}'; + +-- 新增按系列追踪的首充触发状态 +ALTER TABLE tb_device +ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}'; + +COMMENT ON COLUMN tb_device.accumulated_recharge_by_series IS '按套餐系列追踪的累计充值金额(JSON Map: series_id -> amount 分)'; +COMMENT ON COLUMN tb_device.first_recharge_triggered_by_series IS '按套餐系列追踪的首充触发状态(JSON Map: series_id -> bool)'; + +-- ============================================ +-- 5. PackageSeries 表新增一次性佣金规则配置 +-- ============================================ + +ALTER TABLE tb_package_series +ADD COLUMN IF NOT EXISTS one_time_commission_config JSONB; + +COMMENT ON COLUMN tb_package_series.one_time_commission_config IS '一次性佣金规则配置(JSON:enable, trigger_type, threshold, commission_type, commission_amount, validity_type 等)'; + +-- ============================================ +-- 6. ShopSeriesOneTimeCommissionTier 表新增统计范围 +-- ============================================ + +-- 检查表是否存在,存在则添加字段 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'tb_shop_series_one_time_commission_tier' + ) THEN + ALTER TABLE tb_shop_series_one_time_commission_tier + ADD COLUMN IF NOT EXISTS stat_scope VARCHAR(20) DEFAULT 'self' NOT NULL; + + COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.stat_scope IS '统计范围 self-仅自己 self_and_sub-自己+下级'; + END IF; +END $$; + +-- ============================================ +-- 7. ShopSeriesAllocation 表移除废弃字段,新增新字段 +-- ============================================ + +-- 移除一次性佣金完整配置字段 +ALTER TABLE tb_shop_series_allocation +DROP COLUMN IF EXISTS enable_one_time_commission, +DROP COLUMN IF EXISTS one_time_commission_type, +DROP COLUMN IF EXISTS one_time_commission_trigger, +DROP COLUMN IF EXISTS one_time_commission_threshold, +DROP COLUMN IF EXISTS one_time_commission_mode, +DROP COLUMN IF EXISTS one_time_commission_value, +DROP COLUMN IF EXISTS enable_force_recharge, +DROP COLUMN IF EXISTS force_recharge_amount, +DROP COLUMN IF EXISTS force_recharge_trigger_type; + +-- 新增一次性佣金金额字段 +ALTER TABLE tb_shop_series_allocation +ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0 NOT NULL; + +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_amount IS '给被分配店铺的一次性佣金金额(分)'; diff --git a/migrations/000043_simplify_commission_allocation.down.sql b/migrations/000043_simplify_commission_allocation.down.sql new file mode 100644 index 0000000..59ddad9 --- /dev/null +++ b/migrations/000043_simplify_commission_allocation.down.sql @@ -0,0 +1,68 @@ +-- 回滚:恢复 ShopSeriesAllocation 层 + +-- ============================================ +-- 1. 重建 ShopSeriesAllocation 表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS tb_shop_series_allocation ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + shop_id BIGINT NOT NULL, + series_id BIGINT NOT NULL, + allocator_shop_id BIGINT NOT NULL DEFAULT 0, + cost_price_markup BIGINT NOT NULL DEFAULT 0, + one_time_commission_amount BIGINT NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1 +); + +COMMENT ON TABLE tb_shop_series_allocation IS '店铺系列分配表'; +COMMENT ON COLUMN tb_shop_series_allocation.shop_id IS '被分配店铺ID'; +COMMENT ON COLUMN tb_shop_series_allocation.series_id IS '套餐系列ID'; +COMMENT ON COLUMN tb_shop_series_allocation.allocator_shop_id IS '分配者店铺ID(0表示平台分配)'; +COMMENT ON COLUMN tb_shop_series_allocation.cost_price_markup IS '成本价加价(分)'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_amount IS '一次性佣金金额(分)'; + +CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_id ON tb_shop_series_allocation(shop_id); +CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_series_id ON tb_shop_series_allocation(series_id); + +-- ============================================ +-- 2. 重建 ShopSeriesAllocationConfig 表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS tb_shop_series_allocation_config ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + allocation_id BIGINT NOT NULL, + config_type VARCHAR(50) NOT NULL, + config_value JSONB +); + +COMMENT ON TABLE tb_shop_series_allocation_config IS '店铺系列分配配置表'; + +-- ============================================ +-- 3. 恢复 allocation_id 字段 +-- ============================================ + +ALTER TABLE tb_shop_package_allocation +ADD COLUMN IF NOT EXISTS allocation_id BIGINT NOT NULL DEFAULT 0; + +-- ============================================ +-- 4. 删除新增的字段 +-- ============================================ + +DROP INDEX IF EXISTS idx_shop_package_allocation_series_id; +DROP INDEX IF EXISTS idx_shop_package_allocation_allocator_shop_id; + +ALTER TABLE tb_shop_package_allocation +DROP COLUMN IF EXISTS series_id; + +ALTER TABLE tb_shop_package_allocation +DROP COLUMN IF EXISTS allocator_shop_id; diff --git a/migrations/000043_simplify_commission_allocation.up.sql b/migrations/000043_simplify_commission_allocation.up.sql new file mode 100644 index 0000000..7ff9d94 --- /dev/null +++ b/migrations/000043_simplify_commission_allocation.up.sql @@ -0,0 +1,61 @@ +-- 简化佣金分配模型:删除 ShopSeriesAllocation 层,使用 ShopPackageAllocation 直接管理 +-- 重构原因:业务模型只需要套餐级别的分配,不需要系列级别的中间层 + +-- ============================================ +-- 1. 为 ShopPackageAllocation 添加新字段 +-- ============================================ + +-- 添加 series_id 字段(记录套餐所属系列,便于查询) +ALTER TABLE tb_shop_package_allocation +ADD COLUMN IF NOT EXISTS series_id BIGINT DEFAULT 0 NOT NULL; + +COMMENT ON COLUMN tb_shop_package_allocation.series_id IS '套餐系列ID(冗余字段,便于查询)'; + +-- 添加 allocator_shop_id 字段(记录是谁分配的,0表示平台分配) +ALTER TABLE tb_shop_package_allocation +ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0 NOT NULL; + +COMMENT ON COLUMN tb_shop_package_allocation.allocator_shop_id IS '分配者店铺ID(0表示平台分配)'; + +-- ============================================ +-- 2. 从现有数据迁移 series_id 和 allocator_shop_id +-- ============================================ + +-- 通过 package 表获取 series_id +UPDATE tb_shop_package_allocation spa +SET series_id = p.series_id +FROM tb_package p +WHERE spa.package_id = p.id AND spa.series_id = 0; + +-- 通过 shop_series_allocation 获取 allocator_shop_id +UPDATE tb_shop_package_allocation spa +SET allocator_shop_id = ssa.allocator_shop_id +FROM tb_shop_series_allocation ssa +WHERE spa.allocation_id = ssa.id AND spa.allocator_shop_id = 0; + +-- ============================================ +-- 3. 删除废弃的 allocation_id 字段 +-- ============================================ + +ALTER TABLE tb_shop_package_allocation +DROP COLUMN IF EXISTS allocation_id; + +-- ============================================ +-- 4. 添加索引优化查询性能 +-- ============================================ + +CREATE INDEX IF NOT EXISTS idx_shop_package_allocation_series_id +ON tb_shop_package_allocation(series_id); + +CREATE INDEX IF NOT EXISTS idx_shop_package_allocation_allocator_shop_id +ON tb_shop_package_allocation(allocator_shop_id); + +-- ============================================ +-- 5. 删除废弃的表 +-- ============================================ + +-- 删除系列分配配置表 +DROP TABLE IF EXISTS tb_shop_series_allocation_config; + +-- 删除系列分配表 +DROP TABLE IF EXISTS tb_shop_series_allocation; diff --git a/migrations/000044_refactor_one_time_commission_allocation.down.sql b/migrations/000044_refactor_one_time_commission_allocation.down.sql new file mode 100644 index 0000000..485beac --- /dev/null +++ b/migrations/000044_refactor_one_time_commission_allocation.down.sql @@ -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; diff --git a/migrations/000044_refactor_one_time_commission_allocation.up.sql b/migrations/000044_refactor_one_time_commission_allocation.up.sql new file mode 100644 index 0000000..3e4ef54 --- /dev/null +++ b/migrations/000044_refactor_one_time_commission_allocation.up.sql @@ -0,0 +1,77 @@ +-- 重构一次性佣金分配:恢复系列分配表,移除套餐分配中的佣金字段 +-- 原因:一次性佣金是系列级概念,不应存储在套餐分配中 + +-- ============================================ +-- 1. 创建 tb_shop_series_allocation 表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS tb_shop_series_allocation ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + shop_id BIGINT NOT NULL, + series_id BIGINT NOT NULL, + allocator_shop_id BIGINT NOT NULL DEFAULT 0, + one_time_commission_amount BIGINT NOT NULL DEFAULT 0, + enable_one_time_commission BOOLEAN NOT NULL DEFAULT false, + one_time_commission_trigger VARCHAR(50), + one_time_commission_threshold BIGINT NOT NULL DEFAULT 0, + enable_force_recharge BOOLEAN NOT NULL DEFAULT false, + force_recharge_amount BIGINT NOT NULL DEFAULT 0, + force_recharge_trigger_type INT NOT NULL DEFAULT 2, + status INT NOT NULL DEFAULT 1 +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_id + ON tb_shop_series_allocation(shop_id); +CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_series_id + ON tb_shop_series_allocation(series_id); +CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_allocator_shop_id + ON tb_shop_series_allocation(allocator_shop_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_series + ON tb_shop_series_allocation(shop_id, series_id) WHERE deleted_at IS NULL; + +-- 注释 +COMMENT ON TABLE tb_shop_series_allocation IS '店铺系列分配表 - 管理系列级一次性佣金配置'; +COMMENT ON COLUMN tb_shop_series_allocation.shop_id IS '被分配店铺ID'; +COMMENT ON COLUMN tb_shop_series_allocation.series_id IS '套餐系列ID'; +COMMENT ON COLUMN tb_shop_series_allocation.allocator_shop_id IS '分配者店铺ID(0表示平台分配)'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_amount IS '该代理能拿的一次性佣金金额上限(分)'; +COMMENT ON COLUMN tb_shop_series_allocation.enable_one_time_commission IS '是否启用一次性佣金'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_trigger IS '一次性佣金触发类型 first_recharge-首次充值 accumulated_recharge-累计充值'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_threshold IS '一次性佣金触发阈值(分)'; +COMMENT ON COLUMN tb_shop_series_allocation.enable_force_recharge IS '是否启用强制充值'; +COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_amount IS '强制充值金额(分)'; +COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_trigger_type IS '强充触发类型 1-单次充值 2-累计充值'; +COMMENT ON COLUMN tb_shop_series_allocation.status IS '状态 1-启用 2-禁用'; + +-- ============================================ +-- 2. 修改 tb_shop_package_allocation 表 +-- ============================================ + +-- 添加 series_allocation_id 字段(如果不存在) +ALTER TABLE tb_shop_package_allocation +ADD COLUMN IF NOT EXISTS series_allocation_id BIGINT; + +COMMENT ON COLUMN tb_shop_package_allocation.series_allocation_id IS '关联的系列分配ID'; + +CREATE INDEX IF NOT EXISTS idx_shop_package_allocation_series_allocation_id + ON tb_shop_package_allocation(series_allocation_id); + +-- 删除 one_time_commission_amount 字段(佣金配置移到系列分配) +ALTER TABLE tb_shop_package_allocation +DROP COLUMN IF EXISTS one_time_commission_amount; + +-- 删除 series_id 字段(通过 series_allocation_id 关联) +ALTER TABLE tb_shop_package_allocation +DROP COLUMN IF EXISTS series_id; + +-- ============================================ +-- 3. 删除 tb_shop_series_one_time_commission_tier 表(未使用) +-- ============================================ + +DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier; diff --git a/migrations/000045_add_enable_one_time_commission_to_package_series.down.sql b/migrations/000045_add_enable_one_time_commission_to_package_series.down.sql new file mode 100644 index 0000000..8e7ae1d --- /dev/null +++ b/migrations/000045_add_enable_one_time_commission_to_package_series.down.sql @@ -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; diff --git a/migrations/000045_add_enable_one_time_commission_to_package_series.up.sql b/migrations/000045_add_enable_one_time_commission_to_package_series.up.sql new file mode 100644 index 0000000..2418e75 --- /dev/null +++ b/migrations/000045_add_enable_one_time_commission_to_package_series.up.sql @@ -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); diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/.openspec.yaml b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/.openspec.yaml new file mode 100644 index 0000000..8dcf270 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-03 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/design.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/design.md new file mode 100644 index 0000000..f23c829 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/design.md @@ -0,0 +1,462 @@ +## Context + +### 背景 + +当前套餐与佣金系统在快速迭代中积累了技术债务,主要问题: + +1. **套餐价格字段混乱**:`Price`、`SuggestedCostPrice`、`SuggestedRetailPrice` 三个字段语义不清,不同场景使用不一致 +2. **流量字段设计缺陷**:`DataType` 暗示真/虚流量二选一,但业务需求是共存机制 +3. **分配层次过多**:存在 `ShopSeriesAllocation`(系列分配)和 `ShopPackageAllocation`(套餐分配)两层,但业务模型只需要一层 +4. **差价佣金计算复杂**:使用 `BaseCommissionMode/Value` 动态计算,但业务模型是简单的成本价差值 +5. **一次性佣金配置位置错误**:配置在系列分配表中,应该只在套餐分配表中存储金额 + +### 业务模型(Source of Truth) + +详见 [`docs/commission-package-model.md`](../../../docs/commission-package-model.md) + +核心要点: +- **只有一层分配**:`ShopPackageAllocation`(套餐分配) +- **差价佣金 = 下级成本价 - 自己成本价**(固定差值,无动态计算) +- **一次性佣金规则在系列定义,金额在套餐分配中设置** + +### 现有架构(需要重构) + +``` +tb_package_series # 套餐系列(含一次性佣金规则) + │ + ├── tb_package # 套餐 + │ + └── tb_shop_series_allocation # ❌ 系列分配(需要删除) + │ + └── tb_shop_package_allocation # 套餐分配 +``` + +### 目标架构 + +``` +tb_package_series # 套餐系列(含一次性佣金规则配置) + │ + └── tb_package # 套餐 + │ + └── tb_shop_package_allocation # 套餐分配(唯一的分配表) + ├── shop_id # 被分配的店铺 + ├── package_id # 套餐ID + ├── series_id # 系列ID(新增,用于关联规则) + ├── allocator_shop_id # 分配者店铺ID(新增) + ├── cost_price # 该代理的成本价 + ├── one_time_commission_amount # 该代理能拿到的一次性佣金 + └── status +``` + +### 约束条件 + +- 不使用外键约束和 GORM 关联关系 +- 必须支持向后兼容的数据迁移 +- 分层架构:Handler → Service → Store → Model +- 异步任务使用 Asynq + +--- + +## Goals / Non-Goals + +### Goals + +1. **简化套餐模型**:只保留 `cost_price` + `suggested_retail_price`,语义清晰 +2. **支持流量共存**:真流量必填 + 虚流量可选开关,停机判断逻辑统一 +3. **删除 ShopSeriesAllocation**:移除多余的分配层次 +4. **简化差价佣金计算**:直接使用成本价差值,删除动态计算逻辑 +5. **统一分配模型**:一次性佣金金额只存储在 `ShopPackageAllocation` 中 +6. **代理视角隔离**:不同代理看到自己的成本价和能拿到的一次性佣金 + +### Non-Goals + +- 不重构订单支付流程(仅适配新的佣金计算) +- 不修改钱包充值逻辑(仅增加累计追踪) +- 不修改梯度佣金的统计存储结构(仅增加统计范围开关) +- 不处理历史订单的佣金重算(迁移只处理配置数据) + +--- + +## Decisions + +### D1: 删除 ShopSeriesAllocation 及相关表 + +**决策**:完全删除以下表和相关代码 + +| 表名 | 说明 | 删除原因 | +|------|------|----------| +| `tb_shop_series_allocation` | 系列分配表 | 多余的分配层次 | +| `tb_shop_series_allocation_config` | 系列分配配置版本表 | 依赖于 series_allocation | +| `tb_shop_series_one_time_commission_tier` | 系列分配梯度配置表 | 梯度配置移到系列规则中 | + +**代码删除清单**: +- `internal/model/shop_series_allocation.go` +- `internal/model/shop_series_allocation_config.go` +- `internal/model/dto/shop_series_allocation.go` +- `internal/store/postgres/shop_series_allocation_store.go` +- `internal/store/postgres/shop_series_allocation_config_store.go` +- `internal/service/shop_series_allocation/service.go` +- `internal/handler/admin/shop_series_allocation.go` +- `internal/routes/shop_series_allocation.go` +- `pkg/utils/commission.go`(CalculateCostPrice 函数) + +**理由**: +- 业务模型只需要一层分配(套餐分配) +- 系列分配增加了不必要的复杂度 +- 所有需要的信息都可以在套餐分配中存储 + +### D2: 修改 ShopPackageAllocation 模型 + +**决策**:扩展 `ShopPackageAllocation` 以承担原 `ShopSeriesAllocation` 的职责 + +```go +// Before +type ShopPackageAllocation struct { + ID uint + AllocationID uint // ❌ 外键到 ShopSeriesAllocation(删除) + PackageID uint + CostPrice int64 + Status int +} + +// After +type ShopPackageAllocation struct { + ID uint + ShopID uint // 被分配的店铺(新增) + PackageID uint // 套餐ID + SeriesID uint // 系列ID(新增,用于关联一次性佣金规则) + AllocatorShopID uint // 分配者店铺ID(新增,0表示平台) + CostPrice int64 // 该代理的成本价 + OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金金额(新增) + Status int + Creator uint + Updater uint +} +``` + +**理由**: +- `ShopID` 和 `AllocatorShopID` 原本存储在 `ShopSeriesAllocation` 中 +- `SeriesID` 用于查询一次性佣金规则(从 `PackageSeries.OneTimeCommissionConfig` 获取) +- `OneTimeCommissionAmount` 存储分配时设置的金额 + +### D3: 差价佣金计算简化 + +**决策**:删除动态计算逻辑,使用固定成本价差值 + +```go +// Before: 动态计算(删除) +// pkg/utils/commission.go +func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 { + switch allocation.BaseCommissionMode { + case "fixed": + return orderAmount - allocation.BaseCommissionValue + case "percent": + return orderAmount * (100 - allocation.BaseCommissionValue) / 100 + } + return orderAmount +} + +// After: 简单差值计算 +// 差价佣金 = 下级成本价 - 自己成本价 +func CalculateDifferenceCommission(myCostPrice, subCostPrice int64) int64 { + return subCostPrice - myCostPrice +} +``` + +**示例**: +``` +平台成本价: 100 +代理A成本价: 120(分配时设置) +代理A1成本价: 130(A分配给A1时设置) + +当A1销售时: +- A1利润 = 售价 - 130 +- A差价佣金 = 130 - 120 = 10(固定) +- 平台收入 = 120 +``` + +**理由**: +- 业务模型明确定义差价佣金 = 下级成本价 - 自己成本价 +- 无需 `BaseCommissionMode/Value` 的动态计算 +- 简化代码,减少出错可能 + +### D4: 一次性佣金金额存储位置 + +**决策**:一次性佣金金额只存储在 `ShopPackageAllocation.OneTimeCommissionAmount` + +```go +// 套餐系列定义规则(不变) +type PackageSeries struct { + // ... 基础字段 + EnableOneTimeCommission bool // 是否启用 + OneTimeCommissionConfig string // JSON:触发条件、阈值、金额/梯度等 +} + +// 套餐分配记录每个代理能拿到的金额 +type ShopPackageAllocation struct { + // ... 其他字段 + OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金 +} +``` + +**分配流程**: +``` +1. 平台创建套餐系列,配置一次性佣金规则:首充100返20 +2. 平台分配给代理A:设置成本价120,一次性佣金20 + → 创建 ShopPackageAllocation(shop_id=A, cost_price=120, one_time_commission_amount=20) +3. 代理A分配给A1:设置成本价130,一次性佣金8 + → 创建 ShopPackageAllocation(shop_id=A1, cost_price=130, one_time_commission_amount=8) +4. 触发一次性佣金时: + - A1 获得:8 + - A 获得:20 - 8 = 12 + - 合计:20 ✓ +``` + +**约束**: +- 给下级的金额 ≤ 自己能拿到的金额 +- 给下级的金额 ≥ 0 + +**理由**: +- 与成本价分配逻辑一致,都在套餐分配中设置 +- 每个代理只存储"自己能拿到多少",计算简单 +- 删除了 `ShopSeriesAllocation` 后的自然归属 + +### D5: 套餐价格字段简化 + +**决策**:移除 `Price` 和 `DataAmountMB`,保留并重命名字段 + +```go +// Before +type Package struct { + Price int64 // 语义不清 + SuggestedCostPrice int64 // 建议成本价 + SuggestedRetailPrice int64 // 建议售价 + DataType string // real/virtual 二选一 + RealDataMB int64 + VirtualDataMB int64 + DataAmountMB int64 // 语义不清 +} + +// After +type Package struct { + CostPrice int64 // 成本价(平台设置的基础成本价) + SuggestedRetailPrice int64 // 建议售价 + RealDataMB int64 // 真实流量(必填) + EnableVirtualData bool // 是否启用虚流量 + VirtualDataMB int64 // 虚流量(启用时必填,≤ 真实流量) +} +``` + +**理由**: +- `Price` 在不同上下文含义不同,造成混乱 +- `DataType` 是二选一设计,但业务需要共存 +- `DataAmountMB` 没有明确定义是真流量还是虚流量 + +### D6: 代理视角套餐列表实现 + +**决策**:在 Service 层动态计算,从 `ShopPackageAllocation` 获取数据 + +```go +// PackageService.List 方法 +func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) (*dto.PackagePageResult, error) { + // 1. 查询基础套餐数据 + packages, total, err := s.store.List(ctx, req) + + // 2. 获取当前用户信息 + userInfo := middleware.GetUserContextInfo(ctx) + + // 3. 如果是代理用户,查询分配关系并填充视角数据 + if userInfo.UserType == constants.UserTypeAgent { + allocations := s.packageAllocationStore.GetByShopAndPackages(ctx, userInfo.ShopID, packageIDs) + for _, pkg := range packages { + if alloc, ok := allocations[pkg.ID]; ok { + pkg.CostPrice = alloc.CostPrice // 覆盖为代理视角 + pkg.OneTimeCommissionAmount = alloc.OneTimeCommissionAmount + } + } + } + + return result, nil +} +``` + +**理由**: +- 不存储冗余数据,避免一致性问题 +- 查询时动态计算,逻辑集中在 Service 层 +- 使用批量查询避免 N+1 问题 + +### D7: 累计充值追踪方案 + +**决策**:在 `IoTCard` 和 `Device` 模型中新增追踪字段 + +```go +type IoTCard struct { + // ... 现有字段 + + // 按套餐系列追踪(JSON Map: series_id -> amount) + AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb"` + + // 按套餐系列追踪首充状态(JSON Map: series_id -> bool) + FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb"` +} +``` + +**理由**: +- 累计充值和首充状态都是"按系列"的,需要按系列追踪 +- 使用 JSONB 避免多表关联,查询性能好 +- PostgreSQL 原生支持 JSONB 索引和查询 + +--- + +## 删除清单 + +### 需要删除的文件 + +| 文件路径 | 类型 | 说明 | +|----------|------|------| +| `internal/model/shop_series_allocation.go` | Model | 系列分配模型 | +| `internal/model/shop_series_allocation_config.go` | Model | 配置版本模型 | +| `internal/model/dto/shop_series_allocation.go` | DTO | 请求/响应 DTO | +| `internal/store/postgres/shop_series_allocation_store.go` | Store | 数据访问层 | +| `internal/store/postgres/shop_series_allocation_config_store.go` | Store | 配置版本 Store | +| `internal/store/postgres/shop_series_allocation_store_test.go` | Test | Store 测试 | +| `internal/service/shop_series_allocation/service.go` | Service | 业务逻辑层 | +| `internal/handler/admin/shop_series_allocation.go` | Handler | HTTP Handler | +| `internal/routes/shop_series_allocation.go` | Routes | 路由注册 | +| `tests/integration/shop_series_allocation_test.go` | Test | 集成测试 | +| `pkg/utils/commission.go` | Utils | CalculateCostPrice 函数 | + +### 需要删除的字段 + +| 表/模型 | 字段 | 说明 | +|---------|------|------| +| `ShopPackageAllocation` | `AllocationID` | 外键到 ShopSeriesAllocation | +| `ShopSeriesAllocation` | 整表 | 删除整个表 | +| `ShopSeriesAllocationConfig` | 整表 | 删除整个表 | + +### 需要修改的文件 + +见 `tasks.md` 中的详细任务列表。 + +--- + +## Risks / Trade-offs + +### R1: 数据迁移复杂度 + +**风险**:现有数据结构与新结构差异大,迁移可能导致数据丢失或不一致 + +**缓解措施**: +- 分阶段迁移:先新增字段,再迁移数据,最后删除旧字段 +- 迁移前完整备份 +- 迁移脚本支持回滚 +- 新旧字段并存过渡期(2周) + +### R2: API 破坏性变更 + +**风险**:前端需要同步修改,上线需要协调 + +**缓解措施**: +- 提前沟通 API 变更内容 +- 删除 `/api/admin/shop-series-allocations/*` 路由 +- 修改 `/api/admin/shop-package-allocations/*` 路由参数 +- 提供详细的迁移文档 + +### R3: 链式分配计算性能 + +**风险**:触发一次性佣金时需要沿代理链向上计算,可能涉及多级查询 + +**缓解措施**: +- 使用 Redis 缓存代理链关系 +- 限制代理层级(最多 7 级) +- 佣金分配使用异步任务处理 + +### R4: JSONB 字段查询性能 + +**风险**:累计充值和首充状态使用 JSONB 存储,复杂查询可能慢 + +**缓解措施**: +- 为常用查询路径创建 JSONB 索引 +- 触发检查时先查内存/Redis 缓存 +- 监控查询性能,必要时重构为独立表 + +--- + +## Migration Plan + +**注意**:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。 + +### 数据库变更 + +```sql +-- 1. ShopPackageAllocation 新增字段 +ALTER TABLE tb_shop_package_allocation +ADD COLUMN IF NOT EXISTS shop_id BIGINT, +ADD COLUMN IF NOT EXISTS series_id BIGINT, +ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0, +ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0; + +-- 2. ShopPackageAllocation 删除字段 +ALTER TABLE tb_shop_package_allocation +DROP COLUMN IF EXISTS allocation_id; + +-- 3. Package 表调整 +ALTER TABLE tb_package +DROP COLUMN IF EXISTS price, +DROP COLUMN IF EXISTS data_type, +DROP COLUMN IF EXISTS data_amount_mb, +ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false; + +-- 4. IoTCard 新增追踪字段 +ALTER TABLE tb_iot_card +ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}', +ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}'; + +-- 5. Device 新增追踪字段 +ALTER TABLE tb_device +ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}', +ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}'; + +-- 6. 删除废弃表 +DROP TABLE IF EXISTS tb_shop_series_allocation; +DROP TABLE IF EXISTS tb_shop_series_allocation_config; +DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier; +``` + +### 代码变更顺序 + +1. **Model 层** + - 修改 `ShopPackageAllocation` 模型 + - 删除 `ShopSeriesAllocation` 相关模型 + +2. **DTO 层** + - 修改 `ShopPackageAllocation` DTO + - 删除 `ShopSeriesAllocation` DTO + +3. **Store 层** + - 修改 `ShopPackageAllocationStore` + - 删除 `ShopSeriesAllocationStore` + +4. **Service 层** + - 修改所有依赖 `ShopSeriesAllocation` 的 Service + - 删除 `ShopSeriesAllocationService` + +5. **Handler/Routes 层** + - 删除 `ShopSeriesAllocationHandler` + - 删除相关路由注册 + +6. **Bootstrap 层** + - 移除所有 `ShopSeriesAllocation` 相关初始化 + +--- + +## Open Questions + +1. **历史订单佣金**:已完成的订单佣金是否需要按新规则重算? + - 建议:不重算,保持历史数据稳定 + +2. **过渡期时长**:新旧字段并存多久? + - 建议:2周观察期,确认无问题后清理 + +3. **前端发版协调**:是否需要灰度发布? + - 取决于前端改动量,建议同步上线 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/proposal.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/proposal.md new file mode 100644 index 0000000..8c62590 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/proposal.md @@ -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` 一次性佣金配置需要迁移到新结构 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/accumulated-recharge-tracking/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/accumulated-recharge-tracking/spec.md new file mode 100644 index 0000000..0619f09 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/accumulated-recharge-tracking/spec.md @@ -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 不再触发一次性佣金 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/agent-available-packages/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/agent-available-packages/spec.md new file mode 100644 index 0000000..5354256 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/agent-available-packages/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-calculation/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-calculation/spec.md new file mode 100644 index 0000000..a8dac13 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-calculation/spec.md @@ -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元 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-chain-distribution/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-chain-distribution/spec.md new file mode 100644 index 0000000..d735733 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/commission-chain-distribution/spec.md @@ -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元) diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/force-recharge-check/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/force-recharge-check/spec.md new file mode 100644 index 0000000..084297d --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/force-recharge-check/spec.md @@ -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元 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-trigger/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-trigger/spec.md new file mode 100644 index 0000000..de35d41 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-trigger/spec.md @@ -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`(首充) diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-validity/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-validity/spec.md new file mode 100644 index 0000000..28d8d42 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/one-time-commission-validity/spec.md @@ -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 从零开始 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-management/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-management/spec.md new file mode 100644 index 0000000..4a360dc --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-management/spec.md @@ -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`(虚流量)明确区分 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-series-management/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-series-management/spec.md new file mode 100644 index 0000000..406ffbd --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-series-management/spec.md @@ -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** 返回完整的一次性佣金规则配置 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-virtual-data/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-virtual-data/spec.md new file mode 100644 index 0000000..0c3ee1e --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/package-virtual-data/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-commission-tier/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-commission-tier/spec.md new file mode 100644 index 0000000..2f8d58f --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-commission-tier/spec.md @@ -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` 字段 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-series-allocation/spec.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-series-allocation/spec.md new file mode 100644 index 0000000..f2a2adb --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/specs/shop-series-allocation/spec.md @@ -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` 迁移到新字段 diff --git a/openspec/changes/archive/2026-02-04-refactor-commission-package-model/tasks.md b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/tasks.md new file mode 100644 index 0000000..27ded7c --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-commission-package-model/tasks.md @@ -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 文档确认变更已反映 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/consensus.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/consensus.md new file mode 100644 index 0000000..3448397 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/consensus.md @@ -0,0 +1,66 @@ +# 共识文档 + +**Change**: refactor-one-time-commission-allocation +**确认时间**: 2026-02-04T11:50:00+08:00 +**确认人**: 用户(通过 Question_tool 逐条确认) + +--- + +## 1. 要做什么 + +- [x] 创建 `tb_shop_series_allocation` 新表,专门管理系列分配和一次性佣金(已确认) +- [x] 将 `one_time_commission_amount` 从 `ShopPackageAllocation` 移到新表(已确认) +- [x] `ShopPackageAllocation` 精简为只管成本价,添加 `series_allocation_id` 关联(已确认) +- [x] `PackageSeries` 添加 `enable_one_time_commission` 布尔字段(提升到顶层)(已确认) +- [x] 梯度模式下也实现链式分配(代理能拿的金额 = min(梯度匹配金额, 上级给的上限))(已确认) +- [x] 删除未使用的 `ShopSeriesOneTimeCommissionTier` 表和相关代码(已确认) +- [x] 新增系列分配 API(CRUD)(已确认) +- [x] 业务流程改造:必须先分配系列,再分配套餐(已确认) +- [x] 佣金计算逻辑改为从系列分配获取佣金配置(已确认) + +## 2. 不做什么 + +- [x] 不保留旧接口的兼容性(直接切换)(已确认) +- [x] 不支持代理自定义梯度规则(所有代理使用平台统一规则)(已确认) +- [x] 不在此次改造中修改前端交互流程(后续单独处理)(已确认) + +## 3. 关键约束 + +- [x] 遵循项目技术栈规范(Handler → Service → Store → Model)(已确认) +- [x] 删除代码前必须确认无调用(`ShopSeriesOneTimeCommissionTier` 相关)(已确认) +- [x] `ShopSeriesCommissionStats` 的 `allocation_id` 需要重新关联到系列分配(已确认) + +## 4. 验收标准 + +- [x] 同一 shop + series 只存在一条系列分配记录(唯一约束)(已确认) +- [x] 触发佣金时直接查询系列分配,不再有"取第一个"的 hack(已确认) +- [x] `enable_one_time_commission` 可通过 SQL WHERE 直接查询(已确认) +- [x] 分配套餐前必须先分配系列,否则报错(已确认) +- [x] 使用新工作流生成的验收测试和流程测试全部通过(已确认) + +--- + +## 讨论背景 + +用户发现一次性佣金架构存在设计问题: + +1. **概念与存储错位**:一次性佣金是"系列级"概念,但 `one_time_commission_amount` 存储在"套餐分配"(`ShopPackageAllocation`)中 +2. **数据冗余**:同一系列的多个套餐分配时,佣金配置需要重复设置 +3. **隐性假设**:代码靠"取第一个"(`GetByShopAndSeries` + `LIMIT 1`)来获取佣金,假设同系列配置相同但没有约束保证 +4. **`enable` 藏在 JSON 里**:判断是否启用一次性佣金需要解析 JSON,无法高效查询 +5. **废弃代码**:`ShopSeriesOneTimeCommissionTier` 表定义了但完全没有被使用 + +## 关键决策记录 + +| 决策点 | 选择 | 原因 | +|--------|------|------| +| 佣金存储位置 | 新建 `ShopSeriesAllocation` 表 | 职责分离:系列分配管佣金,套餐分配只管成本价 | +| 梯度模式下的分配 | 链式分配(min(梯度匹配, 上级上限)) | 保持与固定模式一致的业务逻辑 | +| 数据迁移 | 不做(开发阶段) | 现阶段无需迁移生产数据 | +| 旧接口兼容 | 不保留 | 简化实现,直接切换 | +| 代理自定义梯度 | 不支持 | 所有代理使用平台统一规则,简化配置 | +| 测试策略 | 使用新工作流验收测试 | 替代原有意义不大的测试 | + +--- + +**签字确认**: 用户已通过 Question_tool 逐条确认以上内容 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/design.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/design.md new file mode 100644 index 0000000..33ba17f --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/design.md @@ -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. **系列分配的状态管理**:系列分配禁用后,已有的套餐分配如何处理? + - 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/proposal.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/proposal.md new file mode 100644 index 0000000..cb4e6a8 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/proposal.md @@ -0,0 +1,91 @@ +# Proposal: refactor-one-time-commission-allocation + +**Feature ID**: feature-012-refactor-one-time-commission-allocation + +## Why + +当前一次性佣金架构存在概念与存储错位的问题:一次性佣金是"系列级"概念(每张卡/设备在该系列下只触发一次),但 `one_time_commission_amount` 却存储在"套餐分配"(`ShopPackageAllocation`)中。这导致: + +1. **数据冗余**:同一系列的多个套餐分配时,佣金配置需要重复设置 +2. **隐性假设**:代码靠"取第一个"(`GetByShopAndSeries` + `LIMIT 1`)获取佣金,假设同系列配置相同但无约束保证 +3. **查询低效**:`enable` 藏在 JSON 里,无法高效查询 +4. **废弃代码**:`ShopSeriesOneTimeCommissionTier` 表定义了但完全未使用 + +## What Changes + +### 新增 + +- 创建 `tb_shop_series_allocation` 新表,专门管理系列分配和一次性佣金 +- 新增系列分配 API(CRUD):`/api/admin/shop-series-allocations` +- `PackageSeries` 添加 `enable_one_time_commission` 布尔字段(从 JSON 提升到顶层) + +### 修改 + +- **BREAKING** `ShopPackageAllocation` 移除 `one_time_commission_amount` 和 `series_id` 字段,添加 `series_allocation_id` 关联 +- 佣金计算逻辑改为从系列分配获取佣金配置 +- 梯度模式下实现链式分配:代理能拿的金额 = min(梯度匹配金额, 上级给的上限) +- 业务流程改造:必须先分配系列,再分配套餐 +- `ShopSeriesCommissionStats` 的 `allocation_id` 重新关联到系列分配 + +### 删除 + +- 删除 `ShopSeriesOneTimeCommissionTier` 表和相关代码(从未使用) +- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法("取第一个"hack) + +## Capabilities + +### New Capabilities + +- `shop-series-allocation`: 店铺系列分配模块,管理店铺对套餐系列的分配关系和一次性佣金配置 + +### Modified Capabilities + +- `commission-calculation`: 佣金计算改用系列分配获取一次性佣金配置,梯度模式实现链式分配 +- `commission-trigger`: 佣金触发时从系列分配读取佣金金额 + +## Impact + +### 代码影响 + +| 模块 | 影响 | +|------|------| +| `internal/model/` | 新增 `ShopSeriesAllocation`,修改 `ShopPackageAllocation`、`PackageSeries` | +| `internal/store/postgres/` | 新增 `shop_series_allocation_store.go`,修改套餐分配和佣金相关 store | +| `internal/service/` | 新增 `shop_series_allocation/`,修改 `commission_calculation/`、`recharge/`、`order/` | +| `internal/handler/admin/` | 新增 `shop_series_allocation.go`,修改套餐分配 handler | +| `internal/router/` | 添加系列分配路由 | + +### API 影响 + +| 类型 | 端点 | 说明 | +|------|------|------| +| 新增 | `POST /api/admin/shop-series-allocations` | 创建系列分配 | +| 新增 | `GET /api/admin/shop-series-allocations` | 查询系列分配列表 | +| 新增 | `GET /api/admin/shop-series-allocations/:id` | 获取系列分配详情 | +| 新增 | `PUT /api/admin/shop-series-allocations/:id` | 更新系列分配 | +| 新增 | `DELETE /api/admin/shop-series-allocations/:id` | 删除系列分配 | +| **BREAKING** | `POST /api/admin/shop-package-allocations` | 移除 `one_time_commission_amount` 参数 | +| **BREAKING** | `PUT /api/admin/shop-package-allocations/:id` | 移除 `one_time_commission_amount` 参数 | + +### 数据库影响 + +- 新增表:`tb_shop_series_allocation` +- 修改表:`tb_shop_package_allocation`(删除列)、`tb_package_series`(新增列) +- 删除表:`tb_shop_series_one_time_commission_tier` + +### 技术栈 + +- 遵循 Handler → Service → Store → Model 分层架构 +- 使用 Fiber v2.x + GORM v1.25.x +- 使用新工作流生成验收测试和流程测试 + +### 测试计划 + +- 使用 `/opsx:gen-tests` 从 Spec 生成验收测试 +- 覆盖系列分配 CRUD、佣金计算链式分配、业务流程约束 +- 删除原有相关测试,使用新工作流测试替代 + +### 性能考虑 + +- 系列分配查询:直接通过 `shop_id + series_id` 唯一索引查询,无需 LIMIT 1 +- `enable_one_time_commission` 字段可建索引,支持高效过滤 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-calculation/spec.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-calculation/spec.md new file mode 100644 index 0000000..5056976 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-calculation/spec.md @@ -0,0 +1,147 @@ +# Delta Spec: commission-calculation + +## MODIFIED Requirements + +### Requirement: 一次性佣金触发检查 + +系统 SHALL 在更新累计充值金额后立即检查是否触发一次性佣金。**一次性佣金金额从系列分配获取,而非套餐分配**。 + +**关键变更**: +- 原来从 `ShopPackageAllocation.one_time_commission_amount` 获取佣金金额 +- 现在从 `ShopSeriesAllocation.one_time_commission_amount` 获取佣金金额 +- 梯度模式下采用链式分配:实际金额 = min(梯度匹配金额, 系列分配的上限) + +#### Scenario: 累计达到阈值触发佣金(固定模式) +- **WHEN** 更新累计充值后,累计值 >= 配置阈值 +- **AND** 卡/设备的 first_commission_paid = false +- **AND** 系列配置为固定模式(type = "fixed") +- **THEN** 系统从该卡/设备销售代理的系列分配获取 one_time_commission_amount +- **AND** 发放该金额作为一次性佣金 +- **AND** 标记 first_commission_paid = true + +#### Scenario: 累计达到阈值触发佣金(梯度模式) +- **WHEN** 更新累计充值后,累计值 >= 配置阈值 +- **AND** 卡/设备的 first_commission_paid = false +- **AND** 系列配置为梯度模式(type = "tiered") +- **AND** 代理销售数量为 50 张,梯度配置:1-30张80元,31-100张100元 +- **AND** 代理的系列分配 one_time_commission_amount = 9000(90元上限) +- **THEN** 梯度匹配金额 = 100 元(50张落在31-100档) +- **AND** 实际发放金额 = min(100, 90) = 90 元 +- **AND** 标记 first_commission_paid = true + +#### Scenario: 累计未达到阈值不触发 +- **WHEN** 更新累计充值后,累计值 < 配置阈值 +- **THEN** 系统不发放一次性佣金 +- **AND** first_commission_paid 保持不变 + +#### Scenario: 已发放过不重复触发 +- **WHEN** 更新累计充值后,累计值 >= 配置阈值 +- **AND** 卡/设备的 first_commission_paid = true +- **THEN** 系统不重复发放一次性佣金 + +--- + +### Requirement: 一次性佣金链式分配 + +系统 SHALL 为代理链上的每一级代理计算一次性佣金差价收入。每级代理的收入 = 自己的分配上限 - 下级的分配上限。 + +#### Scenario: 单级代理 +- **WHEN** 一级代理销售卡,系列分配 one_time_commission_amount = 10000(100元) +- **AND** 无下级(终端销售) +- **THEN** 一级代理获得 100 元一次性佣金 + +#### Scenario: 多级代理链式分配 +- **WHEN** 三级代理销售卡,各级系列分配: + - 平台给一级:one_time_commission_amount = 10000(100元) + - 一级给二级:one_time_commission_amount = 8000(80元) + - 二级给三级:one_time_commission_amount = 5000(50元) +- **THEN** 三级获得 50 元 +- **AND** 二级获得 30 元(80 - 50) +- **AND** 一级获得 20 元(100 - 80) + +#### Scenario: 梯度模式下的链式分配 +- **WHEN** 系列配置为梯度模式,梯度匹配金额为 120 元 +- **AND** 三级代理的系列分配上限为 50 元,二级为 80 元,一级为 100 元 +- **THEN** 三级获得 min(120, 50) = 50 元 +- **AND** 二级获得 min(120, 80) - 50 = 30 元 +- **AND** 一级获得 min(120, 100) - 80 = 20 元 +- **AND** 平台获得 120 - 100 = 20 元 + +#### Scenario: 某级差价为零 +- **WHEN** 某级代理分配的上限等于下级 +- **THEN** 该级代理一次性佣金差价为 0,不创建佣金记录 + +--- + +### Requirement: 钱包充值触发一次性佣金 + +钱包充值成功后 SHALL 更新累计充值,并检查是否触发一次性佣金。**佣金金额从系列分配获取**。 + +#### Scenario: 充值成功更新累计充值 +- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元 +- **THEN** 系统更新卡的 accumulated_recharge 为 300 元 + +#### Scenario: 充值达到首次充值阈值 +- **WHEN** 卡配置为首次充值触发,阈值 100 元,充值 100 元成功,未发放过佣金 +- **THEN** 系统从该卡销售代理的系列分配获取 one_time_commission_amount +- **AND** 触发一次性佣金计算,发放佣金,标记 first_commission_paid = true + +#### Scenario: 充值达到累计充值阈值 +- **WHEN** 卡配置为累计充值触发,阈值 1000 元,充值后累计达到 1000 元,未发放过佣金 +- **THEN** 系统从该卡销售代理的系列分配获取 one_time_commission_amount +- **AND** 触发一次性佣金计算,发放佣金,标记 first_commission_paid = true + +#### Scenario: 充值未达阈值不触发 +- **WHEN** 充值后累计充值未达到阈值 +- **THEN** 系统不触发一次性佣金计算 + +#### Scenario: 已发放过不重复触发 +- **WHEN** 卡的一次性佣金已发放过(first_commission_paid = true) +- **THEN** 系统不触发一次性佣金计算 + +--- + +## ADDED Requirements + +### Requirement: 系列分配查询优化 + +系统 SHALL 提供通过店铺和系列直接查询系列分配的方法,替代原有的"取第一个"逻辑。 + +#### Scenario: 直接查询系列分配 +- **WHEN** 需要获取代理的一次性佣金配置 +- **THEN** 系统通过 shop_id + series_id 直接查询 ShopSeriesAllocation +- **AND** 返回唯一匹配的记录(唯一索引保证) + +#### Scenario: 系列分配不存在 +- **WHEN** 查询的 shop_id + series_id 组合不存在分配记录 +- **THEN** 系统返回 "未找到系列分配配置" 错误 +- **AND** 不发放一次性佣金(但不影响成本价差收入计算) + +--- + +### Requirement: enable_one_time_commission 字段查询 + +系统 SHALL 从 PackageSeries 的顶层字段读取一次性佣金开关状态,支持 SQL 索引查询。 + +#### Scenario: 检查系列是否启用一次性佣金 +- **WHEN** 需要判断是否触发一次性佣金 +- **THEN** 系统查询 PackageSeries.enable_one_time_commission 字段 +- **AND** 不再解析 one_time_commission_config JSON + +#### Scenario: 批量查询启用一次性佣金的系列 +- **WHEN** 需要统计启用一次性佣金的系列数量 +- **THEN** 系统可使用 `WHERE enable_one_time_commission = true` 直接查询 +- **AND** 无需 JSON 解析 + +--- + +## REMOVED Requirements + +### Requirement: 从套餐分配获取一次性佣金金额 + +**Reason**: 一次性佣金是系列级概念,应从系列分配获取,而非套餐分配 + +**Migration**: +- 原查询路径:`ShopPackageAllocation.one_time_commission_amount` +- 新查询路径:`ShopSeriesAllocation.one_time_commission_amount` +- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-trigger/spec.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-trigger/spec.md new file mode 100644 index 0000000..48d289b --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/commission-trigger/spec.md @@ -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` 方法 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md new file mode 100644 index 0000000..32133fa --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md @@ -0,0 +1,166 @@ +# Delta Spec: shop-series-allocation + +## MODIFIED Requirements + +### Requirement: 为下级店铺分配套餐系列 + +系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定**一次性佣金金额上限**(代替原来的基础返佣配置),MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。 + +**关键变更**: +- 移除 `commission_mode` 和 `commission_value` 字段(基础返佣配置) +- 新增 `one_time_commission_amount` 字段:代理能拿的一次性佣金金额上限(分) +- 一次性佣金计算采用**链式分配**:代理实际获得金额 = min(系列配置的梯度/固定金额, 上级给的上限) + +**API 接口变更**: +- 移除请求/响应中的 `commission_mode`、`commission_value` 字段 +- 新增 `one_time_commission_amount` 字段(分,必填) + +#### Scenario: 成功分配套餐系列 +- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置 one_time_commission_amount = 5000(50元) +- **THEN** 系统创建分配记录,下级代理能拿的一次性佣金上限为 50 元 + +#### Scenario: 链式分配金额计算 +- **WHEN** 平台给一级代理分配系列,one_time_commission_amount = 10000(100元) +- **AND** 一级代理给二级代理分配,one_time_commission_amount = 8000(80元) +- **AND** 二级代理给三级代理分配,one_time_commission_amount = 5000(50元) +- **THEN** 三级代理能拿的一次性佣金上限为 50 元 +- **AND** 二级代理差价 = 80 - 50 = 30 元 +- **AND** 一级代理差价 = 100 - 80 = 20 元 + +#### Scenario: 下级金额不能超过上级 +- **WHEN** 代理尝试为下级分配,设置的 one_time_commission_amount 超过自己被分配的金额 +- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限" + +#### Scenario: 分配时启用一次性佣金和强充 +- **WHEN** 代理为下级分配系列,one_time_commission_amount = 5000,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元) +- **THEN** 系统保存配置:one_time_commission_amount = 5000,enable_one_time_commission = true,trigger = "accumulated_recharge",threshold = 100000,enable_force_recharge = true,force_recharge_amount = 10000 + +#### Scenario: 尝试分配未拥有的系列 +- **WHEN** 代理尝试分配自己未被分配的套餐系列 +- **THEN** 系统返回错误 "您没有该套餐系列的分配权限" + +#### Scenario: 尝试分配给非直属下级 +- **WHEN** 代理尝试分配给非直属下级店铺 +- **THEN** 系统返回错误 "只能为直属下级分配套餐" + +#### Scenario: 重复分配同一系列 +- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列 +- **THEN** 系统返回错误 "该店铺已分配此套餐系列" + +--- + +### Requirement: 查询套餐系列分配列表 + +系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。**响应 MUST 包含 one_time_commission_amount 字段**。 + +#### Scenario: 查询所有分配 +- **WHEN** 代理查询分配列表,不带筛选条件 +- **THEN** 系统返回该代理创建的所有分配记录,每条记录包含 one_time_commission_amount 字段 + +#### Scenario: 按店铺筛选 +- **WHEN** 代理指定下级店铺 ID 筛选 +- **THEN** 系统只返回该店铺的分配记录,记录包含 one_time_commission_amount 字段 + +#### Scenario: 响应包含一次性佣金金额 +- **WHEN** 查询分配列表或详情 +- **THEN** 每条记录包含 one_time_commission_amount 字段(分) + +--- + +### Requirement: 更新套餐系列分配 + +系统 SHALL 允许代理更新分配的一次性佣金金额、一次性佣金配置和强充配置。**API 请求 MUST 支持更新 one_time_commission_amount 字段**。 + +#### Scenario: 更新一次性佣金金额 +- **WHEN** 代理将 one_time_commission_amount 从 5000 改为 6000 +- **THEN** 系统更新分配记录,后续一次性佣金按新金额计算 + +#### Scenario: 更新金额不能超过上级上限 +- **WHEN** 代理尝试将 one_time_commission_amount 更新为超过自己被分配上限的值 +- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限" + +#### Scenario: 更新强充配置 +- **WHEN** 代理将 enable_force_recharge 从 false 改为 true,设置 force_recharge_amount = 10000 +- **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求 + +#### Scenario: 更新不存在的分配 +- **WHEN** 代理更新不存在的分配 ID +- **THEN** 系统返回 "分配记录不存在" 错误 + +--- + +### Requirement: 平台分配套餐系列 + +平台管理员 SHALL 能够为一级代理分配套餐系列,指定一次性佣金金额上限。平台作为分配链顶端,其金额上限由系列配置决定。 + +#### Scenario: 平台为一级代理分配 +- **WHEN** 平台管理员为一级代理分配套餐系列,设置 one_time_commission_amount = 10000(100元) +- **THEN** 系统创建分配记录,一级代理能拿的一次性佣金上限为 100 元 + +#### Scenario: 平台金额不能超过系列配置 +- **WHEN** 套餐系列配置的一次性佣金固定金额为 15000(150元) +- **AND** 平台尝试为一级代理分配,one_time_commission_amount = 20000(200元) +- **THEN** 系统返回错误 "一次性佣金金额不能超过系列配置上限" + +#### Scenario: 平台配置强充要求 +- **WHEN** 平台为一级代理分配系列,启用强充,force_recharge_amount = 10000 +- **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求 + +--- + +## ADDED Requirements + +### Requirement: 套餐分配依赖系列分配 + +系统 SHALL 要求在分配套餐给下级店铺之前,必须先分配对应的套餐系列。 + +#### Scenario: 先分配系列再分配套餐 +- **WHEN** 代理尝试为下级分配套餐 A(属于系列 X) +- **AND** 下级店铺已被分配系列 X +- **THEN** 系统允许创建套餐分配,并关联到系列分配记录 + +#### Scenario: 未分配系列时分配套餐失败 +- **WHEN** 代理尝试为下级分配套餐 A(属于系列 X) +- **AND** 下级店铺未被分配系列 X +- **THEN** 系统返回错误 "请先分配该套餐所属的系列" + +#### Scenario: 删除系列分配时检查套餐分配 +- **WHEN** 代理尝试删除系列分配 +- **AND** 存在依赖该系列分配的套餐分配 +- **THEN** 系统返回错误 "存在关联的套餐分配,无法删除" + +--- + +### Requirement: 套餐分配精简 + +套餐分配(ShopPackageAllocation)SHALL 只管理成本价,一次性佣金配置移到系列分配。 + +#### Scenario: 套餐分配只包含成本价 +- **WHEN** 创建套餐分配 +- **THEN** 请求/响应只包含 cost_price 字段 +- **AND** 不包含 one_time_commission_amount 字段 + +#### Scenario: 套餐分配关联系列分配 +- **WHEN** 创建套餐分配 +- **THEN** 系统自动关联对应的系列分配(通过 series_allocation_id) + +--- + +## REMOVED Requirements + +### Requirement: 梯度返佣配置 + +**Reason**: 已在之前版本移除,此处确认删除状态 + +**Migration**: 使用一次性佣金的梯度模式替代 + +--- + +### Requirement: 基础返佣配置 + +**Reason**: commission_mode 和 commission_value 字段被 one_time_commission_amount 替代 + +**Migration**: +- 旧字段 commission_mode(百分比/固定值)和 commission_value 移除 +- 新字段 one_time_commission_amount(分)表示代理能拿的一次性佣金上限 +- 成本价差收入的计算不变,仍从套餐分配的 cost_price 计算 diff --git a/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/tasks.md b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/tasks.md new file mode 100644 index 0000000..bc3e761 --- /dev/null +++ b/openspec/changes/archive/2026-02-04-refactor-one-time-commission-allocation/tasks.md @@ -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` diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 4d9bed8..5f84784 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -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) +} diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 1bd6562..e732721 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -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) diff --git a/pkg/utils/commission.go b/pkg/utils/commission.go deleted file mode 100644 index c9cb84a..0000000 --- a/pkg/utils/commission.go +++ /dev/null @@ -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 -} diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md new file mode 100644 index 0000000..f33ef0d --- /dev/null +++ b/tests/acceptance/README.md @@ -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) +}) +``` diff --git a/tests/acceptance/commission_calculation_acceptance_test.go b/tests/acceptance/commission_calculation_acceptance_test.go new file mode 100644 index 0000000..c689432 --- /dev/null +++ b/tests/acceptance/commission_calculation_acceptance_test.go @@ -0,0 +1,444 @@ +package acceptance + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/break/junhong_cmp_fiber/tests/testutils/integ" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================ +// 验收测试:佣金计算重构 +// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/commission-calculation/spec.md +// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/commission-trigger/spec.md +// ============================================================ + +func TestCommissionCalculation_SeriesAllocationQuery_Acceptance(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + parentShop := env.CreateTestShop("一级代理", 1, nil) + childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID) + series := createCommissionTestSeries(t, env, "佣金测试系列") + + createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000) + createSeriesAllocationForCommission(t, env, parentShop.ID, childShop.ID, series.ID, 5000) + + // ------------------------------------------------------------ + // Scenario: 直接查询系列分配 + // GIVEN: 存在 shop_id + series_id 的系列分配记录 + // WHEN: 通过 shop_id 和 series_id 查询 + // THEN: 返回唯一匹配的记录,包含 one_time_commission_amount + // + // 破坏点:如果查询 API 不支持 series_id 筛选,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_直接查询系列分配", func(t *testing.T) { + path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d", + childShop.ID, series.ID) + + resp, err := env.AsSuperAdmin().Request("GET", path, nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + + data := result.Data.(map[string]interface{}) + items := data["items"].([]interface{}) + require.Len(t, items, 1, "应返回唯一匹配记录") + + allocation := items[0].(map[string]interface{}) + assert.Equal(t, float64(5000), allocation["one_time_commission_amount"], + "佣金金额应为 5000 分") + }) + + // ------------------------------------------------------------ + // Scenario: 系列分配不存在 + // GIVEN: shop_id + series_id 组合不存在分配记录 + // WHEN: 查询该组合 + // THEN: 返回空列表 + // + // 破坏点:如果查询不正确处理空结果,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_系列分配不存在", func(t *testing.T) { + path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=99999", + childShop.ID) + + resp, err := env.AsSuperAdmin().Request("GET", path, nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + data := result.Data.(map[string]interface{}) + list := data["items"].([]interface{}) + assert.Empty(t, list, "不存在的组合应返回空列表") + }) +} + +func TestCommissionCalculation_EnableOneTimeCommission_Acceptance(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + series := createCommissionTestSeriesWithConfig(t, env, "启用佣金系列", true) + + // ------------------------------------------------------------ + // Scenario: 检查系列是否启用一次性佣金 + // GIVEN: 系列配置 enable_one_time_commission = true + // WHEN: 查询系列详情 + // THEN: 响应包含 enable_one_time_commission = true + // + // 破坏点:如果系列 API 不返回 enable_one_time_commission 字段,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_检查系列是否启用一次性佣金", func(t *testing.T) { + path := fmt.Sprintf("/api/admin/package-series/%d", series.ID) + + resp, err := env.AsSuperAdmin().Request("GET", path, nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + + data := result.Data.(map[string]interface{}) + enableOneTime, ok := data["enable_one_time_commission"] + assert.True(t, ok, "响应应包含 enable_one_time_commission 字段") + assert.Equal(t, true, enableOneTime, "应为 true") + }) + + // ------------------------------------------------------------ + // Scenario: 批量查询启用一次性佣金的系列 + // GIVEN: 存在多个系列,部分启用一次性佣金 + // WHEN: 查询系列列表并按 enable_one_time_commission 筛选 + // THEN: 返回符合条件的系列 + // + // 破坏点:如果不支持 enable_one_time_commission 筛选,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_批量查询启用一次性佣金的系列", func(t *testing.T) { + createCommissionTestSeriesWithConfig(t, env, "禁用佣金系列", false) + + resp, err := env.AsSuperAdmin(). + Request("GET", "/api/admin/package-series?enable_one_time_commission=true", nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + data := result.Data.(map[string]interface{}) + list := data["items"].([]interface{}) + + for _, item := range list { + seriesItem := item.(map[string]interface{}) + enableVal, hasField := seriesItem["enable_one_time_commission"] + if hasField { + assert.Equal(t, true, enableVal, "筛选结果应全部为启用状态") + } + } + }) +} + +func TestCommissionCalculation_ChainAllocation_Acceptance(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + level1Shop := env.CreateTestShop("一级代理", 1, nil) + level2Shop := env.CreateTestShop("二级代理", 2, &level1Shop.ID) + level3Shop := env.CreateTestShop("三级代理", 3, &level2Shop.ID) + series := createCommissionTestSeries(t, env, "链式分配系列") + + // ------------------------------------------------------------ + // Scenario: 链式分配金额计算 + // GIVEN: + // - 平台给一级:one_time_commission_amount = 10000(100元) + // - 一级给二级:one_time_commission_amount = 8000(80元) + // - 二级给三级:one_time_commission_amount = 5000(50元) + // WHEN: 查询各级的系列分配 + // THEN: 各级金额正确 + // + // 破坏点:如果分配金额不正确保存,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_链式分配金额计算", func(t *testing.T) { + createPlatformSeriesAllocationForCommission(t, env, level1Shop.ID, series.ID, 10000) + createSeriesAllocationForCommission(t, env, level1Shop.ID, level2Shop.ID, series.ID, 8000) + createSeriesAllocationForCommission(t, env, level2Shop.ID, level3Shop.ID, series.ID, 5000) + + verifyAllocationAmount(t, env, level1Shop.ID, series.ID, 10000) + verifyAllocationAmount(t, env, level2Shop.ID, series.ID, 8000) + verifyAllocationAmount(t, env, level3Shop.ID, series.ID, 5000) + }) + + // ------------------------------------------------------------ + // Scenario: 单级代理 + // GIVEN: 一级代理直接销售(无下级) + // WHEN: 查询一级的系列分配 + // THEN: 一级获得完整的 one_time_commission_amount + // + // 破坏点:如果单级分配不生效,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_单级代理", func(t *testing.T) { + singleShop := env.CreateTestShop("单级代理", 1, nil) + singleSeries := createCommissionTestSeries(t, env, "单级系列") + + createPlatformSeriesAllocationForCommission(t, env, singleShop.ID, singleSeries.ID, 10000) + verifyAllocationAmount(t, env, singleShop.ID, singleSeries.ID, 10000) + }) +} + +func TestCommissionCalculation_TriggerConfig_Acceptance(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + parentShop := env.CreateTestShop("一级代理", 1, nil) + childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID) + series := createCommissionTestSeries(t, env, "触发配置系列") + + createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000) + + // ------------------------------------------------------------ + // Scenario: 累计达到阈值触发佣金配置 + // GIVEN: 系列分配设置为累计充值触发,阈值 1000 元 + // WHEN: 创建系列分配 + // THEN: 配置正确保存 + // + // 破坏点:如果触发配置不保存,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_累计达到阈值触发佣金配置", func(t *testing.T) { + body := map[string]interface{}{ + "shop_id": childShop.ID, + "series_id": series.ID, + "one_time_commission_amount": 5000, + "enable_one_time_commission": true, + "one_time_commission_trigger": "accumulated_recharge", + "one_time_commission_threshold": 100000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin(). + Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + + data := result.Data.(map[string]interface{}) + assert.Equal(t, true, data["enable_one_time_commission"]) + assert.Equal(t, "accumulated_recharge", data["one_time_commission_trigger"]) + assert.Equal(t, float64(100000), data["one_time_commission_threshold"]) + }) + + // ------------------------------------------------------------ + // Scenario: 首次充值触发配置 + // GIVEN: 系列分配设置为首次充值触发,阈值 100 元 + // WHEN: 创建系列分配 + // THEN: 配置正确保存 + // + // 破坏点:如果 first_recharge 触发类型不支持,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_首次充值触发配置", func(t *testing.T) { + newChildShop := env.CreateTestShop("首充测试店铺", 2, &parentShop.ID) + + body := map[string]interface{}{ + "shop_id": newChildShop.ID, + "series_id": series.ID, + "one_time_commission_amount": 5000, + "enable_one_time_commission": true, + "one_time_commission_trigger": "first_recharge", + "one_time_commission_threshold": 10000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin(). + Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + + data := result.Data.(map[string]interface{}) + assert.Equal(t, "first_recharge", data["one_time_commission_trigger"]) + }) +} + +func TestCommissionStats_Allocation_Acceptance(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + parentShop := env.CreateTestShop("一级代理", 1, nil) + series := createCommissionTestSeries(t, env, "统计测试系列") + + // ------------------------------------------------------------ + // Scenario: 创建佣金统计记录关联系列分配 + // GIVEN: 存在系列分配记录 + // WHEN: 查询佣金统计 + // THEN: 统计记录的 allocation_id 指向 ShopSeriesAllocation.id + // + // 破坏点:如果统计不关联系列分配,此测试将失败 + // ------------------------------------------------------------ + t.Run("Scenario_佣金统计关联系列分配", func(t *testing.T) { + allocation := createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000) + + path := fmt.Sprintf("/api/admin/shop-series-commission-stats?shop_id=%d&series_id=%d", + parentShop.ID, series.ID) + + resp, err := env.AsSuperAdmin().Request("GET", path, nil) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode == 200 { + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + if result.Code == 0 && result.Data != nil { + data := result.Data.(map[string]interface{}) + if list, ok := data["items"].([]interface{}); ok && len(list) > 0 { + stats := list[0].(map[string]interface{}) + if allocationID, exists := stats["allocation_id"]; exists { + assert.Equal(t, float64(allocation.ID), allocationID, + "统计应关联到系列分配 ID") + } + } + } + } + }) +} + +// ============================================================ +// 辅助函数 +// ============================================================ + +func createCommissionTestSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries { + t.Helper() + + timestamp := time.Now().UnixNano() + series := &model.PackageSeries{ + SeriesCode: fmt.Sprintf("COMM_SERIES_%d", timestamp), + SeriesName: name, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(series).Error + require.NoError(t, err, "创建测试系列失败") + + return series +} + +func createCommissionTestSeriesWithConfig(t *testing.T, env *integ.IntegrationTestEnv, name string, enableOneTime bool) *model.PackageSeries { + t.Helper() + + timestamp := time.Now().UnixNano() + series := &model.PackageSeries{ + SeriesCode: fmt.Sprintf("COMM_SERIES_%d", timestamp), + SeriesName: name, + Status: constants.StatusEnabled, + EnableOneTimeCommission: enableOneTime, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(series).Error + require.NoError(t, err, "创建测试系列失败") + + return series +} + +func createPlatformSeriesAllocationForCommission(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation { + t.Helper() + + allocation := &model.ShopSeriesAllocation{ + ShopID: shopID, + SeriesID: seriesID, + AllocatorShopID: 0, + OneTimeCommissionAmount: amount, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(allocation).Error + require.NoError(t, err, "创建平台系列分配失败") + + return allocation +} + +func createSeriesAllocationForCommission(t *testing.T, env *integ.IntegrationTestEnv, allocatorShopID, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation { + t.Helper() + + allocation := &model.ShopSeriesAllocation{ + ShopID: shopID, + SeriesID: seriesID, + AllocatorShopID: allocatorShopID, + OneTimeCommissionAmount: amount, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(allocation).Error + require.NoError(t, err, "创建系列分配失败") + + return allocation +} + +func verifyAllocationAmount(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, expectedAmount int64) { + t.Helper() + + path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d", shopID, seriesID) + + resp, err := env.AsSuperAdmin().Request("GET", path, nil) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code) + + data := result.Data.(map[string]interface{}) + list := data["items"].([]interface{}) + require.NotEmpty(t, list, "应存在分配记录") + + allocation := list[0].(map[string]interface{}) + assert.Equal(t, float64(expectedAmount), allocation["one_time_commission_amount"], + "店铺 %d 系列 %d 的佣金金额应为 %d", shopID, seriesID, expectedAmount) +} diff --git a/tests/acceptance/shop_series_allocation_acceptance_test.go b/tests/acceptance/shop_series_allocation_acceptance_test.go new file mode 100644 index 0000000..f35d70d --- /dev/null +++ b/tests/acceptance/shop_series_allocation_acceptance_test.go @@ -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, "设置系列佣金上限失败") +} diff --git a/tests/flows/README.md b/tests/flows/README.md new file mode 100644 index 0000000..70249ff --- /dev/null +++ b/tests/flows/README.md @@ -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. 考虑将部分验证移到验收测试 diff --git a/tests/flows/one_time_commission_chain_flow_test.go b/tests/flows/one_time_commission_chain_flow_test.go new file mode 100644 index 0000000..f13da34 --- /dev/null +++ b/tests/flows/one_time_commission_chain_flow_test.go @@ -0,0 +1,496 @@ +package flows + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/break/junhong_cmp_fiber/tests/testutils/integ" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================ +// 流程测试:一次性佣金链式分配 +// 来源:openspec/changes/refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md +// 参与者:平台管理员, 一级代理, 二级代理, 三级代理 +// ============================================================ + +func TestFlow_OneTimeCommissionChainAllocation(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + // ======================================================== + // 流程级共享状态 + // ======================================================== + var ( + seriesID uint + level1ShopID uint + level2ShopID uint + level3ShopID uint + level1AllocationID uint + level2AllocationID uint + level3AllocationID uint + packageID uint + level3PackageAllocID uint + ) + + // ------------------------------------------------------------ + // Step 1: 平台创建套餐系列并启用一次性佣金 + // 角色: 平台管理员 + // 调用: POST /api/admin/package-series + // 预期: 返回系列 ID,enable_one_time_commission = true + // + // 依赖: 无 + // 破坏点:如果系列创建不支持 enable_one_time_commission,后续分配无法启用 + // ------------------------------------------------------------ + t.Run("Step1_平台创建套餐系列", func(t *testing.T) { + body := map[string]interface{}{ + "series_code": fmt.Sprintf("CHAIN_SERIES_%d", time.Now().UnixNano()), + "series_name": "链式分配测试系列", + "description": "测试一次性佣金链式分配", + "status": 1, + "enable_one_time_commission": true, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/package-series", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "创建系列失败: %s", result.Message) + + data := result.Data.(map[string]interface{}) + seriesID = uint(data["id"].(float64)) + require.NotZero(t, seriesID, "系列 ID 不能为空") + }) + + // ------------------------------------------------------------ + // Step 2: 创建三级店铺层级 + // 角色: 平台管理员 + // 调用: POST /api/admin/shops (3次) + // 预期: 创建一级、二级、三级店铺 + // + // 依赖: 无 + // 破坏点:如果店铺层级关系不正确,后续分配权限检查会失败 + // ------------------------------------------------------------ + t.Run("Step2_创建三级店铺层级", func(t *testing.T) { + level1Shop := env.CreateTestShop("一级代理_链式", 1, nil) + level1ShopID = level1Shop.ID + require.NotZero(t, level1ShopID) + + level2Shop := env.CreateTestShop("二级代理_链式", 2, &level1ShopID) + level2ShopID = level2Shop.ID + require.NotZero(t, level2ShopID) + + level3Shop := env.CreateTestShop("三级代理_链式", 3, &level2ShopID) + level3ShopID = level3Shop.ID + require.NotZero(t, level3ShopID) + }) + + // ------------------------------------------------------------ + // Step 3: 平台为一级代理分配系列(金额上限 100 元) + // 角色: 平台管理员 + // 调用: POST /api/admin/shop-series-allocations + // 预期: 分配成功,one_time_commission_amount = 10000 + // + // 依赖: Step 1 的 seriesID, Step 2 的 level1ShopID + // 破坏点:如果平台无法分配系列,链式分配无法开始 + // ------------------------------------------------------------ + t.Run("Step3_平台为一级代理分配系列", func(t *testing.T) { + if seriesID == 0 || level1ShopID == 0 { + t.Skip("依赖前置步骤") + } + + body := map[string]interface{}{ + "shop_id": level1ShopID, + "series_id": seriesID, + "one_time_commission_amount": 10000, + "enable_one_time_commission": true, + "one_time_commission_trigger": "accumulated_recharge", + "one_time_commission_threshold": 100000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "平台分配失败: %s", result.Message) + + data := result.Data.(map[string]interface{}) + level1AllocationID = uint(data["id"].(float64)) + assert.Equal(t, float64(10000), data["one_time_commission_amount"]) + }) + + // ------------------------------------------------------------ + // Step 4: 一级代理为二级代理分配系列(金额上限 80 元) + // 角色: 一级代理 + // 调用: POST /api/admin/shop-series-allocations + // 预期: 分配成功,one_time_commission_amount = 8000 + // + // 依赖: Step 3 的 level1AllocationID + // 破坏点:如果一级无法为下级分配,链式传递中断 + // ------------------------------------------------------------ + t.Run("Step4_一级为二级分配系列", func(t *testing.T) { + if level1AllocationID == 0 { + t.Skip("依赖 Step 3") + } + + level1Account := env.CreateTestAccount("level1_agent", "password123", constants.UserTypeAgent, &level1ShopID, nil) + + body := map[string]interface{}{ + "shop_id": level2ShopID, + "series_id": seriesID, + "one_time_commission_amount": 8000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsUser(level1Account).Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "一级分配给二级失败: %s", result.Message) + + data := result.Data.(map[string]interface{}) + level2AllocationID = uint(data["id"].(float64)) + assert.Equal(t, float64(8000), data["one_time_commission_amount"]) + }) + + // ------------------------------------------------------------ + // Step 5: 二级代理为三级代理分配系列(金额上限 50 元) + // 角色: 二级代理 + // 调用: POST /api/admin/shop-series-allocations + // 预期: 分配成功,one_time_commission_amount = 5000 + // + // 依赖: Step 4 的 level2AllocationID + // 破坏点:如果二级无法为下级分配,链式传递中断 + // ------------------------------------------------------------ + t.Run("Step5_二级为三级分配系列", func(t *testing.T) { + if level2AllocationID == 0 { + t.Skip("依赖 Step 4") + } + + level2Account := env.CreateTestAccount("level2_agent", "password123", constants.UserTypeAgent, &level2ShopID, nil) + + body := map[string]interface{}{ + "shop_id": level3ShopID, + "series_id": seriesID, + "one_time_commission_amount": 5000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsUser(level2Account).Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "二级分配给三级失败: %s", result.Message) + + data := result.Data.(map[string]interface{}) + level3AllocationID = uint(data["id"].(float64)) + assert.Equal(t, float64(5000), data["one_time_commission_amount"]) + }) + + // ------------------------------------------------------------ + // Step 6: 验证链式分配金额正确 + // 角色: 平台管理员 + // 调用: GET /api/admin/shop-series-allocations?shop_id=xxx&series_id=xxx (3次) + // 预期: 一级 10000,二级 8000,三级 5000 + // + // 依赖: Step 3-5 的分配记录 + // 破坏点:如果金额查询不正确,佣金计算会出错 + // ------------------------------------------------------------ + t.Run("Step6_验证链式分配金额", func(t *testing.T) { + if level3AllocationID == 0 { + t.Skip("依赖前置步骤") + } + + verifyChainAllocationAmount(t, env, level1ShopID, seriesID, 10000) + verifyChainAllocationAmount(t, env, level2ShopID, seriesID, 8000) + verifyChainAllocationAmount(t, env, level3ShopID, seriesID, 5000) + }) + + // ------------------------------------------------------------ + // Step 7: 平台创建套餐并关联系列 + // 角色: 平台管理员 + // 调用: POST /api/admin/packages + // 预期: 返回套餐 ID,series_id 正确关联 + // + // 依赖: Step 1 的 seriesID + // 破坏点:如果套餐不关联系列,佣金计算无法找到配置 + // ------------------------------------------------------------ + t.Run("Step7_创建套餐", func(t *testing.T) { + if seriesID == 0 { + t.Skip("依赖 Step 1") + } + + body := map[string]interface{}{ + "package_code": fmt.Sprintf("CHAIN_PKG_%d", time.Now().UnixNano()), + "package_name": "链式分配测试套餐", + "series_id": seriesID, + "package_type": "formal", + "duration_months": 1, + "cost_price": 5000, + "suggested_retail_price": 9900, + "status": 1, + "shelf_status": 1, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/packages", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "创建套餐失败: %s", result.Message) + + data := result.Data.(map[string]interface{}) + packageID = uint(data["id"].(float64)) + require.NotZero(t, packageID) + }) + + // ------------------------------------------------------------ + // Step 8: 为三级代理分配套餐(需先有系列分配) + // 角色: 平台管理员 + // 调用: POST /api/admin/shop-package-allocations + // 预期: 分配成功,series_allocation_id 关联到系列分配 + // + // 依赖: Step 5 的 level3AllocationID, Step 7 的 packageID + // 破坏点:如果套餐分配不检查系列依赖,此测试将失败 + // ------------------------------------------------------------ + t.Run("Step8_为三级代理分配套餐", func(t *testing.T) { + if level3AllocationID == 0 || packageID == 0 { + t.Skip("依赖前置步骤") + } + + body := map[string]interface{}{ + "shop_id": level3ShopID, + "package_id": packageID, + "cost_price": 6000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-package-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code, "套餐分配失败: %s", result.Message) + + data := result.Data.(map[string]interface{}) + level3PackageAllocID = uint(data["id"].(float64)) + + if allocID, ok := data["series_allocation_id"]; ok && allocID != nil { + assert.Equal(t, float64(level3AllocationID), allocID, + "套餐分配应关联到系列分配") + } + }) + + // ------------------------------------------------------------ + // Step 9: 验证完整分配链路 + // 角色: 平台管理员 + // 调用: GET APIs + // 预期: 所有分配记录正确关联 + // + // 依赖: 所有前置步骤 + // 破坏点:如果任何环节数据不一致,此验证将失败 + // ------------------------------------------------------------ + t.Run("Step9_验证完整分配链路", func(t *testing.T) { + if level3PackageAllocID == 0 { + t.Skip("依赖前置步骤") + } + + assert.NotZero(t, seriesID, "系列已创建") + assert.NotZero(t, level1AllocationID, "一级系列分配已创建") + assert.NotZero(t, level2AllocationID, "二级系列分配已创建") + assert.NotZero(t, level3AllocationID, "三级系列分配已创建") + assert.NotZero(t, packageID, "套餐已创建") + assert.NotZero(t, level3PackageAllocID, "三级套餐分配已创建") + }) + + _ = level1AllocationID + _ = level3PackageAllocID +} + +func TestFlow_OneTimeCommissionChainAllocation_Exceptions(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + // ------------------------------------------------------------ + // 异常流程:下级金额超过上级上限 + // 预期:分配失败,返回错误 + // ------------------------------------------------------------ + t.Run("Exception_下级金额超过上级", func(t *testing.T) { + parentShop := env.CreateTestShop("超限测试_父级", 1, nil) + childShop := env.CreateTestShop("超限测试_子级", 2, &parentShop.ID) + series := createFlowTestSeries(t, env, "超限测试系列") + + createFlowPlatformAllocation(t, env, parentShop.ID, series.ID, 10000) + + body := map[string]interface{}{ + "shop_id": childShop.ID, + "series_id": series.ID, + "one_time_commission_amount": 15000, + } + jsonBody, _ := json.Marshal(body) + + parentAccount := env.CreateTestAccount("parent_agent", "password123", constants.UserTypeAgent, &parentShop.ID, nil) + + resp, err := env.AsUser(parentAccount).Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + assert.True(t, resp.StatusCode == 400 || resp.StatusCode == 403, + "超限分配应返回 400 或 403,实际: %d", resp.StatusCode) + }) + + // ------------------------------------------------------------ + // 异常流程:未分配系列就分配套餐 + // 预期:套餐分配失败 + // ------------------------------------------------------------ + t.Run("Exception_未分配系列就分配套餐", func(t *testing.T) { + shop := env.CreateTestShop("无系列分配店铺", 1, nil) + series := createFlowTestSeries(t, env, "未分配系列") + pkg := createFlowTestPackage(t, env, series.ID, "未分配测试套餐") + + body := map[string]interface{}{ + "shop_id": shop.ID, + "package_id": pkg.ID, + "cost_price": 5000, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-package-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 400, resp.StatusCode, "未分配系列时分配套餐应失败") + }) +} + +// ============================================================ +// 辅助函数 +// ============================================================ + +func verifyChainAllocationAmount(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, expectedAmount int64) { + t.Helper() + + path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d", shopID, seriesID) + + resp, err := env.AsSuperAdmin().Request("GET", path, nil) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, 0, result.Code) + + data := result.Data.(map[string]interface{}) + items := data["items"].([]interface{}) + require.NotEmpty(t, items, "店铺 %d 应存在系列 %d 的分配记录", shopID, seriesID) + + allocation := items[0].(map[string]interface{}) + assert.Equal(t, float64(expectedAmount), allocation["one_time_commission_amount"], + "店铺 %d 的佣金金额应为 %d", shopID, expectedAmount) +} + +func createFlowTestSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries { + t.Helper() + + timestamp := time.Now().UnixNano() + series := &model.PackageSeries{ + SeriesCode: fmt.Sprintf("FLOW_SERIES_%d", timestamp), + SeriesName: name, + Status: constants.StatusEnabled, + EnableOneTimeCommission: true, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(series).Error + require.NoError(t, err) + + return series +} + +func createFlowTestPackage(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, name string) *model.Package { + t.Helper() + + timestamp := time.Now().UnixNano() + pkg := &model.Package{ + PackageCode: fmt.Sprintf("FLOW_PKG_%d", timestamp), + PackageName: name, + SeriesID: seriesID, + PackageType: "formal", + DurationMonths: 1, + CostPrice: 5000, + SuggestedRetailPrice: 9900, + Status: constants.StatusEnabled, + ShelfStatus: 1, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(pkg).Error + require.NoError(t, err) + + return pkg +} + +func createFlowPlatformAllocation(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation { + t.Helper() + + allocation := &model.ShopSeriesAllocation{ + ShopID: shopID, + SeriesID: seriesID, + AllocatorShopID: 0, + OneTimeCommissionAmount: amount, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + } + + err := env.TX.Create(allocation).Error + require.NoError(t, err) + + return allocation +} diff --git a/tests/integration/device_test.go b/tests/integration/device_test.go index 4805001..9a1f2f3 100644 --- a/tests/integration/device_test.go +++ b/tests/integration/device_test.go @@ -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}, diff --git a/tests/integration/iot_card_test.go b/tests/integration/iot_card_test.go index 6d50e5e..02a8bef 100644 --- a/tests/integration/iot_card_test.go +++ b/tests/integration/iot_card_test.go @@ -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 +} diff --git a/tests/integration/my_package_test.go b/tests/integration/my_package_test.go deleted file mode 100644 index 5450444..0000000 --- a/tests/integration/my_package_test.go +++ /dev/null @@ -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 -} diff --git a/tests/integration/package_test.go b/tests/integration/package_test.go index 5b9091a..053cc49 100644 --- a/tests/integration/package_test.go +++ b/tests/integration/package_test.go @@ -416,7 +416,6 @@ func TestPackageAPI_Get(t *testing.T) { PackageName: "测试套餐", PackageType: "formal", DurationMonths: 12, - Price: 99900, Status: constants.StatusEnabled, ShelfStatus: 2, BaseModel: model.BaseModel{ @@ -452,7 +451,6 @@ func TestPackageAPI_List(t *testing.T) { PackageName: "列表测试套餐1", PackageType: "formal", DurationMonths: 12, - Price: 99900, Status: constants.StatusEnabled, ShelfStatus: 1, BaseModel: model.BaseModel{Creator: 1}, @@ -462,7 +460,6 @@ func TestPackageAPI_List(t *testing.T) { PackageName: "列表测试套餐2", PackageType: "addon", DurationMonths: 1, - Price: 9990, Status: constants.StatusEnabled, ShelfStatus: 2, BaseModel: model.BaseModel{Creator: 1}, @@ -495,7 +492,6 @@ func TestPackageAPI_Update(t *testing.T) { PackageName: "原始套餐名称", PackageType: "formal", DurationMonths: 12, - Price: 99900, Status: constants.StatusEnabled, ShelfStatus: 2, BaseModel: model.BaseModel{ @@ -538,7 +534,6 @@ func TestPackageAPI_Delete(t *testing.T) { PackageName: "测试套餐", PackageType: "formal", DurationMonths: 12, - Price: 99900, Status: constants.StatusEnabled, ShelfStatus: 2, BaseModel: model.BaseModel{ diff --git a/tests/integration/shop_package_batch_allocation_test.go b/tests/integration/shop_package_batch_allocation_test.go index 909b428..b5574d0 100644 --- a/tests/integration/shop_package_batch_allocation_test.go +++ b/tests/integration/shop_package_batch_allocation_test.go @@ -31,7 +31,6 @@ func TestBatchAllocationAPI_Create(t *testing.T) { "mode": "fixed", "value": 1000, }, - "enable_tier_commission": false, } jsonBody, _ := json.Marshal(body) @@ -59,7 +58,6 @@ func TestBatchAllocationAPI_Create(t *testing.T) { "mode": "percent", "value": 200, }, - "enable_tier_commission": false, } jsonBody, _ := json.Marshal(body) @@ -89,7 +87,6 @@ func TestBatchAllocationAPI_Create(t *testing.T) { "mode": "fixed", "value": 800, }, - "enable_tier_commission": false, } jsonBody, _ := json.Marshal(body) @@ -115,7 +112,7 @@ func TestBatchAllocationAPI_Create(t *testing.T) { "mode": "percent", "value": 150, }, - "enable_tier_commission": true, + "tier_config": map[string]interface{}{ "period_type": "monthly", "tier_type": "sales_count", @@ -149,7 +146,6 @@ func TestBatchAllocationAPI_Create(t *testing.T) { "mode": "fixed", "value": 1000, }, - "enable_tier_commission": false, } jsonBody, _ := json.Marshal(body) @@ -192,15 +188,15 @@ func createBatchTestPackages(t *testing.T, env *integ.IntegrationTestEnv, series for i := 0; i < count; i++ { pkg := &model.Package{ - PackageCode: fmt.Sprintf("BATCH_PKG_%d_%d", timestamp, i), - PackageName: fmt.Sprintf("批量测试套餐%d", i+1), - SeriesID: seriesID, - PackageType: "formal", - DurationMonths: 1, - Price: 9900 + int64(i*1000), - SuggestedCostPrice: 5000 + int64(i*500), - Status: constants.StatusEnabled, - ShelfStatus: 1, + PackageCode: fmt.Sprintf("BATCH_PKG_%d_%d", timestamp, i), + PackageName: fmt.Sprintf("批量测试套餐%d", i+1), + SeriesID: seriesID, + PackageType: "formal", + DurationMonths: 1, + CostPrice: 5000 + int64(i*500), + SuggestedRetailPrice: 9900 + int64(i*1000), + Status: constants.StatusEnabled, + ShelfStatus: 1, BaseModel: model.BaseModel{ Creator: 1, Updater: 1, diff --git a/tests/integration/shop_package_batch_pricing_test.go b/tests/integration/shop_package_batch_pricing_test.go index 8947a93..f8e8958 100644 --- a/tests/integration/shop_package_batch_pricing_test.go +++ b/tests/integration/shop_package_batch_pricing_test.go @@ -179,15 +179,14 @@ func createPricingTestPackages(t *testing.T, env *integ.IntegrationTestEnv, seri for i := 0; i < count; i++ { pkg := &model.Package{ - PackageCode: fmt.Sprintf("PRICING_PKG_%d_%d", timestamp, i), - PackageName: fmt.Sprintf("调价测试套餐%d", i+1), - SeriesID: seriesID, - PackageType: "formal", - DurationMonths: 1, - Price: 9900, - SuggestedCostPrice: 5000, - Status: constants.StatusEnabled, - ShelfStatus: 1, + PackageCode: fmt.Sprintf("PRICING_PKG_%d_%d", timestamp, i), + PackageName: fmt.Sprintf("调价测试套餐%d", i+1), + SeriesID: seriesID, + PackageType: "formal", + DurationMonths: 1, + CostPrice: 5000, + Status: constants.StatusEnabled, + ShelfStatus: 1, BaseModel: model.BaseModel{ Creator: 1, Updater: 1, @@ -207,11 +206,11 @@ func createPricingTestAllocation(t *testing.T, env *integ.IntegrationTestEnv, sh t.Helper() allocation := &model.ShopPackageAllocation{ - ShopID: shopID, - PackageID: packageID, - AllocationID: 0, - CostPrice: costPrice, - Status: constants.StatusEnabled, + ShopID: shopID, + PackageID: packageID, + AllocatorShopID: 0, + CostPrice: costPrice, + Status: constants.StatusEnabled, BaseModel: model.BaseModel{ Creator: 1, Updater: 1, diff --git a/tests/integration/shop_series_allocation_test.go b/tests/integration/shop_series_allocation_test.go deleted file mode 100644 index 952f4ce..0000000 --- a/tests/integration/shop_series_allocation_test.go +++ /dev/null @@ -1,579 +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" -) - -// ==================== 套餐系列分配 API 测试 ==================== - -func TestShopSeriesAllocationAPI_Create(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - shop := env.CreateTestShop("一级店铺", 1, nil) - series := createTestPackageSeries(t, env, "测试系列") - - t.Run("平台为一级店铺分配套餐系列", func(t *testing.T) { - body := map[string]interface{}{ - "shop_id": shop.ID, - "series_id": series.ID, - "base_commission": map[string]interface{}{ - "mode": "fixed", - "value": 1000, - }, - } - 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, "应返回成功: %s", result.Message) - - if result.Data != nil { - dataMap := result.Data.(map[string]interface{}) - assert.Equal(t, float64(shop.ID), dataMap["shop_id"]) - assert.Equal(t, float64(series.ID), dataMap["series_id"]) - if baseComm, ok := dataMap["base_commission"].(map[string]interface{}); ok { - assert.Equal(t, "fixed", baseComm["mode"]) - assert.Equal(t, float64(1000), baseComm["value"]) - } - t.Logf("创建的分配 ID: %v", dataMap["id"]) - } - }) - - t.Run("一级店铺为二级店铺分配套餐系列", func(t *testing.T) { - parentShop := env.CreateTestShop("另一个一级店铺", 1, nil) - childShop := env.CreateTestShop("二级店铺", 2, &parentShop.ID) - agentAccount := env.CreateTestAccount("agent_create", "password123", constants.UserTypeAgent, &parentShop.ID, nil) - series2 := createTestPackageSeries(t, env, "系列2") - createTestAllocation(t, env, parentShop.ID, series2.ID, 0) - - body := map[string]interface{}{ - "shop_id": childShop.ID, - "series_id": series2.ID, - "base_commission": map[string]interface{}{ - "mode": "percent", - "value": 100, - }, - } - jsonBody, _ := json.Marshal(body) - - resp, err := env.AsUser(agentAccount).Request("POST", "/api/admin/shop-series-allocations", jsonBody) - 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, "应返回成功: %s", result.Message) - }) - - t.Run("平台不能为二级店铺分配", func(t *testing.T) { - parent := env.CreateTestShop("父店铺", 1, nil) - child := env.CreateTestShop("子店铺", 2, &parent.ID) - series3 := createTestPackageSeries(t, env, "系列3") - body := map[string]interface{}{ - "shop_id": child.ID, - "series_id": series3.ID, - "base_commission": map[string]interface{}{ - "mode": "fixed", - "value": 500, - }, - } - jsonBody, _ := json.Marshal(body) - - resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) - 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) { - newShop := env.CreateTestShop("新店铺", 1, nil) - series4 := createTestPackageSeries(t, env, "系列4") - createTestAllocation(t, env, newShop.ID, series4.ID, 0) - - body := map[string]interface{}{ - "shop_id": newShop.ID, - "series_id": series4.ID, - "base_commission": map[string]interface{}{ - "mode": "fixed", - "value": 1000, - }, - } - jsonBody, _ := json.Marshal(body) - - resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) - 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 TestShopSeriesAllocationAPI_Get(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - shop := env.CreateTestShop("一级店铺", 1, nil) - series := createTestPackageSeries(t, env, "测试系列") - allocation := createTestAllocation(t, env, shop.ID, series.ID, 0) - - t.Run("获取分配详情", func(t *testing.T) { - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) - resp, err := env.AsSuperAdmin().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) - - dataMap := result.Data.(map[string]interface{}) - assert.Equal(t, float64(allocation.ID), dataMap["id"]) - assert.Equal(t, float64(shop.ID), dataMap["shop_id"]) - }) - - t.Run("获取不存在的分配", func(t *testing.T) { - resp, err := env.AsSuperAdmin().Request("GET", "/api/admin/shop-series-allocations/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 TestShopSeriesAllocationAPI_Update(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - shop := env.CreateTestShop("一级店铺", 1, nil) - series := createTestPackageSeries(t, env, "测试系列") - allocation := createTestAllocation(t, env, shop.ID, series.ID, 0) - - t.Run("更新基础佣金", func(t *testing.T) { - body := map[string]interface{}{ - "base_commission": map[string]interface{}{ - "mode": "percent", - "value": 150, - }, - } - jsonBody, _ := json.Marshal(body) - - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) - resp, err := env.AsSuperAdmin().Request("PUT", url, 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) - - dataMap := result.Data.(map[string]interface{}) - if baseComm, ok := dataMap["base_commission"].(map[string]interface{}); ok { - assert.Equal(t, "percent", baseComm["mode"]) - assert.Equal(t, float64(150), baseComm["value"]) - } - }) - - t.Run("启用梯度佣金", func(t *testing.T) { - enableTier := true - body := map[string]interface{}{ - "enable_tier_commission": &enableTier, - } - jsonBody, _ := json.Marshal(body) - - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) - resp, err := env.AsSuperAdmin().Request("PUT", url, jsonBody) - 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 TestShopSeriesAllocationAPI_Delete(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - shop := env.CreateTestShop("一级店铺", 1, nil) - series := createTestPackageSeries(t, env, "测试系列") - allocation := createTestAllocation(t, env, shop.ID, series.ID, 0) - - t.Run("删除分配", func(t *testing.T) { - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) - resp, err := env.AsSuperAdmin().Request("DELETE", 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) - - getResp, err := env.AsSuperAdmin().Request("GET", url, nil) - require.NoError(t, err) - defer getResp.Body.Close() - - var getResult response.Response - json.NewDecoder(getResp.Body).Decode(&getResult) - assert.NotEqual(t, 0, getResult.Code, "删除后应无法获取") - }) -} - -func TestShopSeriesAllocationAPI_List(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - shop1 := env.CreateTestShop("店铺1", 1, nil) - shop2 := env.CreateTestShop("店铺2", 1, nil) - series := createTestPackageSeries(t, env, "测试系列") - createTestAllocation(t, env, shop1.ID, series.ID, 0) - createTestAllocation(t, env, shop2.ID, series.ID, 0) - - t.Run("获取分配列表", func(t *testing.T) { - resp, err := env.AsSuperAdmin().Request("GET", "/api/admin/shop-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) - }) - - t.Run("按店铺ID筛选", func(t *testing.T) { - url := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d", shop1.ID) - resp, err := env.AsSuperAdmin().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("按系列ID筛选", func(t *testing.T) { - url := fmt.Sprintf("/api/admin/shop-series-allocations?series_id=%d", series.ID) - resp, err := env.AsSuperAdmin().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) - }) -} - -func TestShopSeriesAllocationAPI_UpdateStatus(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - shop := env.CreateTestShop("一级店铺", 1, nil) - series := createTestPackageSeries(t, env, "测试系列") - allocation := createTestAllocation(t, env, shop.ID, series.ID, 0) - - t.Run("禁用分配", func(t *testing.T) { - body := map[string]interface{}{ - "status": constants.StatusDisabled, - } - jsonBody, _ := json.Marshal(body) - - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d/status", allocation.ID) - resp, err := env.AsSuperAdmin().Request("PUT", url, 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) - - getURL := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) - getResp, _ := env.AsSuperAdmin().Request("GET", getURL, nil) - defer getResp.Body.Close() - - var getResult response.Response - json.NewDecoder(getResp.Body).Decode(&getResult) - dataMap := getResult.Data.(map[string]interface{}) - assert.Equal(t, float64(constants.StatusDisabled), dataMap["status"]) - }) - - t.Run("启用分配", func(t *testing.T) { - body := map[string]interface{}{ - "status": constants.StatusEnabled, - } - jsonBody, _ := json.Marshal(body) - - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d/status", allocation.ID) - resp, err := env.AsSuperAdmin().Request("PUT", url, jsonBody) - 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 TestShopSeriesAllocationAPI_OneTimeCommission(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - t.Run("创建分配-固定类型一次性佣金配置落库", func(t *testing.T) { - shop := env.CreateTestShop("一次性佣金测试店铺1", 1, nil) - series := createTestPackageSeries(t, env, "一次性佣金测试系列1") - - body := map[string]interface{}{ - "shop_id": shop.ID, - "series_id": series.ID, - "base_commission": map[string]interface{}{ - "mode": "fixed", - "value": 1000, - }, - "enable_one_time_commission": true, - "one_time_commission_config": map[string]interface{}{ - "type": "fixed", - "trigger": "accumulated_recharge", - "threshold": 10000, - "mode": "fixed", - "value": 500, - }, - } - jsonBody, _ := json.Marshal(body) - - resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) - 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, "应返回成功: %s", result.Message) - - dataMap := result.Data.(map[string]interface{}) - assert.Equal(t, true, dataMap["enable_one_time_commission"]) - if cfg, ok := dataMap["one_time_commission_config"].(map[string]interface{}); ok { - assert.Equal(t, "fixed", cfg["type"]) - assert.Equal(t, "accumulated_recharge", cfg["trigger"]) - assert.Equal(t, float64(10000), cfg["threshold"]) - assert.Equal(t, "fixed", cfg["mode"]) - assert.Equal(t, float64(500), cfg["value"]) - } else { - t.Error("一次性佣金配置应返回") - } - }) - - t.Run("创建分配-梯度类型一次性佣金配置落库", func(t *testing.T) { - shop := env.CreateTestShop("一次性佣金测试店铺2", 1, nil) - series := createTestPackageSeries(t, env, "一次性佣金测试系列2") - - body := map[string]interface{}{ - "shop_id": shop.ID, - "series_id": series.ID, - "base_commission": map[string]interface{}{ - "mode": "fixed", - "value": 1000, - }, - "enable_one_time_commission": true, - "one_time_commission_config": map[string]interface{}{ - "type": "tiered", - "trigger": "single_recharge", - "threshold": 5000, - "tiers": []map[string]interface{}{ - {"tier_type": "sales_count", "threshold": 10, "mode": "fixed", "value": 100}, - {"tier_type": "sales_count", "threshold": 50, "mode": "fixed", "value": 500}, - {"tier_type": "sales_amount", "threshold": 100000, "mode": "percent", "value": 50}, - }, - }, - } - jsonBody, _ := json.Marshal(body) - - resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) - 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, "应返回成功: %s", result.Message) - - dataMap := result.Data.(map[string]interface{}) - assert.Equal(t, true, dataMap["enable_one_time_commission"]) - if cfg, ok := dataMap["one_time_commission_config"].(map[string]interface{}); ok { - assert.Equal(t, "tiered", cfg["type"]) - assert.Equal(t, "single_recharge", cfg["trigger"]) - if tiers, ok := cfg["tiers"].([]interface{}); ok { - assert.Equal(t, 3, len(tiers), "应有3个梯度档位") - } else { - t.Error("梯度档位应返回") - } - } - }) - - t.Run("创建分配-启用一次性佣金但未提供配置应失败", func(t *testing.T) { - shop := env.CreateTestShop("一次性佣金测试店铺3", 1, nil) - series := createTestPackageSeries(t, env, "一次性佣金测试系列3") - - body := map[string]interface{}{ - "shop_id": shop.ID, - "series_id": series.ID, - "base_commission": map[string]interface{}{ - "mode": "fixed", - "value": 1000, - }, - "enable_one_time_commission": true, - } - jsonBody, _ := json.Marshal(body) - - resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) - 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) { - shop := env.CreateTestShop("一次性佣金测试店铺4", 1, nil) - series := createTestPackageSeries(t, env, "一次性佣金测试系列4") - allocation := createTestAllocation(t, env, shop.ID, series.ID, 0) - - enableOneTime := true - body := map[string]interface{}{ - "enable_one_time_commission": &enableOneTime, - "one_time_commission_config": map[string]interface{}{ - "type": "fixed", - "trigger": "accumulated_recharge", - "threshold": 20000, - "mode": "percent", - "value": 100, - }, - } - jsonBody, _ := json.Marshal(body) - - url := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) - resp, err := env.AsSuperAdmin().Request("PUT", url, jsonBody) - 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, "更新应成功: %s", result.Message) - - dataMap := result.Data.(map[string]interface{}) - assert.Equal(t, true, dataMap["enable_one_time_commission"]) - if cfg, ok := dataMap["one_time_commission_config"].(map[string]interface{}); ok { - assert.Equal(t, float64(20000), cfg["threshold"]) - assert.Equal(t, "percent", cfg["mode"]) - assert.Equal(t, float64(100), cfg["value"]) - } - }) -} - -// ==================== 梯度佣金 API 测试 ==================== - -// ==================== 权限测试 ==================== - -func TestShopSeriesAllocationAPI_Auth(t *testing.T) { - env := integ.NewIntegrationTestEnv(t) - - t.Run("未认证请求应返回错误", func(t *testing.T) { - resp, err := env.ClearAuth().Request("GET", "/api/admin/shop-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, "未认证请求应返回错误码") - }) -} - -// ==================== 辅助函数 ==================== - -// createTestPackageSeries 创建测试套餐系列 -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 -} - -// createTestAllocation 创建测试分配 -func createTestAllocation(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: 1000, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{ - Creator: 1, - Updater: 1, - }, - } - - err := env.TX.Create(allocation).Error - require.NoError(t, err, "创建测试分配失败") - - return allocation -} diff --git a/tests/testutils/db.go b/tests/testutils/db.go index 741ac38..b44df37 100644 --- a/tests/testutils/db.go +++ b/tests/testutils/db.go @@ -85,7 +85,6 @@ func GetTestDB(t *testing.T) *gorm.DB { &model.PackageSeries{}, &model.Package{}, &model.ShopPackageAllocation{}, - &model.ShopSeriesAllocation{}, &model.EnterpriseCardAuthorization{}, &model.EnterpriseDeviceAuthorization{}, &model.AssetAllocationRecord{}, diff --git a/tests/unit/commission_calculation_service_test.go b/tests/unit/commission_calculation_service_test.go deleted file mode 100644 index 07ea4c9..0000000 --- a/tests/unit/commission_calculation_service_test.go +++ /dev/null @@ -1,410 +0,0 @@ -package unit - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" - "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/constants" - "github.com/break/junhong_cmp_fiber/tests/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" -) - -func TestCommissionCalculation_AccumulatedRecharge(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - ctx := context.Background() - - t.Run("累计充值触发-每次支付都写回累计金额", func(t *testing.T) { - shop := &model.Shop{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - ShopName: "测试店铺", - ShopCode: fmt.Sprintf("SHOP_%d", time.Now().UnixNano()), - Level: 1, - Status: constants.StatusEnabled, - } - require.NoError(t, tx.Create(shop).Error) - - series := &model.PackageSeries{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()), - SeriesName: "测试系列", - Status: constants.StatusEnabled, - } - require.NoError(t, tx.Create(series).Error) - - allocation := &model.ShopSeriesAllocation{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - ShopID: shop.ID, - SeriesID: series.ID, - AllocatorShopID: 0, - BaseCommissionMode: model.CommissionModeFixed, - BaseCommissionValue: 1000, - EnableOneTimeCommission: true, - OneTimeCommissionType: model.OneTimeCommissionTypeFixed, - OneTimeCommissionTrigger: model.OneTimeCommissionTriggerAccumulatedRecharge, - OneTimeCommissionThreshold: 10000, - OneTimeCommissionMode: model.CommissionModeFixed, - OneTimeCommissionValue: 500, - Status: constants.StatusEnabled, - } - require.NoError(t, tx.Create(allocation).Error) - - card := &model.IotCard{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - ICCID: fmt.Sprintf("898600%013d", time.Now().Unix()%10000000000000), - - CardCategory: "normal", - CarrierID: 1, - CarrierType: "CMCC", - CarrierName: "中国移动", - Status: 3, - ShopID: &shop.ID, - SeriesID: &allocation.ID, - FirstCommissionPaid: false, - AccumulatedRecharge: 0, - } - require.NoError(t, tx.Create(card).Error) - - wallet := &model.Wallet{ - ResourceType: "shop", - ResourceID: shop.ID, - WalletType: "commission", - Balance: 0, - Version: 0, - Status: 1, - } - require.NoError(t, tx.Create(wallet).Error) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - - t.Run("第一次支付-累计金额更新为3000", func(t *testing.T) { - order1 := &model.Order{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - OrderNo: fmt.Sprintf("ORDER_%d_1", time.Now().UnixNano()), - OrderType: model.OrderTypeSingleCard, - BuyerType: model.BuyerTypeAgent, - BuyerID: shop.ID, - IotCardID: &card.ID, - SellerShopID: &shop.ID, - SeriesID: &series.ID, - TotalAmount: 3000, - SellerCostPrice: 2000, - PaymentStatus: model.PaymentStatusPaid, - CommissionStatus: model.CommissionStatusPending, - } - require.NoError(t, tx.Create(order1).Error) - - cardBefore, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(0), cardBefore.AccumulatedRecharge) - - alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesID) - require.NoError(t, err) - - if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { - newAccumulated := cardBefore.AccumulatedRecharge + order1.TotalAmount - err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). - Update("accumulated_recharge", newAccumulated).Error - require.NoError(t, err) - } - - cardAfter, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(3000), cardAfter.AccumulatedRecharge, "第一次支付后累计金额应为3000") - assert.False(t, cardAfter.FirstCommissionPaid, "未达阈值不应标记为已发放") - }) - - t.Run("第二次支付-累计金额更新为7000", func(t *testing.T) { - order2 := &model.Order{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - OrderNo: fmt.Sprintf("ORDER_%d_2", time.Now().UnixNano()), - OrderType: model.OrderTypeSingleCard, - BuyerType: model.BuyerTypeAgent, - BuyerID: shop.ID, - IotCardID: &card.ID, - SellerShopID: &shop.ID, - SeriesID: &series.ID, - TotalAmount: 4000, - SellerCostPrice: 3000, - PaymentStatus: model.PaymentStatusPaid, - CommissionStatus: model.CommissionStatusPending, - } - require.NoError(t, tx.Create(order2).Error) - - cardBefore, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(3000), cardBefore.AccumulatedRecharge) - - alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesID) - require.NoError(t, err) - - if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { - newAccumulated := cardBefore.AccumulatedRecharge + order2.TotalAmount - err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). - Update("accumulated_recharge", newAccumulated).Error - require.NoError(t, err) - } - - cardAfter, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(7000), cardAfter.AccumulatedRecharge, "第二次支付后累计金额应为7000") - assert.False(t, cardAfter.FirstCommissionPaid, "仍未达阈值不应标记为已发放") - }) - - t.Run("第三次支付-累计金额更新为11000且达到阈值", func(t *testing.T) { - order3 := &model.Order{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - OrderNo: fmt.Sprintf("ORDER_%d_3", time.Now().UnixNano()), - OrderType: model.OrderTypeSingleCard, - BuyerType: model.BuyerTypeAgent, - BuyerID: shop.ID, - IotCardID: &card.ID, - SellerShopID: &shop.ID, - SeriesID: &series.ID, - TotalAmount: 4000, - SellerCostPrice: 3000, - PaymentStatus: model.PaymentStatusPaid, - CommissionStatus: model.CommissionStatusPending, - } - require.NoError(t, tx.Create(order3).Error) - - cardBefore, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(7000), cardBefore.AccumulatedRecharge) - - alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesID) - require.NoError(t, err) - - if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { - newAccumulated := cardBefore.AccumulatedRecharge + order3.TotalAmount - err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). - Update("accumulated_recharge", newAccumulated).Error - require.NoError(t, err) - } - - cardAfter, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(11000), cardAfter.AccumulatedRecharge, "第三次支付后累计金额应为11000") - - if cardAfter.AccumulatedRecharge >= alloc.OneTimeCommissionThreshold && !cardAfter.FirstCommissionPaid { - assert.GreaterOrEqual(t, cardAfter.AccumulatedRecharge, alloc.OneTimeCommissionThreshold, "累计金额已达阈值") - - err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). - Update("first_commission_paid", true).Error - require.NoError(t, err) - - cardFinal, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.True(t, cardFinal.FirstCommissionPaid, "达到阈值应标记为已发放") - } - }) - - t.Run("第四次支付-累计金额继续更新但不重复发放", func(t *testing.T) { - order4 := &model.Order{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - OrderNo: fmt.Sprintf("ORDER_%d_4", time.Now().UnixNano()), - OrderType: model.OrderTypeSingleCard, - BuyerType: model.BuyerTypeAgent, - BuyerID: shop.ID, - IotCardID: &card.ID, - SellerShopID: &shop.ID, - SeriesID: &series.ID, - TotalAmount: 3000, - SellerCostPrice: 2000, - PaymentStatus: model.PaymentStatusPaid, - CommissionStatus: model.CommissionStatusPending, - } - require.NoError(t, tx.Create(order4).Error) - - cardBefore, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(11000), cardBefore.AccumulatedRecharge) - assert.True(t, cardBefore.FirstCommissionPaid, "标记应保持为true") - - alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesID) - require.NoError(t, err) - - if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { - newAccumulated := cardBefore.AccumulatedRecharge + order4.TotalAmount - err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). - Update("accumulated_recharge", newAccumulated).Error - require.NoError(t, err) - } - - cardAfter, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.Equal(t, int64(14000), cardAfter.AccumulatedRecharge, "第四次支付后累计金额应为14000") - assert.True(t, cardAfter.FirstCommissionPaid, "已发放标记不应改变") - }) - }) -} - -func TestCommissionCalculation_OneTimeCommissionLogic(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - ctx := context.Background() - - 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) - shopSeriesCommissionStatsStore := postgres.NewShopSeriesCommissionStatsStore(tx) - commissionStatsService := commission_stats.New(shopSeriesCommissionStatsStore) - logger, _ := zap.NewDevelopment() - - commCalcService := commission_calculation.New( - tx, - commissionRecordStore, - shopStore, - shopSeriesAllocationStore, - shopSeriesOneTimeCommissionTierStore, - iotCardStore, - deviceStore, - walletStore, - walletTransactionStore, - orderStore, - orderItemStore, - packageStore, - commissionStatsService, - logger, - ) - - t.Run("单次充值触发-达到阈值时发放佣金", func(t *testing.T) { - shop := &model.Shop{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - ShopName: "单次触发店铺", - ShopCode: fmt.Sprintf("SHOP_%d", time.Now().UnixNano()), - Level: 1, - Status: constants.StatusEnabled, - } - require.NoError(t, tx.Create(shop).Error) - - series := &model.PackageSeries{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()), - SeriesName: "单次触发系列", - Status: constants.StatusEnabled, - } - require.NoError(t, tx.Create(series).Error) - - allocation := &model.ShopSeriesAllocation{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - ShopID: shop.ID, - SeriesID: series.ID, - AllocatorShopID: 0, - BaseCommissionMode: model.CommissionModeFixed, - BaseCommissionValue: 500, - EnableOneTimeCommission: true, - OneTimeCommissionType: model.OneTimeCommissionTypeFixed, - OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge, - OneTimeCommissionThreshold: 5000, - OneTimeCommissionMode: model.CommissionModeFixed, - OneTimeCommissionValue: 300, - Status: constants.StatusEnabled, - } - require.NoError(t, tx.Create(allocation).Error) - - card := &model.IotCard{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - ICCID: fmt.Sprintf("898600%013d", time.Now().Unix()%10000000000000+1), - - CardCategory: "normal", - CarrierID: 1, - CarrierType: "CMCC", - CarrierName: "中国移动", - Status: 3, - ShopID: &shop.ID, - SeriesID: &allocation.ID, - FirstCommissionPaid: false, - AccumulatedRecharge: 0, - } - require.NoError(t, tx.Create(card).Error) - - wallet := &model.Wallet{ - ResourceType: "shop", - ResourceID: shop.ID, - WalletType: "commission", - Balance: 0, - Version: 0, - Status: 1, - } - require.NoError(t, tx.Create(wallet).Error) - - t.Run("单次充值未达阈值-不触发", func(t *testing.T) { - order := &model.Order{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - OrderNo: fmt.Sprintf("ORDER_%d", time.Now().UnixNano()), - OrderType: model.OrderTypeSingleCard, - BuyerType: model.BuyerTypeAgent, - BuyerID: shop.ID, - IotCardID: &card.ID, - SellerShopID: &shop.ID, - SeriesID: &series.ID, - TotalAmount: 3000, - SellerCostPrice: 2500, - PaymentStatus: model.PaymentStatusPaid, - CommissionStatus: model.CommissionStatusPending, - } - require.NoError(t, tx.Create(order).Error) - - err := commCalcService.CalculateCommission(ctx, order.ID) - require.NoError(t, err) - - cardAfter, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.False(t, cardAfter.FirstCommissionPaid, "单次充值未达阈值不应发放") - }) - - t.Run("单次充值达到阈值-触发", func(t *testing.T) { - order := &model.Order{ - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - OrderNo: fmt.Sprintf("ORDER_%d", time.Now().UnixNano()+1), - OrderType: model.OrderTypeSingleCard, - BuyerType: model.BuyerTypeAgent, - BuyerID: shop.ID, - IotCardID: &card.ID, - SellerShopID: &shop.ID, - SeriesID: &series.ID, - TotalAmount: 6000, - SellerCostPrice: 5500, - PaymentStatus: model.PaymentStatusPaid, - CommissionStatus: model.CommissionStatusPending, - } - require.NoError(t, tx.Create(order).Error) - - err := commCalcService.CalculateCommission(ctx, order.ID) - require.NoError(t, err) - - cardAfter, err := iotCardStore.GetByID(ctx, card.ID) - require.NoError(t, err) - assert.True(t, cardAfter.FirstCommissionPaid, "单次充值达到阈值应发放") - - var commRecords []model.CommissionRecord - err = tx.Where("order_id = ? AND commission_source = ?", order.ID, model.CommissionSourceOneTime). - Find(&commRecords).Error - require.NoError(t, err) - assert.Len(t, commRecords, 1, "应有一条一次性佣金记录") - assert.Equal(t, int64(300), commRecords[0].Amount, "佣金金额应为300") - }) - }) -}