From e0cb4498e6bfc3996374482b59cec7c07941f98b Mon Sep 17 00:00:00 2001 From: huang Date: Wed, 4 Mar 2026 11:37:33 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=BD=92=E6=A1=A3=20refactor-agent-ser?= =?UTF-8?q?ies-grant=20=E5=8F=98=E6=9B=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将已完成的变更(proposal、design、tasks、delta specs)归档至 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/。变更内容:合并系列分配和套餐分配为系列授权(Grant)、新增梯度佣金模式、新增代理层强充层级规则。50/50 任务全部完成。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../.openspec.yaml | 2 + .../design.md | 188 ++++++++++++++++++ .../proposal.md | 54 +++++ .../specs/agent-series-grant/spec.md | 181 +++++++++++++++++ .../specs/force-recharge-check/spec.md | 75 +++++++ .../specs/shop-series-allocation/spec.md | 37 ++++ .../tasks.md | 158 +++++++++++++++ 7 files changed, 695 insertions(+) create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/design.md create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/proposal.md create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/agent-series-grant/spec.md create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/force-recharge-check/spec.md create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/shop-series-allocation/spec.md create mode 100644 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/tasks.md diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/.openspec.yaml b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/.openspec.yaml new file mode 100644 index 0000000..85cf50d --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-03 diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/design.md b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/design.md new file mode 100644 index 0000000..e3234bb --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/design.md @@ -0,0 +1,188 @@ +## Context + +当前系统中,"给代理分配套餐"需要两步独立操作:先调用 `POST /shop-series-allocations` 创建系列分配,再多次调用 `POST /shop-package-allocations` 逐一分配套餐。 + +`ShopSeriesAllocation` 有 6 个字段存在问题: +- 前 3 个死字段(`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold`)从未被计算引擎读取,与 PackageSeries 配置完全重复 +- 强充 3 个字段(`enable_force_recharge`、`force_recharge_amount`、`force_recharge_trigger_type`)语义正确,但 `checkForceRechargeRequirement` 只读 PackageSeries,代理配置完全无效 + +梯度模式下,`calculateChainOneTimeCommission` 直接把 PackageSeries 全局 tiers 传给 `matchOneTimeCommissionTier`,即所有代理用同一套阶梯金额。但业务模型要求每个代理有自己的专属阶梯金额(上级可以把金额压低给下级,但阈值不变),数据库中完全没有存储这个信息的字段。 + +## Goals / Non-Goals + +**Goals:** +- 新增统一的"系列授权" API,一次请求原子性完成系列分配 + 套餐列表分配 +- 修复强充层级:平台未设强充时,销售代理自设的强充配置真正生效 +- 修复梯度模式数据模型:新增 `commission_tiers_json` 字段存储每代理专属阶梯金额 +- 修复梯度模式计算引擎:读取各代理自己的阶梯金额,而非全局 tiers +- 新增金额上限校验:固定模式单值天花板;梯度模式每档位天花板 +- 清理 3 个语义重复的死字段(数据库迁移删除) +- 删除旧的 `/shop-series-allocations` 和 `/shop-package-allocations` 接口,干净重构 + +**Non-Goals:** +- 不重写现有的佣金计算链式逻辑(数学结构正确,只改数据读取来源) +- 不修改 ShopPackageBatchAllocation、ShopPackageBatchPricing 这两个 Service(批量操作不在本次范围) +- 不修改差价佣金(`CalculateCostDiffCommission`)逻辑 +- 不改变数据存储底层结构(仍是两张表) + +## Decisions + +### 决策 1:新接口策略——外观层 + +**选择**:新增 `ShopSeriesGrantService`,内部事务性地调用已有 `ShopSeriesAllocationStore` 和 `ShopPackageAllocationStore`。 + +**理由**:底层两张表的数据结构不变,对外以"授权"概念聚合呈现。 + +### 决策 2:梯度模式数据模型——JSONB 字段 + +**选择**:在 `ShopSeriesAllocation` 新增 `commission_tiers_json JSONB` 字段,存储该代理的专属阶梯金额列表。 + +数据格式: +```json +[ + {"threshold": 100, "amount": 80}, + {"threshold": 150, "amount": 120} +] +``` + +- `threshold` 必须与 PackageSeries 全局 tiers 的阈值一一对应(不允许创建不存在的阈值) +- `amount` 由上级在授权时设定,不得超过上级同阈值的 amount +- 固定模式下此字段为空数组 `[]` + +**理由**:阶梯金额是每代理独立的配置,与分配记录 1:1 关联,JSONB 存储简洁无需额外联表;阈值条件(dimension、stat_scope、threshold 数值)全局一致,无需在分配层重复存储。 + +**备选方案**:新建 `tb_shop_series_allocation_tier` 子表 → 被否决,阈值不可修改且数量固定(跟随系列配置),子表增加了查询复杂度。 + +### 决策 3:梯度模式计算引擎改写 + +**原逻辑**(错误): +``` +matchOneTimeCommissionTier(ctx, shopID, seriesID, allocationID, config.Tiers) + ↑ PackageSeries 全局阶梯金额 +``` + +**新逻辑**: +``` +1. 从 PackageSeries 取 tiers(dimension、stat_scope、threshold 列表) +2. 查询当前代理的 ShopSeriesAllocation.commission_tiers_json(专属金额) +3. 根据该代理的销售统计匹配命中的 threshold +4. 从步骤 2 的专属金额列表中取出该 threshold 对应的 amount → 即 myAmount +``` + +阶梯金额的来源由全局改为各代理自己的记录,dimension/stat_scope/threshold 仍从全局读取。 + +### 决策 4:统一授权响应格式 + +`GET /shop-series-grants/:id` 返回聚合视图: + +``` +ShopSeriesGrantResponse { + id // ShopSeriesAllocation.ID + shop_id / shop_name + series_id / series_name / series_code + commission_type // "fixed" 或 "tiered",从 PackageSeries 读取 + one_time_commission_amount // 固定模式:该代理的天花板;梯度模式:返回 0 + commission_tiers // 梯度模式:专属阶梯列表;固定模式:空数组 + force_recharge_locked // true=平台已设强充,前端只读 + force_recharge_enabled + force_recharge_amount + allocator_shop_id / allocator_shop_name + status + packages: [{ + package_id, package_name, package_code, cost_price, shelf_status, status + }] + created_at / updated_at +} +``` + +### 决策 5:金额上限校验规则 + +**固定模式**: +``` +allocator_shop_id == 0(平台)→ one_time_commission_amount ≤ PackageSeries.commission_amount +allocator_shop_id > 0(代理)→ one_time_commission_amount ≤ 分配者自身的 one_time_commission_amount +``` + +**梯度模式**: +``` +对请求中每一个 {threshold, amount}: + allocator_shop_id == 0(平台)→ amount ≤ PackageSeries 同 threshold 的 amount + allocator_shop_id > 0(代理)→ amount ≤ 分配者的 commission_tiers_json 中同 threshold 的 amount +``` + +### 决策 6:强充层级的实现位置 + +在 `order/service.go` 的 `checkForceRechargeRequirement` 中增加: + +``` +1. 从 PackageSeries 获取配置,config.Enable == false → 无强充 + +2. firstCommissionPaid == true → 无强充 + +3. config.TriggerType == "first_recharge": + → 直接返回需要强充(amount = config.Threshold) + → 首次充值本身即为强充机制,代理无法修改此行为 + → 创建授权时忽略代理传入的 enable_force_recharge,响应 force_recharge_locked=true + +4. config.TriggerType == "accumulated_recharge": + a. config.EnableForceRecharge == true + → 使用平台强充(amount = config.ForceAmount),force_recharge_locked=true + b. config.EnableForceRecharge == false + → 查询 result.Card.ShopID(或 result.Device.ShopID)的 ShopSeriesAllocation + → 若 allocation.EnableForceRecharge == true → 使用代理强充配置 + → 否则 → 无强充 +``` + +**Grant API 强充字段的可写条件**: +- `TriggerType == first_recharge`:`enable_force_recharge` 字段无效,创建/更新时忽略,响应 `force_recharge_locked=true` +- `TriggerType == accumulated_recharge` + 平台已设:同上,`force_recharge_locked=true` +- `TriggerType == accumulated_recharge` + 平台未设:`enable_force_recharge` 和 `force_recharge_amount` 生效,`force_recharge_locked=false` + +### 决策 7:删除旧接口范围 + +删除以下内容(开发阶段干净重构): +- `internal/handler/admin/shop_series_allocation.go` +- `internal/handler/admin/shop_package_allocation.go` +- `internal/routes/shop_series_allocation.go` +- `internal/routes/shop_package_allocation.go` +- `internal/model/dto/shop_series_allocation.go` +- `internal/model/dto/shop_package_allocation.go` +- `internal/service/shop_series_allocation/` +- `internal/service/shop_package_allocation/` +- 从 `bootstrap/types.go`、`bootstrap/handlers.go`、`bootstrap/services.go`、`pkg/openapi/handlers.go`、`routes/admin.go` 中移除相关引用 + +保留(不删除): +- 两个 Store(`ShopSeriesAllocationStore`、`ShopPackageAllocationStore`)——被佣金计算、订单服务、新 Grant Service 使用 +- `ShopPackageBatchAllocation`、`ShopPackageBatchPricing` Service + Handler + routes(批量操作不在本次范围) + +### 决策 8:数据库迁移策略 + +单次迁移文件完成: +- 删除 3 列:`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold` +- 新增 1 列:`commission_tiers_json JSONB NOT NULL DEFAULT '[]'` + +迁移顺序:先部署代码(新代码不再读写 3 个死字段,新字段有默认值),再执行迁移,规避滚动部署期间的字段缺失问题。DOWN 脚本不可逆(不恢复数据)。 + +### 决策 9:梯度阶梯运算符支持 + +**背景**:当前 `matchOneTimeCommissionTier` 对所有阶梯固定使用 `>=` 做阈值比较,无法支持不同运算符(如"销量 < 100 给 X 元")。 + +**选择**:在 `OneTimeCommissionTier` 结构体新增 `Operator string` 字段,支持 `>`、`>=`、`<`、`<=`,默认值 `>=`(向前兼容)。 + +**存储位置**: +- `Operator` 仅存于 `PackageSeries.config.Tiers`(全局,由套餐系列配置决定) +- `ShopSeriesAllocation.commission_tiers_json` 不存 `Operator`(代理不能修改条件,只能修改金额) +- Grant 响应的 `commission_tiers` 展示时,将 `Operator` 从 PackageSeries 全局 tiers 按 threshold 合并进来 + +**计算引擎**:`matchOneTimeCommissionTier` 根据 `Operator` 选择对应比较函数;`Operator` 为空时 fallback 到 `>=`。 + +**不修改**:PackageSeries 的创建/编辑 API 不在本次范围(通过直接写 JSONB 设置 operator)。 + +## Risks / Trade-offs + +| 风险 | 缓解措施 | +|------|----------| +| 旧接口删除后前端需同步更新 | 开发阶段删除无历史数据顾虑,与前端对齐后统一上线 | +| 现有梯度模式历史分配记录的 commission_tiers_json 为空 | 新字段 DEFAULT '[]',计算引擎遇到空数组时 fallback 到 one_time_commission_amount(存 0)→ 不发佣金,需在上线前批量补填历史数据或确认历史记录均为固定模式 | +| checkForceRechargeRequirement 新增 DB 查询 | 函数已有 2 次查询,增加 1 次;量级可接受,后续可加缓存 | +| 梯度模式计算引擎改写影响正在运行的佣金计算 | 新逻辑仅在 commission_tiers_json 有值时生效;历史空记录走 fallback,不影响已发放佣金 | diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/proposal.md b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/proposal.md new file mode 100644 index 0000000..2ae08c7 --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/proposal.md @@ -0,0 +1,54 @@ +## Why + +代理套餐授权体系存在四类问题: + +1. **接口割裂**:给代理授权套餐需要两步独立 API(先创建系列分配,再多次创建套餐分配),但这本质是一个业务动作 +2. **死字段**:`ShopSeriesAllocation` 有 3 个字段(`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold`)从未被计算引擎读取,与 PackageSeries 配置语义完全重复 +3. **强充逻辑未接入**:代理自设强充的 3 个字段虽然存储,但 `checkForceRechargeRequirement` 从不读取,导致代理无论如何设置都不生效 +4. **梯度模式实现错误**:梯度佣金计算引擎用 PackageSeries 全局阶梯表(所有代理同一套金额),但业务模型要求每个代理有自己的专属阶梯金额(可压缩、不超父级同档位上限);且数据库中没有存储每代理专属阶梯金额的字段 + +## What Changes + +- **新增** 统一"系列授权"接口(`/shop-series-grants`),一次操作原子性完成系列分配 + 套餐列表分配,底层仍为两张表 +- **删除** 旧的 `/shop-series-allocations` 和 `/shop-package-allocations` 接口及其全部 Handler、routes、DTO、Service(开发阶段,干净重构) +- **修复** 强充层级:订单服务接入销售代理的 `ShopSeriesAllocation` 强充配置,实现"平台未设强充 → 代理自设生效" +- **修复** 梯度模式数据模型:`ShopSeriesAllocation` 新增 `commission_tiers_json` JSONB 字段,存储每代理专属阶梯金额 +- **修复** 梯度模式计算引擎:读取各代理自己的阶梯金额,而不是 PackageSeries 全局阶梯 +- **新增** 金额上限校验:固定模式——子级 `one_time_commission_amount` 不得超过父级;梯度模式——子级每档位金额不得超过父级同档位金额 +- **清理** 删除 3 个死字段(`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold`) + +## Capabilities + +### New Capabilities + +- `agent-series-grant`:统一代理系列授权 CRUD,单次操作包含套餐系列 + 套餐列表 + 成本价 + 一次性佣金配置(固定模式:单个上限金额;梯度模式:每档位上限金额列表)+ 强充配置(平台未设时) + +### Modified Capabilities + +- `force-recharge-check`:强充检查增加层级判断——平台系列已设强充时沿用,未设时改为读取销售代理的 `ShopSeriesAllocation` 强充配置 + +### Removed Capabilities + +- `shop-series-allocation`:旧系列分配接口全部删除(Handler、routes、DTO、Service) +- `shop-package-allocation`:旧套餐分配接口全部删除(Handler、routes、DTO、Service) + +## Impact + +**受影响的数据库表** +- `tb_shop_series_allocation`: + - 删除 3 列(enable_one_time_commission、one_time_commission_trigger、one_time_commission_threshold) + - 新增 1 列(commission_tiers_json JSONB,梯度模式专用) + +**受影响的 API** +- 新增:`POST/GET/PUT/DELETE /api/admin/shop-series-grants` 系列 +- 新增:`PUT /api/admin/shop-series-grants/:id/packages` +- 删除:`/api/admin/shop-series-allocations` 全部接口 +- 删除:`/api/admin/shop-package-allocations` 全部接口 +- 变更:强充预检接口(`/purchase-check`、`/wallet-recharge-check`)返回结果受新逻辑影响 + +**受影响的服务** +- `internal/service/order/service.go`:`checkForceRechargeRequirement` 增加代理分配查询 +- `internal/service/commission_calculation/service.go`:梯度模式改为读取各代理专属阶梯金额 +- 删除:`internal/service/shop_series_allocation/` +- 删除:`internal/service/shop_package_allocation/` +- 新增:`internal/service/shop_series_grant/` diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/agent-series-grant/spec.md b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/agent-series-grant/spec.md new file mode 100644 index 0000000..9432eb2 --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/agent-series-grant/spec.md @@ -0,0 +1,181 @@ +# Capability: 代理系列授权管理 + +## Purpose + +定义"系列授权"(Series Grant)的 CRUD 操作。系列授权将原本割裂的"系列分配"和"套餐分配"合并为一个原子操作,一次请求完成:授权代理可销售某系列下的指定套餐、设定每个套餐的成本价、配置一次性佣金(固定模式:单值天花板;梯度模式:每档位上限金额列表)和代理自设强充(平台未设时)。 + +底层仍使用 `tb_shop_series_allocation` 和 `tb_shop_package_allocation` 两张表,对外以 `ShopSeriesAllocation.ID` 作为 grant 主键。 + +--- + +## Requirements + +### Requirement: 创建系列授权(固定模式) + +系统 SHALL 提供 `POST /shop-series-grants` 接口,在一次请求中原子性创建系列分配和套餐分配列表。固定模式下 `one_time_commission_amount` MUST 必填,且不得超过分配者自身的天花板。 + +#### Scenario: 代理成功创建固定模式授权 +- **WHEN** 代理A(自身天花板=80元)为直属下级代理B 创建系列授权,commission_type=fixed,one_time_commission_amount=5000(50元),packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}] +- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocation(one_time_commission_amount=5000)和 2 条 ShopPackageAllocation,响应返回包含 packages 列表的聚合视图 + +#### Scenario: 代理B 已存在此系列授权,重复创建 +- **WHEN** 代理A 为代理B 创建系列授权,但代理B 在此系列下已有 active 授权记录 +- **THEN** 系统返回错误"该代理已存在此系列授权" + +#### Scenario: 分配者自身无此系列授权 +- **WHEN** 代理A 自身未被授权此套餐系列,尝试为代理B 创建此系列授权 +- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配" + +#### Scenario: 平台成功创建固定模式授权 +- **WHEN** 平台管理员为一级代理创建系列授权,commission_type=fixed,one_time_commission_amount=8000(80元),系列总额 commission_amount=10000(100元) +- **THEN** 系统创建授权,响应中 allocator_shop_id=0,allocator_shop_name="平台" + +#### Scenario: 固定模式 one_time_commission_amount 为必填 +- **WHEN** 请求中不包含 one_time_commission_amount +- **THEN** 系统返回参数错误"固定模式下一次性佣金额度为必填项" + +#### Scenario: 金额超过代理自身天花板 +- **WHEN** 代理A(天花板=8000分)为代理B 创建授权,one_time_commission_amount=10000 +- **THEN** 系统返回错误"一次性佣金额度不能超过上级限额" + +#### Scenario: 平台金额超过系列总额 +- **WHEN** 平台为代理A 创建授权,one_time_commission_amount=12000,但 PackageSeries.commission_amount=10000 +- **THEN** 系统返回错误"一次性佣金额度不能超过套餐系列设定的总额" + +--- + +### Requirement: 创建系列授权(梯度模式) + +系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0,不可省略该档位。 + +#### Scenario: 代理成功创建梯度模式授权 +- **WHEN** 代理A 的专属阶梯为 [{operator:">=", threshold:100, amount:80}, {operator:">=", threshold:150, amount:120}],A 为代理B 创建授权,传入 commission_tiers=[{threshold:100, amount:50}, {threshold:150, amount:100}] +- **THEN** 系统创建授权,commission_tiers_json 存储 [{threshold:100, amount:50}, {threshold:150, amount:100}],响应中 commission_tiers=[{operator:">=", threshold:100, amount:50}, {operator:">=", threshold:150, amount:100}](operator 从 PackageSeries 读取后合并) + +#### Scenario: 平台成功创建梯度模式授权 +- **WHEN** 平台为顶级代理A 创建授权,PackageSeries 阶梯为 [{operator:">=", threshold:100, amount:100}, {operator:"<", threshold:50, amount:30}],传入 commission_tiers=[{threshold:100, amount:80}, {threshold:50, amount:20}] +- **THEN** 系统创建授权,A 的专属阶梯存入 commission_tiers_json,响应中 commission_tiers 包含对应的 operator + +#### Scenario: 梯度模式某档位金额超过父级 +- **WHEN** 代理A 的阶梯第一档 amount=80,A 为 B 创建授权时传入第一档 amount=90 +- **THEN** 系统返回错误"梯度佣金档位金额不能超过上级同档位限额" + +#### Scenario: 梯度模式传入了不存在的阈值 +- **WHEN** PackageSeries 只有 threshold=100 和 150 两档,请求中传入 threshold=200 +- **THEN** 系统返回错误"阶梯阈值与系列配置不匹配" + +#### Scenario: 梯度模式 commission_tiers 为必填 +- **WHEN** 请求中不包含 commission_tiers 或为空数组 +- **THEN** 系统返回参数错误"梯度模式下必须提供阶梯金额配置" + +--- + +### Requirement: 强充配置的平台/代理层级 + +创建系列授权时,系统 SHALL 根据 PackageSeries 的触发类型和强充设置决定代理是否可自设强充。 + +#### Scenario: 首次充值触发类型,强充不可配置 +- **WHEN** PackageSeries.trigger_type=first_recharge,代理创建授权时传入任意 enable_force_recharge 值 +- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true(首次充值本身即为强充机制,无需额外配置) + +#### Scenario: 累计充值触发类型,平台已设强充,代理配置被忽略 +- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=true,代理创建授权时传入 enable_force_recharge=false +- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true + +#### Scenario: 累计充值触发类型,平台未设强充,代理可自设 +- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=false,代理创建授权时传入 enable_force_recharge=true,force_recharge_amount=10000 +- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=false,force_recharge_enabled=true,force_recharge_amount=10000 + +--- + +### Requirement: 查询系列授权详情 + +系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。 + +#### Scenario: 固定模式详情 +- **WHEN** 查询固定模式系列授权详情 +- **THEN** 响应包含 commission_type="fixed",one_time_commission_amount=有效值,commission_tiers=[] + +#### Scenario: 梯度模式详情 +- **WHEN** 查询梯度模式系列授权详情 +- **THEN** 响应包含 commission_type="tiered",one_time_commission_amount=0,commission_tiers=[{threshold, amount}, ...] + +#### Scenario: 查询不存在的授权 +- **WHEN** 查询不存在的授权 ID +- **THEN** 系统返回错误"授权记录不存在" + +--- + +### Requirement: 查询系列授权列表 + +系统 SHALL 提供 `GET /shop-series-grants` 接口,支持分页和多维度筛选,响应内嵌套餐数量摘要(不含完整套餐列表)。 + +#### Scenario: 列表查询支持按店铺和系列筛选 +- **WHEN** 传入 shop_id、series_id、allocator_shop_id 等筛选条件 +- **THEN** 仅返回符合条件的授权记录,每条记录包含 package_count + +--- + +### Requirement: 更新系列授权配置 + +系统 SHALL 提供 `PUT /shop-series-grants/:id` 接口,支持更新一次性佣金配置和强充配置。 + +#### Scenario: 固定模式更新佣金额度 +- **WHEN** 更新 one_time_commission_amount,新值不超过分配者天花板 +- **THEN** 系统更新成功 + +#### Scenario: 梯度模式更新阶梯金额 +- **WHEN** 更新 commission_tiers,每档位金额不超过分配者同档位上限 +- **THEN** 系统更新 commission_tiers_json 字段 + +#### Scenario: 更新代理自设强充(平台未设时) +- **WHEN** 平台未设强充,更新 enable_force_recharge=true,force_recharge_amount=10000 +- **THEN** 系统更新成功,后续该代理渠道下的客户须满足强充要求 + +--- + +### Requirement: 管理授权内套餐 + +系统 SHALL 提供 `PUT /shop-series-grants/:id/packages` 接口,支持添加套餐、移除套餐、更新成本价,操作在事务中完成,成功后返回 HTTP 200(无需返回完整授权视图)。 + +#### Scenario: 向授权中添加新套餐 +- **WHEN** 请求包含新的 package_id 和 cost_price,且该套餐属于此系列 +- **THEN** 系统创建新的 ShopPackageAllocation + +#### Scenario: 更新套餐成本价 +- **WHEN** 请求中套餐的 cost_price 与当前值不同 +- **THEN** 系统更新 cost_price 并写价格历史记录 + +#### Scenario: 移除授权中的套餐 +- **WHEN** 请求中某套餐标记 remove=true,且该套餐在当前授权中存在 +- **THEN** 系统软删除对应的 ShopPackageAllocation + +#### Scenario: remove=true 但套餐已不在授权中 +- **WHEN** 请求中某套餐标记 remove=true,但该套餐已被软删除或从未在此授权中 +- **THEN** 系统静默忽略该条目,不报错,继续处理其他条目 + +#### Scenario: 重新添加曾被移除的套餐 +- **WHEN** 某套餐曾经被软删除,请求中再次包含该 package_id(无 remove 标志) +- **THEN** 系统创建一条新的 ShopPackageAllocation 记录,不恢复旧记录 + +#### Scenario: 添加不属于该系列的套餐 +- **WHEN** 请求中包含不属于该系列的 package_id +- **THEN** 系统返回错误"套餐不属于该系列,无法添加到此授权" + +#### Scenario: 添加上级未授权的套餐 +- **WHEN** 代理A 尝试添加代理A 自己也未获授权的套餐 +- **THEN** 系统返回错误"无权限分配该套餐" + +--- + +### Requirement: 删除系列授权 + +系统 SHALL 提供 `DELETE /shop-series-grants/:id` 接口,删除时同步软删除所有关联的套餐分配。 + +#### Scenario: 成功删除无下级依赖的授权 +- **WHEN** 删除一个下级代理未基于此授权再分配的记录 +- **THEN** 系统软删除 ShopSeriesAllocation 和所有关联的 ShopPackageAllocation + +#### Scenario: 有下级依赖时禁止删除 +- **WHEN** 删除一个已被下级代理用于创建子授权的记录 +- **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权" diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/force-recharge-check/spec.md b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/force-recharge-check/spec.md new file mode 100644 index 0000000..62fd6a7 --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/force-recharge-check/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### Requirement: 代理层强充层级判断 + +系统 SHALL 在强充预检时按层级判断生效的强充配置:平台在 PackageSeries 中设置的强充具有最高优先级;平台未设强充时,读取客户所属销售代理(`order.SellerShopID`)对应的 ShopSeriesAllocation 强充配置。 + +#### Scenario: 平台已设强充,代理自设被忽略 +- **WHEN** PackageSeries.enable_force_recharge=true(平台层),客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=false +- **THEN** 系统使用平台强充规则,need_force_recharge=true,force_recharge_amount=平台设定值 + +#### Scenario: 平台未设强充,代理自设生效 +- **WHEN** PackageSeries.enable_force_recharge=false,客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=10000 +- **THEN** 系统使用代理A 的强充配置,need_force_recharge=true,force_recharge_amount=10000 + +#### Scenario: 平台未设强充,代理也未设强充 +- **WHEN** PackageSeries.enable_force_recharge=false,代理A 的 ShopSeriesAllocation.enable_force_recharge=false +- **THEN** 系统返回 need_force_recharge=false + +#### Scenario: 平台未设强充,查询不到销售代理分配 +- **WHEN** PackageSeries.enable_force_recharge=false,系统查询不到 SellerShop 对应的 ShopSeriesAllocation +- **THEN** 系统返回 need_force_recharge=false(降级处理,不影响购买流程) + +--- + +## MODIFIED Requirements + +### Requirement: 钱包充值预检 + +系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。强充判断 MUST 按代理层级规则执行:优先使用平台强充,平台未设时使用销售代理自设强充。 + +#### Scenario: 无强充要求 +- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=false,销售代理 ShopSeriesAllocation.enable_force_recharge=false +- **THEN** 系统返回 need_force_recharge=false + +#### Scenario: 首次充值强充(平台层) +- **WHEN** 客户查询卡钱包充值预检,PackageSeries 配置为首次充值触发,阈值 10000 分,未发放佣金 +- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="single_recharge" + +#### Scenario: 累计充值启用强充(平台层) +- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=true,force_amount=10000 +- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="accumulated_recharge" + +#### Scenario: 代理自设累计充值强充(平台未设) +- **WHEN** PackageSeries.enable_force_recharge=false,销售代理的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=8000 +- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=8000 + +#### Scenario: 一次性佣金已发放 +- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过 +- **THEN** 系统返回 need_force_recharge=false(不再强充) + +#### Scenario: 未启用一次性佣金 +- **WHEN** 客户查询卡钱包充值预检,卡关联系列未启用一次性佣金 +- **THEN** 系统返回 need_force_recharge=false + +--- + +### Requirement: 套餐购买预检 + +系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。强充判断 MUST 按代理层级规则执行。 + +#### Scenario: 无强充要求正常购买 +- **WHEN** 客户购买 90 元套餐,平台和销售代理均未设强充 +- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=false,actual_payment=9000,wallet_credit=0 + +#### Scenario: 代理自设强充,套餐价低于强充金额 +- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000 +- **THEN** 系统返回 actual_payment=10000,wallet_credit=5000 + +#### Scenario: 首次充值强充(平台层),套餐价低于阈值 +- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层) +- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=true,force_recharge_amount=10000,actual_payment=10000,wallet_credit=1000 + +#### Scenario: 购买多个套餐 +- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元 +- **THEN** 系统返回 total_package_amount=12000,actual_payment=12000,wallet_credit=0 diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/shop-series-allocation/spec.md b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/shop-series-allocation/spec.md new file mode 100644 index 0000000..cd31386 --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/specs/shop-series-allocation/spec.md @@ -0,0 +1,37 @@ +## REMOVED Requirements + +### Requirement: /shop-series-allocations 接口 + +**Reason**: 已被 `/shop-series-grants` 完全替代。开发阶段干净重构,不保留兼容接口。 + +**删除范围**: +- `internal/handler/admin/shop_series_allocation.go` +- `internal/routes/shop_series_allocation.go` +- `internal/model/dto/shop_series_allocation.go` +- `internal/service/shop_series_allocation/` +- 从 `bootstrap/types.go`、`bootstrap/handlers.go`、`bootstrap/services.go`、`pkg/openapi/handlers.go`、`routes/admin.go` 移除引用 + +**保留**:`internal/store/postgres/shop_series_allocation_store.go`(被佣金计算、订单服务、Grant Service 使用) + +--- + +### Requirement: /shop-package-allocations 接口 + +**Reason**: 套餐分配已合并进 `/shop-series-grants` 的创建和套餐管理接口。开发阶段干净重构,不保留兼容接口。 + +**删除范围**: +- `internal/handler/admin/shop_package_allocation.go` +- `internal/routes/shop_package_allocation.go` +- `internal/model/dto/shop_package_allocation.go` +- `internal/service/shop_package_allocation/` +- 从 bootstrap、openapi/handlers、routes/admin 移除引用 + +**保留**:`internal/store/postgres/shop_package_allocation_store.go`(被多处使用) + +--- + +### Requirement: 分配时配置 enable_one_time_commission 等字段 + +**Reason**: `enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold` 三个字段从未被计算引擎读取,与 PackageSeries 的配置语义完全重复。 + +**Migration**:一次性佣金是否启用由 `PackageSeries.enable_one_time_commission` 控制;分配表中仅保留 `one_time_commission_amount`(固定模式天花板)、`commission_tiers_json`(梯度模式专属阶梯)和强充 3 个字段。 diff --git a/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/tasks.md b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/tasks.md new file mode 100644 index 0000000..ac80e96 --- /dev/null +++ b/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/tasks.md @@ -0,0 +1,158 @@ +## 1. 数据库迁移文件准备 + +- [x] 1.1 使用 db-migration 规范创建迁移文件: + - **删除** `tb_shop_series_allocation` 的 3 列:`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold` + - **新增** `commission_tiers_json JSONB NOT NULL DEFAULT '[]'`(梯度模式专属阶梯金额) + - DOWN 脚本添加说明注释(不恢复数据) + +## 2. 删除旧接口(Handler / routes / DTO / Service) + +- [x] 2.1 删除文件: + - `internal/handler/admin/shop_series_allocation.go` + - `internal/handler/admin/shop_package_allocation.go` + - `internal/routes/shop_series_allocation.go` + - `internal/routes/shop_package_allocation.go` + - `internal/model/dto/shop_series_allocation.go` + - `internal/model/dto/shop_package_allocation.go` + - `internal/service/shop_series_allocation/`(整个目录) + - `internal/service/shop_package_allocation/`(整个目录) +- [x] 2.2 `internal/bootstrap/types.go`:删除 `ShopSeriesAllocation`、`ShopPackageAllocation` 两个 Handler 字段 +- [x] 2.3 `internal/bootstrap/handlers.go`:删除对应 Handler 初始化行;删除对应 service 引用 +- [x] 2.4 `internal/bootstrap/services.go`:删除 `ShopSeriesAllocation`、`ShopPackageAllocation` 两个 Service 字段及初始化;移除对应 import +- [x] 2.5 `pkg/openapi/handlers.go`:删除 `ShopSeriesAllocation`、`ShopPackageAllocation` 两行 +- [x] 2.6 `internal/routes/admin.go`:删除 `registerShopSeriesAllocationRoutes`、`registerShopPackageAllocationRoutes` 两处调用 +- [x] 2.7 运行 `go build ./...` 确认无编译错误 + +## 3. Model 更新 + +- [x] 3.1 `internal/model/shop_series_allocation.go`: + - 删除 `EnableOneTimeCommission`、`OneTimeCommissionTrigger`、`OneTimeCommissionThreshold` 三个字段 + - 新增 `CommissionTiersJSON string` 字段(JSONB,默认 `'[]'`) + - 新增辅助类型 `AllocationCommissionTier struct { Threshold int64; Amount int64 }` + - 新增 `GetCommissionTiers() ([]AllocationCommissionTier, error)` 方法 + - 新增 `SetCommissionTiers(tiers []AllocationCommissionTier) error` 方法 +- [x] 3.2 `internal/model/package.go`: + - `OneTimeCommissionTier` 新增 `Operator string` 字段(json:"operator") + - 新增运算符常量:`TierOperatorGT = ">"` / `TierOperatorGTE = ">="` / `TierOperatorLT = "<"` / `TierOperatorLTE = "<="` +- [x] 3.3 运行 `go build ./...` 确认无编译错误 + +## 4. 修复梯度模式计算引擎 + +- [x] 4.1 `internal/service/commission_calculation/service.go`: + 修改 `calculateChainOneTimeCommission` 中梯度模式分支: + - 原来:直接把 `config.Tiers`(全局)传给 `matchOneTimeCommissionTier` + - 新的:从 `currentSeriesAllocation.GetCommissionTiers()` 取专属金额列表,结合 `config.Tiers`(取 operator/dimension/stat_scope/threshold)做匹配 + - 匹配逻辑:根据代理销售统计和 tier.Operator 判断是否命中 threshold → 查专属列表同 threshold 的 amount → 即 myAmount;未命中任何阶梯时 myAmount = 0 + - commission_tiers_json 为空时(历史数据)fallback 到 `currentSeriesAllocation.OneTimeCommissionAmount` +- [x] 4.2 修改 `matchOneTimeCommissionTier`:接受 agentTiers `[]AllocationCommissionTier` 参数作为金额来源;根据 `tier.Operator` 选择对应比较逻辑(>、>=、<、<=),Operator 为空时默认 `>=` +- [x] 4.3 运行 `go build ./...` 确认无编译错误 + +## 5. 修复强充层级 + +- [x] 5.1 `internal/service/order/service.go` `checkForceRechargeRequirement()`: + - `config.TriggerType == "first_recharge"` 时:直接返回需要强充(不变),不查代理配置 + - `config.TriggerType == "accumulated_recharge"` 且 `config.EnableForceRecharge == false` 时: + 从 `result.Card.ShopID`(或 `result.Device.ShopID`)查询该代理的 `ShopSeriesAllocation`, + 若该分配 `EnableForceRecharge=true` 则返回代理强充配置,查询不到时降级返回 `need_force_recharge=false` +- [x] 5.2 验证 `GetPurchaseCheck` 调用路径已覆盖新逻辑(复用同一函数,无需额外修改) +- [x] 5.3 运行 `go build ./...` 确认无编译错误 + +## 6. 新系列授权 DTO + +- [x] 6.1 创建 `internal/model/dto/shop_series_grant_dto.go`,定义: + - `GrantPackageItem`(package_id、cost_price、remove *bool) + - `ShopSeriesGrantPackageItem`(package_id、package_name、package_code、cost_price、shelf_status、status) + - `GrantCommissionTierItem`(operator string、threshold int64、amount int64) + —— operator 仅出现在响应中(从 PackageSeries 合并),请求中不传 operator +- [x] 6.2 定义 `ShopSeriesGrantResponse`: + - id、shop_id/name、series_id/name/code、commission_type + - one_time_commission_amount(固定模式有效,梯度模式返回 0) + - commission_tiers []GrantCommissionTierItem(梯度模式有值,固定模式为空) + - force_recharge_locked、force_recharge_enabled、force_recharge_amount + - allocator_shop_id/name、status、packages、created_at、updated_at +- [x] 6.3 定义 `CreateShopSeriesGrantRequest`: + - shop_id、series_id + - one_time_commission_amount *int64(固定模式必填) + - commission_tiers []GrantCommissionTierItem(梯度模式必填) + - enable_force_recharge *bool、force_recharge_amount *int64 + - packages []GrantPackageItem +- [x] 6.4 定义 `UpdateShopSeriesGrantRequest`: + - one_time_commission_amount *int64 + - commission_tiers []GrantCommissionTierItem + - enable_force_recharge *bool、force_recharge_amount *int64 +- [x] 6.5 定义 `ManageGrantPackagesRequest`(packages []GrantPackageItem) +- [x] 6.6 定义 `ShopSeriesGrantListRequest`(page、page_size、shop_id *uint、series_id *uint、allocator_shop_id *uint、status *int)及列表 DTO(`ShopSeriesGrantListItem` 含 package_count、`ShopSeriesGrantPageResult`) + +## 7. 新系列授权 Service + +- [x] 7.1 创建 `internal/service/shop_series_grant/service.go`,定义 Service 结构及 New() 构造函数 + (依赖:db、shopSeriesAllocationStore、shopPackageAllocationStore、shopPackageAllocationPriceHistoryStore、shopStore、packageStore、packageSeriesStore) + +- [x] 7.2 实现私有方法 `getParentCeilingFixed()`:固定模式天花板查询 + - allocatorShopID=0 → 读 PackageSeries.commission_amount + - allocatorShopID>0 → 读分配者自身的 ShopSeriesAllocation.one_time_commission_amount + +- [x] 7.3 实现私有方法 `getParentCeilingTiered()`:梯度模式天花板查询 + - allocatorShopID=0 → 读 PackageSeries.config.Tiers 中各 threshold 的 amount + - allocatorShopID>0 → 读分配者自身 ShopSeriesAllocation.commission_tiers_json + +- [x] 7.4 实现 `Create()`: + - 查询 PackageSeries 确认 commission_type + - 检查重复授权:shop_id + series_id 已有 active 记录 → 错误"该代理已存在此系列授权" + - allocator 是代理时:查分配者自身的 ShopSeriesAllocation,无记录 → 错误"当前账号无此系列授权,无法向下分配" + - 固定模式:one_time_commission_amount 必填 + 天花板校验 + - 梯度模式:commission_tiers 必填 + 阶梯数量和 threshold 必须与 PackageSeries 完全一致(不多不少,amount 可为 0)+ 每档位天花板校验 + - 强充层级判断(TriggerType=first_recharge 或平台已设强充 → locked=true 忽略代理传入;仅 accumulated_recharge 且平台未设时接受代理强充配置) + - 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation + - 返回聚合响应 + +- [x] 7.5 实现 `Get()`:查询 ShopSeriesAllocation → 查 PackageSeries 取全局 tiers(含 operator)→ 关联套餐分配 → 拼装 ShopSeriesGrantResponse + (梯度模式下,commission_tiers 响应需将 agent 的 amount 与 PackageSeries tiers 的 operator 按 threshold 合并) + +- [x] 7.6 实现 `List()`:分页查询 → 统计 package_count → 返回 ShopSeriesGrantPageResult + +- [x] 7.7 实现 `Update()`: + - 固定模式:含 one_time_commission_amount 时做天花板校验 + - 梯度模式:含 commission_tiers 时做每档位天花板校验 + - 平台已设强充时忽略强充变更 + - 保存更新 + +- [x] 7.8 实现 `ManagePackages()`:事务中处理 packages 列表: + - remove=true:查找 active 的 ShopPackageAllocation,找到则软删除,找不到则静默忽略 + - 无 remove 标志:校验套餐归属和分配权限,查现有 active 记录(有则更新 cost_price+写历史,无则新建) + +- [x] 7.9 实现 `Delete()`:检查子级依赖 → 事务软删除 ShopSeriesAllocation + 所有关联 ShopPackageAllocation + +- [x] 7.10 运行 `go build ./...` 确认无编译错误 + +## 8. Handler、路由及文档生成器 + +- [x] 8.1 创建 `internal/handler/admin/shop_series_grant.go`,实现 Create、Get、List、Update、ManagePackages、Delete 六个 Handler 方法 +- [x] 8.2 创建 `internal/routes/shop_series_grant.go`,注册路由(Tag: "代理系列授权"): + - `GET /shop-series-grants` + - `POST /shop-series-grants` + - `GET /shop-series-grants/:id` + - `PUT /shop-series-grants/:id` + - `DELETE /shop-series-grants/:id` + - `PUT /shop-series-grants/:id/packages` +- [x] 8.3 运行 `go build ./...` 确认无编译错误 + +## 9. 依赖注入 & Bootstrap + +- [x] 9.1 `internal/bootstrap/types.go`:添加 `ShopSeriesGrant *admin.ShopSeriesGrantHandler` 字段 +- [x] 9.2 `internal/bootstrap/services.go`:import shop_series_grant service 包,添加字段并在 `initServices()` 中初始化 +- [x] 9.3 `internal/bootstrap/handlers.go`:添加 ShopSeriesGrant Handler 初始化 +- [x] 9.4 `pkg/openapi/handlers.go`:添加 `ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil)` +- [x] 9.5 `internal/routes/admin.go`:添加 `registerShopSeriesGrantRoutes()` 调用 +- [x] 9.6 运行 `go build ./...` 确认完整构建通过 + +## 10. 执行迁移 & 数据验证 + +- [x] 10.1 执行迁移(`make migrate-up`),用 db-migration 规范验证:3 列已删除,commission_tiers_json 列已添加 +- [x] 10.2 db-validation:创建固定模式授权(正常路径),确认 ShopSeriesAllocation 和 ShopPackageAllocation 均创建成功 +- [x] 10.3 db-validation:固定模式金额超过父级天花板时接口返回错误 +- [x] 10.4 db-validation:梯度模式创建授权,commission_tiers_json 正确写入 +- [x] 10.5 db-validation:梯度模式某档位金额超过父级同档位时接口返回错误 +- [x] 10.6 db-validation:代理自设强充后,购买预检接口返回 need_force_recharge=true,金额与代理设置一致 +- [x] 10.7 db-validation:平台系列已设强充时,代理自设强充被锁定,购买预检使用平台强充金额 +- [x] 10.8 db-validation:梯度模式阶梯含 `operator="<"` 时,销售统计低于阈值的代理命中该档位,高于阈值的代理不命中