Compare commits

...

14 Commits

Author SHA1 Message Date
de9eacd273 chore: 新增 systematic-debugging 技能,更新项目开发规范
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m27s
新增 systematic-debugging Skill(四阶段根因分析流程),在 AGENTS.md 和 CLAUDE.md 中补充触发条件说明。opencode.json 配置同步更新。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:38:01 +08:00
f40abaf93c docs: 同步 OpenSpec 主规范,新增系列授权 capability 并更新强充预检规范
三个 capability 同步:
- agent-series-grant(新建):定义系列授权 CRUD,覆盖固定/梯度佣金模式和强充层级场景
- force-recharge-check(更新):新增「代理层强充层级判断」Requirement,更新钱包充值和套餐购买预检场景以反映平台/代理层级规则
- shop-series-allocation(更新):在 REMOVED 区域追加三个已废弃接口的文档说明(/shop-series-allocations、/shop-package-allocations、enable_one_time_commission 等字段)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:37:46 +08:00
e0cb4498e6 docs: 归档 refactor-agent-series-grant 变更文档
将已完成的变更(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 <clio-agent@sisyphuslabs.ai>
2026-03-04 11:37:33 +08:00
c7b8ecfebf refactor: 佣金计算适配梯度阶梯 Operator 比较,套餐服务集成代理强充逻辑
commission_calculation: matchOneTimeCommissionTier() 接收 agentTiers 参数,根据 tier.Operator(>、>=、<、<=,默认 >=)执行对应比较逻辑,支持代理专属梯度阶梯计算。package/service: 套餐购买预检调用更新后的强充层级判断接口。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:37:02 +08:00
2ca33b7172 fix: 强充预检按平台/代理层级判断,代理自设强充在平台未设时生效
checkForceRechargeRequirement() 新增层级逻辑:平台(PackageSeries)的强充配置具有最高优先级;平台未设强充时,读取 order.SellerShopID 对应的 ShopSeriesAllocation 强充配置;两者均未设时返回 need_force_recharge=false(降级处理)。GetPurchaseCheck 复用同一函数,无需额外修改。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:36:49 +08:00
769f6b8709 refactor: 更新路由总线和 OpenAPI 文档注册
admin.go 删除 registerShopSeriesAllocationRoutes、registerShopPackageAllocationRoutes 两处调用,注册 registerShopSeriesGrantRoutes。OpenAPI handlers.go 同步移除旧 Handler 引用,注册 ShopSeriesGrant Handler 供文档生成器使用。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:36:39 +08:00
dd68d0a62b refactor: 更新 Bootstrap 注册,移除旧分配服务,接入系列授权
Types、Services、Handlers 三个文件同步:删除 ShopSeriesAllocation 和 ShopPackageAllocation 的 Handler/Service 字段及初始化逻辑,注册新的 ShopSeriesGrant Handler 和 Service。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:36:30 +08:00
c5018f110f feat: 新增系列授权 Handler 和路由(/shop-series-grants)
Handler 实现 POST /shop-series-grants(创建)、GET /shop-series-grants(列表)、GET /shop-series-grants/:id(详情)、PUT /shop-series-grants/:id(更新佣金和强充配置)、PUT /shop-series-grants/:id/packages(管理授权内套餐)、DELETE /shop-series-grants/:id(删除)六个接口。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:36:20 +08:00
ad3a7a770a feat: 新增系列授权 Service,支持固定/梯度佣金模式和代理自设强充
实现 /shop-series-grants 全套业务逻辑:
- 创建授权(固定/梯度模式):原子性创建 ShopSeriesAllocation + ShopPackageAllocation;校验分配者天花板和阶梯阈值匹配;平台创建无天花板限制
- 强充层级:首次充值类型由平台锁定;累计充值类型平台已设时代理配置被忽略,平台未设时代理可自设
- 查询(列表/详情):聚合套餐列表,梯度模式从 PackageSeries 读取 operator 合并响应
- 更新佣金和强充配置;套餐增删改(事务保证)
- 删除:有下级依赖时禁止删除

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:36:09 +08:00
beed9d25e0 refactor: 删除旧套餐系列分配和套餐分配 Service
业务逻辑已全部迁移至 shop_series_grant/service.go,旧 Service 层完整删除。底层 Store(shop_series_allocation_store、shop_package_allocation_store)保留,仍被佣金计算、订单服务和 Grant Service 使用。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:35:56 +08:00
163d01dae5 refactor: 删除旧套餐系列/套餐分配 Handler 和路由
/shop-series-allocations 和 /shop-package-allocations 接口已被 /shop-series-grants 完全替代,开发阶段干净删除,不保留兼容接口。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:35:46 +08:00
e7d52db270 refactor: 新增系列授权 DTO,删除旧套餐/系列分配 DTO
新增 ShopSeriesGrantDTO(含 packages 列表聚合视图)、CreateShopSeriesGrantRequest(支持固定/梯度模式及强充配置)、UpdateShopSeriesGrantRequest、ManageGrantPackagesRequest 等请求/响应结构。删除已被 Grant 接口取代的 ShopSeriesAllocationDTO 和 ShopPackageAllocationDTO。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:35:38 +08:00
672274f9fd refactor: 更新套餐系列分配和套餐模型,支持梯度佣金和代理强充
ShopSeriesAllocation 新增 commission_tiers_json(梯度模式专属阶梯 JSON)、enable_force_recharge(代理自设强充开关)、force_recharge_amount(强充金额,0 表示使用阈值)字段;移除与 PackageSeries 重复的三个字段。Package 模型补充 PackageSeriesID 字段,用于系列授权套餐归属校验。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:35:27 +08:00
b52744b149 feat: 新增数据库迁移,重构套餐系列分配佣金和强充字段
迁移编号 000071,在 tb_shop_series_allocation 中新增梯度佣金字段(commission_tiers_json)、代理自设强充字段(enable_force_recharge、force_recharge_amount),删除与 PackageSeries 语义重复的三个冗余字段(enable_one_time_commission、one_time_commission_trigger、one_time_commission_threshold)。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-04 11:34:55 +08:00
39 changed files with 2680 additions and 1330 deletions

View File

@@ -0,0 +1,260 @@
---
name: systematic-debugging
description: 遇到任何 bug、异常行为、报错时必须使用。在提出任何修复方案之前强制执行根因分析流程。适用于 API 报错、数据异常、业务逻辑错误、性能问题等所有技术问题。
---
# 系统化调试方法论
## 铁律
```
没有找到根因,禁止提出任何修复方案。
```
改之前先搞懂为什么坏了。猜测不是调试,验证假设才是。
---
## 什么时候用
**所有技术问题都用这个流程**
- API 接口报错4xx / 5xx
- 业务数据异常(金额不对、状态流转错误)
- 性能问题(接口慢、数据库慢查询)
- 异步任务失败Asynq 任务报错/卡住)
- 构建失败、启动失败
**尤其是以下场景**
- 时间紧迫(越急越不能瞎猜)
- "很简单的问题"(简单问题也有根因)
- 已经试了一次修复但没解决
- 不完全理解为什么出问题
---
## 四阶段流程
必须按顺序完成每个阶段,不可跳过。
### 阶段一:根因调查
**这是最重要的阶段,占整个调试时间的 60%。没完成本阶段,禁止进入阶段二。**
#### 1. 仔细阅读错误信息
- 完整阅读 stack trace不要跳过
- 注意行号、文件路径、错误码
- 很多时候答案就在错误信息里
- 检查 `logs/app.log``logs/access.log` 中的上下文
#### 2. 稳定复现
- 能稳定触发吗?精确的请求参数是什么?
- 用 curl 或 Postman 复现,记录完整的请求和响应
- 不能复现 → 收集更多数据检查日志、Redis 状态、数据库记录),**不要瞎猜**
#### 3. 检查最近改动
- `git diff` / `git log --oneline -10` 看最近改了什么
- 新加了什么依赖?改了什么配置?改了什么 SQL
- 对比改动前后的行为差异
#### 4. 逐层诊断(针对本项目架构)
本项目有明确的分层架构,问题一定出在某一层的边界:
```
请求 → Fiber Middleware → Handler → Service → Store → PostgreSQL/Redis
↑ ↑ ↑ ↑ ↑
认证/限流 参数解析 业务逻辑 SQL/缓存 数据本身
```
**在每个层边界确认数据是否正确**
```go
// Handler 层 — 请求进来的参数对不对?
logger.Info("Handler 收到请求",
zap.Any("params", req),
zap.String("request_id", requestID),
)
// Service 层 — 传给业务逻辑的数据对不对?
logger.Info("Service 开始处理",
zap.Uint("user_id", userID),
zap.Any("input", input),
)
// Store 层 — SQL 查询/写入的数据对不对?
// 开启 GORM Debug 模式查看实际 SQL
db.Debug().Where(...).Find(&result)
// Redis 层 — 缓存的数据对不对?
// 用 redis-cli 直接检查 key 的值
// GET auth:token:{token}
// GET sim:status:{iccid}
```
**跑一次 → 看日志 → 找到断裂的那一层 → 再深入该层排查。**
#### 5. 追踪数据流
如果错误深藏在调用链中:
- 坏数据从哪来的?
- 谁调用了这个函数,传了什么参数?
- 一直往上追,直到找到数据变坏的源头
- **修源头,不修症状**
---
### 阶段二:模式分析
**找到参照物,对比差异。**
#### 1. 找能用的参照
项目里有没有类似的、能正常工作的代码?
| 如果问题在... | 参照物在... |
|-------------|-----------|
| Handler 参数解析 | 其他 Handler 的相同模式 |
| Service 业务逻辑 | 同模块其他方法的实现 |
| Store SQL 查询 | 同 Store 文件中类似的查询 |
| Redis 操作 | `pkg/constants/redis.go` 中的 Key 定义 |
| 异步任务 | `internal/task/` 中其他任务处理器 |
| GORM Callback | `pkg/database/` 中的 callback 实现 |
#### 2. 逐行对比
完整阅读参考代码,不要跳读。列出每一处差异。
#### 3. 不要假设"这个不重要"
小差异经常是 bug 的根因:
- 字段标签 `gorm:"column:xxx"` 拼写不对
- `errors.New()` 用了错误的错误码
- Redis Key 函数参数传反了
- Context 里的 UserID 没取到(中间件没配)
---
### 阶段三:假设和验证
**科学方法:一次只验证一个假设。**
#### 1. 形成单一假设
明确写下:
> "我认为根因是 X因为 Y。验证方法是 Z。"
#### 2. 最小化验证
- 只改一个地方
- 一次只验证一个变量
- 不要同时修多处
#### 3. 验证结果
- 假设成立 → 进入阶段四
- 假设不成立 → 回到阶段一,用新信息重新分析
- **绝对不能在失败的修复上再叠加修复**
#### 4. 三次失败 → 停下来
如果连续 3 次假设都不成立:
**这不是 bug是架构问题。**
- 停止一切修复尝试
- 整理已知信息
- 向用户说明情况,讨论是否需要重构
- 不要再试第 4 次
---
### 阶段四:实施修复
**确认根因后,一次性修好。**
#### 1. 修根因,不修症状
```
❌ 症状修复:在 Handler 里加个 if 把坏数据过滤掉
✅ 根因修复:修 Service 层生成坏数据的逻辑
```
#### 2. 一次只改一个地方
- 不搞"顺手优化"
- 不在修 bug 的同时重构代码
- 修完 bug 就停
#### 3. 验证修复
- `go build ./...` 编译通过
- `lsp_diagnostics` 无新增错误
- 用原来复现 bug 的请求再跑一次,确认修好了
- 用 PostgreSQL MCP 工具检查数据库中的数据状态
#### 4. 清理诊断代码
- 删除阶段一加的临时诊断日志(除非它们本身就该保留)
- 确保没有 `db.Debug()` 残留在代码里
---
## 本项目常见调试场景速查
| 场景 | 首先检查 |
|------|---------|
| API 返回 401 | `logs/access.log` 中该请求的 token → Redis 中 `auth:token:{token}` 是否存在 |
| API 返回 403 | 用户类型是什么 → GORM Callback 自动过滤的条件对不对 → `middleware.CanManageShop()` 的参数 |
| 数据查不到 | GORM 数据权限过滤有没有生效 → `shop_id` / `enterprise_id` 是否正确 → 是否需要 `SkipDataPermission` |
| 金额/余额不对 | 乐观锁 version 字段 → `RowsAffected` 是否为 0 → 并发场景下的锁竞争 |
| 状态流转错误 | `WHERE status = expected` 条件更新 → 状态机是否有遗漏的路径 |
| 异步任务不执行 | Asynq Dashboard → `RedisTaskLockKey` 有没有残留 → Worker 日志 |
| 异步任务重复执行 | `RedisTaskLockKey` 的 TTL → 任务幂等性检查 |
| 分佣计算错误 | 佣金类型(差价/一次性) → 套餐级别的佣金率 → 设备级防重复分佣 |
| 套餐激活异常 | 卡状态 → 实名状态 → 主套餐排队逻辑 → 加油包绑定关系 |
| Redis 缓存不一致 | Key 的 TTL → 缓存更新时机 → 是否有手动 `Del` 清除 |
| 微信支付回调失败 | 签名验证 → 幂等性处理 → 回调 URL 是否可达 |
| GORM 查询慢 | `db.Debug()` 看实际 SQL → 是否 N+1 → 是否缺少索引 |
---
## 红线规则
如果你发现自己在想以下任何一条,**立刻停下来,回到阶段一**
| 想法 | 为什么是错的 |
|------|------------|
| "先快速修一下,回头再查" | 快速修 = 猜测。猜测 = 浪费时间。 |
| "试试改这个看看行不行" | 一次只验证一个假设,不是随机改。 |
| "大概是 X 的问题,我直接改了" | "大概"不是根因。先验证再改。 |
| "这个很简单,不用走流程" | 简单问题走流程只需要 5 分钟。不走流程可能浪费 2 小时。 |
| "我不完全理解但这应该行" | 不理解 = 没找到根因。回阶段一。 |
| "再试一次"(已经失败 2 次) | 3 次失败 = 架构问题。停下来讨论。 |
| "同时改这几个地方应该能修好" | 改多处 = 无法确认哪个是根因。一次只改一处。 |
---
## 常见借口和真相
| 借口 | 真相 |
|------|------|
| "问题很简单,不需要走流程" | 简单问题也有根因。走流程对简单问题只花 5 分钟。 |
| "太紧急了,没时间分析" | 系统化调试比乱猜快 3-5 倍。越急越要走流程。 |
| "先改了验证一下" | 这叫猜测,不叫验证。先确认根因再改。 |
| "我看到问题了,直接修" | 看到症状 ≠ 理解根因。症状修复是技术债。 |
| "改了好几个地方,反正能用了" | 不知道哪个改动修的,下次还会出问题。 |
---
## 快速参考
| 阶段 | 核心动作 | 完成标准 |
|------|---------|---------|
| **一、根因调查** | 读错误日志、复现、检查改动、逐层诊断、追踪数据流 | 能说清楚"因为 X 所以 Y" |
| **二、模式分析** | 找参照代码、逐行对比、列出差异 | 知道正确的应该长什么样 |
| **三、假设验证** | 写下假设、最小改动、单变量验证 | 假设被证实或推翻 |
| **四、实施修复** | 修根因、编译检查、请求验证、清理诊断代码 | bug 消失,无新增问题 |

View File

@@ -0,0 +1,265 @@
---
name: systematic-debugging
description: 遇到任何 bug、异常行为、报错时必须使用。在提出任何修复方案之前强制执行根因分析流程。适用于 API 报错、数据异常、业务逻辑错误、性能问题等所有技术问题。
license: MIT
metadata:
author: junhong
version: "1.0"
source: "adapted from obra/superpowers systematic-debugging"
---
# 系统化调试方法论
## 铁律
```
没有找到根因,禁止提出任何修复方案。
```
改之前先搞懂为什么坏了。猜测不是调试,验证假设才是。
---
## 什么时候用
**所有技术问题都用这个流程**
- API 接口报错4xx / 5xx
- 业务数据异常(金额不对、状态流转错误)
- 性能问题(接口慢、数据库慢查询)
- 异步任务失败Asynq 任务报错/卡住)
- 构建失败、启动失败
**尤其是以下场景**
- 时间紧迫(越急越不能瞎猜)
- "很简单的问题"(简单问题也有根因)
- 已经试了一次修复但没解决
- 不完全理解为什么出问题
---
## 四阶段流程
必须按顺序完成每个阶段,不可跳过。
### 阶段一:根因调查
**这是最重要的阶段,占整个调试时间的 60%。没完成本阶段,禁止进入阶段二。**
#### 1. 仔细阅读错误信息
- 完整阅读 stack trace不要跳过
- 注意行号、文件路径、错误码
- 很多时候答案就在错误信息里
- 检查 `logs/app.log``logs/access.log` 中的上下文
#### 2. 稳定复现
- 能稳定触发吗?精确的请求参数是什么?
- 用 curl 或 Postman 复现,记录完整的请求和响应
- 不能复现 → 收集更多数据检查日志、Redis 状态、数据库记录),**不要瞎猜**
#### 3. 检查最近改动
- `git diff` / `git log --oneline -10` 看最近改了什么
- 新加了什么依赖?改了什么配置?改了什么 SQL
- 对比改动前后的行为差异
#### 4. 逐层诊断(针对本项目架构)
本项目有明确的分层架构,问题一定出在某一层的边界:
```
请求 → Fiber Middleware → Handler → Service → Store → PostgreSQL/Redis
↑ ↑ ↑ ↑ ↑
认证/限流 参数解析 业务逻辑 SQL/缓存 数据本身
```
**在每个层边界确认数据是否正确**
```go
// Handler 层 — 请求进来的参数对不对?
logger.Info("Handler 收到请求",
zap.Any("params", req),
zap.String("request_id", requestID),
)
// Service 层 — 传给业务逻辑的数据对不对?
logger.Info("Service 开始处理",
zap.Uint("user_id", userID),
zap.Any("input", input),
)
// Store 层 — SQL 查询/写入的数据对不对?
// 开启 GORM Debug 模式查看实际 SQL
db.Debug().Where(...).Find(&result)
// Redis 层 — 缓存的数据对不对?
// 用 redis-cli 直接检查 key 的值
// GET auth:token:{token}
// GET sim:status:{iccid}
```
**跑一次 → 看日志 → 找到断裂的那一层 → 再深入该层排查。**
#### 5. 追踪数据流
如果错误深藏在调用链中:
- 坏数据从哪来的?
- 谁调用了这个函数,传了什么参数?
- 一直往上追,直到找到数据变坏的源头
- **修源头,不修症状**
---
### 阶段二:模式分析
**找到参照物,对比差异。**
#### 1. 找能用的参照
项目里有没有类似的、能正常工作的代码?
| 如果问题在... | 参照物在... |
|-------------|-----------|
| Handler 参数解析 | 其他 Handler 的相同模式 |
| Service 业务逻辑 | 同模块其他方法的实现 |
| Store SQL 查询 | 同 Store 文件中类似的查询 |
| Redis 操作 | `pkg/constants/redis.go` 中的 Key 定义 |
| 异步任务 | `internal/task/` 中其他任务处理器 |
| GORM Callback | `pkg/database/` 中的 callback 实现 |
#### 2. 逐行对比
完整阅读参考代码,不要跳读。列出每一处差异。
#### 3. 不要假设"这个不重要"
小差异经常是 bug 的根因:
- 字段标签 `gorm:"column:xxx"` 拼写不对
- `errors.New()` 用了错误的错误码
- Redis Key 函数参数传反了
- Context 里的 UserID 没取到(中间件没配)
---
### 阶段三:假设和验证
**科学方法:一次只验证一个假设。**
#### 1. 形成单一假设
明确写下:
> "我认为根因是 X因为 Y。验证方法是 Z。"
#### 2. 最小化验证
- 只改一个地方
- 一次只验证一个变量
- 不要同时修多处
#### 3. 验证结果
- 假设成立 → 进入阶段四
- 假设不成立 → 回到阶段一,用新信息重新分析
- **绝对不能在失败的修复上再叠加修复**
#### 4. 三次失败 → 停下来
如果连续 3 次假设都不成立:
**这不是 bug是架构问题。**
- 停止一切修复尝试
- 整理已知信息
- 向用户说明情况,讨论是否需要重构
- 不要再试第 4 次
---
### 阶段四:实施修复
**确认根因后,一次性修好。**
#### 1. 修根因,不修症状
```
❌ 症状修复:在 Handler 里加个 if 把坏数据过滤掉
✅ 根因修复:修 Service 层生成坏数据的逻辑
```
#### 2. 一次只改一个地方
- 不搞"顺手优化"
- 不在修 bug 的同时重构代码
- 修完 bug 就停
#### 3. 验证修复
- `go build ./...` 编译通过
- `lsp_diagnostics` 无新增错误
- 用原来复现 bug 的请求再跑一次,确认修好了
- 用 PostgreSQL MCP 工具检查数据库中的数据状态
#### 4. 清理诊断代码
- 删除阶段一加的临时诊断日志(除非它们本身就该保留)
- 确保没有 `db.Debug()` 残留在代码里
---
## 本项目常见调试场景速查
| 场景 | 首先检查 |
|------|---------|
| API 返回 401 | `logs/access.log` 中该请求的 token → Redis 中 `auth:token:{token}` 是否存在 |
| API 返回 403 | 用户类型是什么 → GORM Callback 自动过滤的条件对不对 → `middleware.CanManageShop()` 的参数 |
| 数据查不到 | GORM 数据权限过滤有没有生效 → `shop_id` / `enterprise_id` 是否正确 → 是否需要 `SkipDataPermission` |
| 金额/余额不对 | 乐观锁 version 字段 → `RowsAffected` 是否为 0 → 并发场景下的锁竞争 |
| 状态流转错误 | `WHERE status = expected` 条件更新 → 状态机是否有遗漏的路径 |
| 异步任务不执行 | Asynq Dashboard → `RedisTaskLockKey` 有没有残留 → Worker 日志 |
| 异步任务重复执行 | `RedisTaskLockKey` 的 TTL → 任务幂等性检查 |
| 分佣计算错误 | 佣金类型(差价/一次性) → 套餐级别的佣金率 → 设备级防重复分佣 |
| 套餐激活异常 | 卡状态 → 实名状态 → 主套餐排队逻辑 → 加油包绑定关系 |
| Redis 缓存不一致 | Key 的 TTL → 缓存更新时机 → 是否有手动 `Del` 清除 |
| 微信支付回调失败 | 签名验证 → 幂等性处理 → 回调 URL 是否可达 |
| GORM 查询慢 | `db.Debug()` 看实际 SQL → 是否 N+1 → 是否缺少索引 |
---
## 红线规则
如果你发现自己在想以下任何一条,**立刻停下来,回到阶段一**
| 想法 | 为什么是错的 |
|------|------------|
| "先快速修一下,回头再查" | 快速修 = 猜测。猜测 = 浪费时间。 |
| "试试改这个看看行不行" | 一次只验证一个假设,不是随机改。 |
| "大概是 X 的问题,我直接改了" | "大概"不是根因。先验证再改。 |
| "这个很简单,不用走流程" | 简单问题走流程只需要 5 分钟。不走流程可能浪费 2 小时。 |
| "我不完全理解但这应该行" | 不理解 = 没找到根因。回阶段一。 |
| "再试一次"(已经失败 2 次) | 3 次失败 = 架构问题。停下来讨论。 |
| "同时改这几个地方应该能修好" | 改多处 = 无法确认哪个是根因。一次只改一处。 |
---
## 常见借口和真相
| 借口 | 真相 |
|------|------|
| "问题很简单,不需要走流程" | 简单问题也有根因。走流程对简单问题只花 5 分钟。 |
| "太紧急了,没时间分析" | 系统化调试比乱猜快 3-5 倍。越急越要走流程。 |
| "先改了验证一下" | 这叫猜测,不叫验证。先确认根因再改。 |
| "我看到问题了,直接修" | 看到症状 ≠ 理解根因。症状修复是技术债。 |
| "改了好几个地方,反正能用了" | 不知道哪个改动修的,下次还会出问题。 |
---
## 快速参考
| 阶段 | 核心动作 | 完成标准 |
|------|---------|---------|
| **一、根因调查** | 读错误日志、复现、检查改动、逐层诊断、追踪数据流 | 能说清楚"因为 X 所以 Y" |
| **二、模式分析** | 找参照代码、逐行对比、列出差异 | 知道正确的应该长什么样 |
| **三、假设验证** | 写下假设、最小改动、单变量验证 | 假设被证实或推翻 |
| **四、实施修复** | 修根因、编译检查、请求验证、清理诊断代码 | bug 消失,无新增问题 |

View File

@@ -17,6 +17,7 @@
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
### ⚠️ 新增 Handler 时必须同步更新文档生成器

View File

@@ -17,6 +17,7 @@
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
### ⚠️ 新增 Handler 时必须同步更新文档生成器

View File

@@ -42,10 +42,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(svc.ShopSeriesAllocation),
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
H5Order: h5.NewOrderHandler(svc.Order),
H5Recharge: h5.NewRechargeHandler(svc.Recharge),

View File

@@ -32,10 +32,9 @@ import (
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
shopPackageAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_allocation"
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
shopSeriesAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_allocation"
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
)
type services struct {
@@ -65,10 +64,9 @@ type services struct {
Package *packageSvc.Service
PackageDailyRecord *packageSvc.DailyRecordService
PackageCustomerView *packageSvc.CustomerViewService
ShopSeriesAllocation *shopSeriesAllocationSvc.Service
ShopPackageAllocation *shopPackageAllocationSvc.Service
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
ShopSeriesGrant *shopSeriesGrantSvc.Service
CommissionStats *commissionStatsSvc.Service
PurchaseValidation *purchaseValidationSvc.Service
Order *orderSvc.Service
@@ -134,10 +132,9 @@ func initServices(s *stores, deps *Dependencies) *services {
Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation),
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
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),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.CardWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger),

View File

@@ -40,10 +40,9 @@ type Handlers struct {
Package *admin.PackageHandler
PackageUsage *admin.PackageUsageHandler
H5PackageUsage *h5.PackageUsageHandler
ShopSeriesAllocation *admin.ShopSeriesAllocationHandler
ShopPackageAllocation *admin.ShopPackageAllocationHandler
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
ShopSeriesGrant *admin.ShopSeriesGrantHandler
AdminOrder *admin.OrderHandler
H5Order *h5.OrderHandler
H5Recharge *h5.RechargeHandler

View File

@@ -1,137 +0,0 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopPackageAllocationService "github.com/break/junhong_cmp_fiber/internal/service/shop_package_allocation"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type ShopPackageAllocationHandler struct {
service *shopPackageAllocationService.Service
}
func NewShopPackageAllocationHandler(service *shopPackageAllocationService.Service) *ShopPackageAllocationHandler {
return &ShopPackageAllocationHandler{service: service}
}
func (h *ShopPackageAllocationHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopPackageAllocationRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
allocation, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, allocation)
}
func (h *ShopPackageAllocationHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的店铺套餐分配 ID")
}
allocation, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, allocation)
}
func (h *ShopPackageAllocationHandler) Update(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.UpdateShopPackageAllocationRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
allocation, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, allocation)
}
func (h *ShopPackageAllocationHandler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的店铺套餐分配 ID")
}
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *ShopPackageAllocationHandler) List(c *fiber.Ctx) error {
var req dto.ShopPackageAllocationListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
allocations, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, allocations, total, req.Page, req.PageSize)
}
func (h *ShopPackageAllocationHandler) 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)
}
// UpdateCostPrice 更新成本价
func (h *ShopPackageAllocationHandler) UpdateCostPrice(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的店铺套餐分配 ID")
}
type UpdateCostPriceRequest struct {
NewCostPrice int64 `json:"new_cost_price" validate:"required,min=0"`
ChangeReason string `json:"change_reason" validate:"omitempty,max=255"`
}
var req UpdateCostPriceRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.UpdateCostPrice(c.UserContext(), uint(id), req.NewCostPrice, req.ChangeReason)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,94 +0,0 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopSeriesAllocationService "github.com/break/junhong_cmp_fiber/internal/service/shop_series_allocation"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type ShopSeriesAllocationHandler struct {
service *shopSeriesAllocationService.Service
}
func NewShopSeriesAllocationHandler(service *shopSeriesAllocationService.Service) *ShopSeriesAllocationHandler {
return &ShopSeriesAllocationHandler{service: service}
}
func (h *ShopSeriesAllocationHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopSeriesAllocationRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
allocation, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, allocation)
}
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")
}
allocation, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, allocation)
}
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")
}
var req dto.UpdateShopSeriesAllocationRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
allocation, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, allocation)
}
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")
}
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *ShopSeriesAllocationHandler) List(c *fiber.Ctx) error {
var req dto.ShopSeriesAllocationListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
allocations, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, allocations, total, req.Page, req.PageSize)
}

View File

@@ -0,0 +1,131 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
grantService "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ShopSeriesGrantHandler 代理系列授权 Handler
type ShopSeriesGrantHandler struct {
service *grantService.Service
}
// NewShopSeriesGrantHandler 创建代理系列授权 Handler
func NewShopSeriesGrantHandler(service *grantService.Service) *ShopSeriesGrantHandler {
return &ShopSeriesGrantHandler{service: service}
}
// Create 创建系列授权
// POST /api/admin/shop-series-grants
func (h *ShopSeriesGrantHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopSeriesGrantRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询系列授权列表
// GET /api/admin/shop-series-grants
func (h *ShopSeriesGrantHandler) List(c *fiber.Ctx) error {
var req dto.ShopSeriesGrantListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// Get 查询系列授权详情
// GET /api/admin/shop-series-grants/:id
func (h *ShopSeriesGrantHandler) Get(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的授权ID")
}
result, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Update 更新系列授权
// PUT /api/admin/shop-series-grants/:id
func (h *ShopSeriesGrantHandler) Update(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的授权ID")
}
var req dto.UpdateShopSeriesGrantRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ManagePackages 管理授权套餐
// PUT /api/admin/shop-series-grants/:id/packages
func (h *ShopSeriesGrantHandler) ManagePackages(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的授权ID")
}
var req dto.ManageGrantPackagesRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.ManagePackages(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// Delete 删除系列授权
// DELETE /api/admin/shop-series-grants/:id
func (h *ShopSeriesGrantHandler) Delete(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的授权ID")
}
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -1,66 +0,0 @@
package dto
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:"该代理的成本价(分)"`
}
type UpdateShopPackageAllocationRequest struct {
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"该代理的成本价(分)"`
}
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"`
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 更新单套餐分配状态请求
type UpdateShopPackageAllocationStatusRequest struct {
Status int `json:"status" validate:"required,oneof=1 2" required:"true" description:"状态 (1:启用, 2:禁用)"`
}
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:"套餐编码"`
SeriesID uint `json:"series_id" description:"套餐系列ID"`
SeriesName string `json:"series_name" description:"套餐系列名称"`
SeriesAllocationID *uint `json:"series_allocation_id" description:"关联的系列分配ID"`
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID0表示平台分配"`
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
CostPrice int64 `json:"cost_price" description:"该代理的成本价(分)"`
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// ShopPackageAllocationPageResult 套餐分配分页结果
type ShopPackageAllocationPageResult struct {
List []*ShopPackageAllocationResponse `json:"list" description:"分配列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
// UpdateShopPackageAllocationParams 更新套餐分配聚合参数
type UpdateShopPackageAllocationParams struct {
IDReq
UpdateShopPackageAllocationRequest
}
// UpdateShopPackageAllocationStatusParams 更新套餐分配状态聚合参数
type UpdateShopPackageAllocationStatusParams struct {
IDReq
UpdateShopPackageAllocationStatusRequest
}

View File

@@ -1,67 +0,0 @@
package dto
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"`
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:累计充值)"`
}
type UpdateShopSeriesAllocationRequest struct {
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:禁用)"`
}
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"`
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:禁用)"`
}
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:"套餐系列名称"`
SeriesCode string `json:"series_code" description:"套餐系列编码"`
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID0表示平台分配"`
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"该代理能拿的一次性佣金金额上限(分)"`
EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
OneTimeCommissionTrigger string `json:"one_time_commission_trigger" description:"一次性佣金触发类型"`
OneTimeCommissionThreshold int64 `json:"one_time_commission_threshold" description:"一次性佣金触发阈值(分)"`
EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强制充值"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型 (1:单次充值, 2:累计充值)"`
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
type ShopSeriesAllocationPageResult struct {
List []*ShopSeriesAllocationResponse `json:"list" description:"分配列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
type UpdateShopSeriesAllocationParams struct {
IDReq
UpdateShopSeriesAllocationRequest
}

View File

@@ -0,0 +1,119 @@
package dto
// GrantPackageItem 授权套餐操作项(用于创建/管理套餐列表)
type GrantPackageItem struct {
PackageID uint `json:"package_id" validate:"required" description:"套餐ID"`
CostPrice int64 `json:"cost_price" validate:"required,min=0" description:"成本价(分)"`
Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权true=删除)"`
}
// ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息)
type ShopSeriesGrantPackageItem struct {
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageCode string `json:"package_code" description:"套餐编码"`
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"`
Status int `json:"status" description:"分配状态 1-启用 2-禁用"`
}
// GrantCommissionTierItem 梯度佣金档位operator 仅出现在响应中,来自 PackageSeries 全局配置)
type GrantCommissionTierItem struct {
Operator string `json:"operator,omitempty" description:"比较运算符(>、>=、<、<=),响应中从 PackageSeries 合并,请求中不传"`
Threshold int64 `json:"threshold" description:"阈值(与 PackageSeries 全局配置对应)"`
Amount int64 `json:"amount" description:"该代理在此档位的佣金金额(分)"`
}
// ShopSeriesGrantResponse 系列授权详情响应
type ShopSeriesGrantResponse 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:"套餐系列名称"`
SeriesCode string `json:"series_code" description:"套餐系列编码"`
CommissionType string `json:"commission_type" description:"佣金类型 fixed-固定 tiered-梯度"`
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"固定模式佣金金额(分),梯度模式返回 0"`
CommissionTiers []GrantCommissionTierItem `json:"commission_tiers" description:"梯度模式阶梯列表(固定模式为空)"`
ForceRechargeLocked bool `json:"force_recharge_locked" description:"强充是否被平台锁定true 时代理不可修改)"`
ForceRechargeEnabled bool `json:"force_recharge_enabled" description:"是否启用强充"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"`
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID0 表示平台"`
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
Status int `json:"status" description:"状态 1-启用 2-禁用"`
Packages []ShopSeriesGrantPackageItem `json:"packages" description:"已授权套餐列表"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// CreateShopSeriesGrantRequest 创建系列授权请求
type CreateShopSeriesGrantRequest struct {
ShopID uint `json:"shop_id" validate:"required" description:"被授权代理店铺ID"`
SeriesID uint `json:"series_id" validate:"required" description:"套餐系列ID"`
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"固定模式佣金金额(分),固定模式必填"`
CommissionTiers []GrantCommissionTierItem `json:"commission_tiers,omitempty" description:"梯度模式阶梯配置,梯度模式必填"`
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
Packages []GrantPackageItem `json:"packages,omitempty" description:"初始授权套餐列表"`
}
// UpdateShopSeriesGrantRequest 更新系列授权请求
type UpdateShopSeriesGrantRequest struct {
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"固定模式佣金金额(分)"`
CommissionTiers []GrantCommissionTierItem `json:"commission_tiers,omitempty" description:"梯度模式阶梯配置"`
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
}
// ManageGrantPackagesRequest 管理授权套餐请求
type ManageGrantPackagesRequest struct {
Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"`
}
// ShopSeriesGrantListRequest 系列授权列表查询请求
type ShopSeriesGrantListRequest 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"`
AllocatorShopID *uint `json:"allocator_shop_id" query:"allocator_shop_id" validate:"omitempty" description:"过滤分配者店铺ID"`
Status *int `json:"status" query:"status" validate:"omitempty" description:"过滤状态 1-启用 2-禁用"`
}
// ShopSeriesGrantListItem 系列授权列表项
type ShopSeriesGrantListItem 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:"套餐系列名称"`
CommissionType string `json:"commission_type" description:"佣金类型"`
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"固定模式佣金金额(分)"`
ForceRechargeEnabled bool `json:"force_recharge_enabled" description:"是否启用强充"`
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"`
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
PackageCount int `json:"package_count" description:"已授权套餐数量"`
Status int `json:"status" description:"状态 1-启用 2-禁用"`
CreatedAt string `json:"created_at" description:"创建时间"`
}
// ShopSeriesGrantPageResult 系列授权分页结果
type ShopSeriesGrantPageResult struct {
List []*ShopSeriesGrantListItem `json:"list" description:"授权列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
// UpdateShopSeriesGrantParams 更新系列授权聚合参数(文档生成用)
type UpdateShopSeriesGrantParams struct {
IDReq
UpdateShopSeriesGrantRequest
}
// ManageGrantPackagesParams 管理授权套餐聚合参数(文档生成用)
type ManageGrantPackagesParams struct {
IDReq
ManageGrantPackagesRequest
}

View File

@@ -119,6 +119,7 @@ type OneTimeCommissionConfig struct {
// OneTimeCommissionTier 一次性佣金梯度配置
type OneTimeCommissionTier struct {
Operator string `json:"operator"` // 阈值比较运算符:>、>=、<、<=,空值默认 >=
Dimension string `json:"dimension"`
StatScope string `json:"stat_scope"`
Threshold int64 `json:"threshold"`
@@ -141,6 +142,11 @@ const (
TierTypeSalesCount = "sales_count"
TierTypeSalesAmount = "sales_amount"
// 阈值运算符常量
TierOperatorGT = ">"
TierOperatorGTE = ">="
TierOperatorLT = "<"
TierOperatorLTE = "<="
)
func (ps *PackageSeries) GetOneTimeCommissionConfig() (*OneTimeCommissionConfig, error) {

View File

@@ -1,25 +1,60 @@
package model
import (
"encoding/json"
"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;default:0;comment:分配者店铺ID0表示平台分配" json:"allocator_shop_id"`
OneTimeCommissionAmount int64 `gorm:"column:one_time_commission_amount;type:bigint;default:0;not null;comment:该代理能拿的一次性佣金金额上限(分)" json:"one_time_commission_amount"`
EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;default:false;not null;comment:是否启用一次性佣金" json:"enable_one_time_commission"`
OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(50);comment:一次性佣金触发类型" json:"one_time_commission_trigger"`
OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;not null;comment:一次性佣金触发阈值(分)" json:"one_time_commission_threshold"`
EnableForceRecharge bool `gorm:"column:enable_force_recharge;default:false;not null;comment:是否启用强制充值" json:"enable_force_recharge"`
ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;not null;comment:强制充值金额(分)" json:"force_recharge_amount"`
ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;not null;comment:强充触发类型 1-单次充值 2-累计充值" json:"force_recharge_trigger_type"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
BaseModel `gorm:"embedded"`
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
SeriesID uint `gorm:"column:series_id;index;not null;comment:套餐系列ID" json:"series_id"`
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID0表示平台分配" json:"allocator_shop_id"`
OneTimeCommissionAmount int64 `gorm:"column:one_time_commission_amount;type:bigint;default:0;not null;comment:该代理能拿的一次性佣金金额上限(分),固定模式有效" json:"one_time_commission_amount"`
CommissionTiersJSON string `gorm:"column:commission_tiers_json;type:jsonb;default:'[]';not null;comment:梯度模式专属阶梯金额列表" json:"commission_tiers_json"`
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"
}
// AllocationCommissionTier 代理专属梯度佣金档位(仅存金额,阈值和运算符从 PackageSeries 全局配置读取)
type AllocationCommissionTier struct {
Threshold int64 `json:"threshold"` // 阈值(与 PackageSeries.Tiers 对应)
Amount int64 `json:"amount"` // 该代理在此档位的佣金上限(分)
}
// GetCommissionTiers 解析梯度佣金阶梯列表
func (a *ShopSeriesAllocation) GetCommissionTiers() ([]AllocationCommissionTier, error) {
if a.CommissionTiersJSON == "" || a.CommissionTiersJSON == "[]" {
return []AllocationCommissionTier{}, nil
}
var tiers []AllocationCommissionTier
if err := json.Unmarshal([]byte(a.CommissionTiersJSON), &tiers); err != nil {
return nil, err
}
return tiers, nil
}
// SetCommissionTiers 序列化梯度佣金阶梯列表
func (a *ShopSeriesAllocation) SetCommissionTiers(tiers []AllocationCommissionTier) error {
if len(tiers) == 0 {
a.CommissionTiersJSON = "[]"
return nil
}
data, err := json.Marshal(tiers)
if err != nil {
return err
}
a.CommissionTiersJSON = string(data)
return nil
}

View File

@@ -77,18 +77,15 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PackageUsage != nil {
registerPackageUsageRoutes(authGroup, handlers.PackageUsage, doc, basePath)
}
if handlers.ShopSeriesAllocation != nil {
registerShopSeriesAllocationRoutes(authGroup, handlers.ShopSeriesAllocation, doc, basePath)
}
if handlers.ShopPackageAllocation != nil {
registerShopPackageAllocationRoutes(authGroup, handlers.ShopPackageAllocation, doc, basePath)
}
if handlers.ShopPackageBatchAllocation != nil {
registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath)
}
if handlers.ShopPackageBatchPricing != nil {
registerShopPackageBatchPricingRoutes(authGroup, handlers.ShopPackageBatchPricing, doc, basePath)
}
if handlers.ShopSeriesGrant != nil {
registerShopSeriesGrantRoutes(authGroup, handlers.ShopSeriesGrant, doc, basePath)
}
if handlers.AdminOrder != nil {
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
}

View File

@@ -1,70 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerShopPackageAllocationRoutes(router fiber.Router, handler *admin.ShopPackageAllocationHandler, doc *openapi.Generator, basePath string) {
allocations := router.Group("/shop-package-allocations")
groupPath := basePath + "/shop-package-allocations"
Register(allocations, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "单套餐分配列表",
Tags: []string{"套餐分配"},
Input: new(dto.ShopPackageAllocationListRequest),
Output: new(dto.ShopPackageAllocationPageResult),
Auth: true,
})
Register(allocations, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建单套餐分配",
Tags: []string{"套餐分配"},
Input: new(dto.CreateShopPackageAllocationRequest),
Output: new(dto.ShopPackageAllocationResponse),
Auth: true,
})
Register(allocations, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
Summary: "获取单套餐分配详情",
Tags: []string{"套餐分配"},
Input: new(dto.IDReq),
Output: new(dto.ShopPackageAllocationResponse),
Auth: true,
})
Register(allocations, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新单套餐分配",
Tags: []string{"套餐分配"},
Input: new(dto.UpdateShopPackageAllocationParams),
Output: new(dto.ShopPackageAllocationResponse),
Auth: true,
})
Register(allocations, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
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,
})
Register(allocations, doc, groupPath, "PUT", "/:id/cost-price", handler.UpdateCostPrice, RouteSpec{
Summary: "更新单套餐分配成本价",
Tags: []string{"套餐分配"},
Input: new(dto.IDReq),
Output: new(dto.ShopPackageAllocationResponse),
Auth: true,
})
}

View File

@@ -1,54 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
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{"套餐分配"},
Input: new(dto.ShopSeriesAllocationListRequest),
Output: new(dto.ShopSeriesAllocationPageResult),
Auth: true,
})
Register(allocations, doc, groupPath, "POST", "", handler.Create, RouteSpec{
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{"套餐分配"},
Input: new(dto.IDReq),
Output: new(dto.ShopSeriesAllocationResponse),
Auth: true,
})
Register(allocations, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
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{"套餐分配"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,62 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerShopSeriesGrantRoutes(router fiber.Router, handler *admin.ShopSeriesGrantHandler, doc *openapi.Generator, basePath string) {
grants := router.Group("/shop-series-grants")
groupPath := basePath + "/shop-series-grants"
Register(grants, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询代理系列授权列表",
Tags: []string{"代理系列授权"},
Input: new(dto.ShopSeriesGrantListRequest),
Output: new(dto.ShopSeriesGrantPageResult),
Auth: true,
})
Register(grants, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建代理系列授权",
Tags: []string{"代理系列授权"},
Input: new(dto.CreateShopSeriesGrantRequest),
Output: new(dto.ShopSeriesGrantResponse),
Auth: true,
})
Register(grants, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
Summary: "查询代理系列授权详情",
Tags: []string{"代理系列授权"},
Input: new(dto.IDReq),
Output: new(dto.ShopSeriesGrantResponse),
Auth: true,
})
Register(grants, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新代理系列授权",
Tags: []string{"代理系列授权"},
Input: new(dto.UpdateShopSeriesGrantParams),
Output: new(dto.ShopSeriesGrantResponse),
Auth: true,
})
Register(grants, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除代理系列授权",
Tags: []string{"代理系列授权"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(grants, doc, groupPath, "PUT", "/:id/packages", handler.ManagePackages, RouteSpec{
Summary: "管理授权套餐(新增/更新/删除)",
Tags: []string{"代理系列授权"},
Input: new(dto.ManageGrantPackagesParams),
Output: new(dto.ShopSeriesGrantResponse),
Auth: true,
})
}

View File

@@ -449,12 +449,23 @@ func (s *Service) calculateChainOneTimeCommission(ctx context.Context, bottomSho
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))
// 获取该代理的专属阶梯金额列表
agentTiers, tiersErr := currentSeriesAllocation.GetCommissionTiers()
if tiersErr != nil {
s.logger.Warn("解析代理阶梯佣金失败,使用固定金额", zap.Uint("shop_id", currentShopID), zap.Error(tiersErr))
myAmount = currentSeriesAllocation.OneTimeCommissionAmount
} else if len(agentTiers) == 0 {
// commission_tiers_json 为空(历史数据),降级到 OneTimeCommissionAmount
s.logger.Warn("代理专属阶梯为空fallback 到固定金额", zap.Uint("shop_id", currentShopID))
myAmount = currentSeriesAllocation.OneTimeCommissionAmount
} else {
myAmount = tieredAmount
tieredAmount, tierErr := s.matchOneTimeCommissionTier(ctx, currentShopID, seriesID, currentSeriesAllocation.ID, config.Tiers, agentTiers)
if tierErr != nil {
s.logger.Warn("匹配梯度佣金失败,使用固定金额", zap.Uint("shop_id", currentShopID), zap.Error(tierErr))
myAmount = currentSeriesAllocation.OneTimeCommissionAmount
} else {
myAmount = tieredAmount
}
}
} else {
myAmount = currentSeriesAllocation.OneTimeCommissionAmount
@@ -512,7 +523,7 @@ func (s *Service) calculateChainOneTimeCommission(ctx context.Context, bottomSho
return records, nil
}
func (s *Service) matchOneTimeCommissionTier(ctx context.Context, shopID uint, seriesID uint, allocationID uint, tiers []model.OneTimeCommissionTier) (int64, error) {
func (s *Service) matchOneTimeCommissionTier(ctx context.Context, shopID uint, seriesID uint, allocationID uint, tiers []model.OneTimeCommissionTier, agentTiers []model.AllocationCommissionTier) (int64, error) {
if len(tiers) == 0 {
return 0, nil
}
@@ -553,8 +564,29 @@ func (s *Service) matchOneTimeCommissionTier(ctx context.Context, shopID uint, s
currentValue = salesAmount
}
if currentValue >= tier.Threshold && tier.Amount > matchedAmount {
matchedAmount = tier.Amount
// 根据 tier.Operator 判断是否命中阈值Operator 为空时默认 >=
var hit bool
switch tier.Operator {
case model.TierOperatorGT:
hit = currentValue > tier.Threshold
case model.TierOperatorLT:
hit = currentValue < tier.Threshold
case model.TierOperatorLTE:
hit = currentValue <= tier.Threshold
default: // >= 或空字符串
hit = currentValue >= tier.Threshold
}
if !hit {
continue
}
// 从代理专属阶梯列表中按 threshold 查找对应金额
for _, agentTier := range agentTiers {
if agentTier.Threshold == tier.Threshold && agentTier.Amount > matchedAmount {
matchedAmount = agentTier.Amount
break
}
}
}

View File

@@ -2123,20 +2123,27 @@ type ForceRechargeRequirement struct {
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
// 1. 获取 seriesID
// 1. 获取 seriesID 和卖家店铺 ID
var seriesID *uint
var firstCommissionPaid bool
var sellerShopID uint
if result.Card != nil {
seriesID = result.Card.SeriesID
if seriesID != nil {
firstCommissionPaid = result.Card.IsFirstRechargeTriggeredBySeries(*seriesID)
}
if result.Card.ShopID != nil {
sellerShopID = *result.Card.ShopID
}
} else if result.Device != nil {
seriesID = result.Device.SeriesID
if seriesID != nil {
firstCommissionPaid = result.Device.IsFirstRechargeTriggeredBySeries(*seriesID)
}
if result.Device.ShopID != nil {
sellerShopID = *result.Device.ShopID
}
}
if seriesID == nil {
@@ -2169,7 +2176,7 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *pur
}
}
// 5. 累计充值模式,检查是否启用强充
// 5. 累计充值模式,检查平台是否启用强充
if config.EnableForceRecharge {
forceAmount := config.ForceAmount
if forceAmount == 0 {
@@ -2182,6 +2189,23 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *pur
}
}
// 6. 平台未设强充,查询卖家代理的 ShopSeriesAllocation判断代理是否自设强充
// 仅在累计充值模式且平台未启用时,代理强充配置才生效
if sellerShopID > 0 {
agentAllocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID)
if allocErr == nil && agentAllocation.EnableForceRecharge {
agentForceAmount := agentAllocation.ForceRechargeAmount
if agentForceAmount == 0 {
agentForceAmount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: agentForceAmount,
TriggerType: model.OneTimeCommissionTriggerAccumulatedRecharge,
}
}
}
return defaultResult
}

View File

@@ -616,8 +616,8 @@ func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, s
return
}
// 检查是否启用一次性佣金
if !seriesAllocation.EnableOneTimeCommission || !config.Enable {
// 一次性佣金是否启用由 PackageSeries.enable_one_time_commission 控制
if !config.Enable {
return
}

View File

@@ -1,432 +0,0 @@
package shop_package_allocation
import (
"context"
"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"
"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"
"gorm.io/gorm"
)
type Service struct {
packageAllocationStore *postgres.ShopPackageAllocationStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore
priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore
shopStore *postgres.ShopStore
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
}
func New(
packageAllocationStore *postgres.ShopPackageAllocationStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore,
shopStore *postgres.ShopStore,
packageStore *postgres.PackageStore,
packageSeriesStore *postgres.PackageSeriesStore,
) *Service {
return &Service{
packageAllocationStore: packageAllocationStore,
seriesAllocationStore: seriesAllocationStore,
priceHistoryStore: priceHistoryStore,
shopStore: shopStore,
packageStore: packageStore,
packageSeriesStore: packageSeriesStore,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateShopPackageAllocationRequest) (*dto.ShopPackageAllocationResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
userType := middleware.GetUserTypeFromContext(ctx)
allocatorShopID := middleware.GetShopIDFromContext(ctx)
if userType == constants.UserTypeAgent && allocatorShopID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
}
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
}
if userType == constants.UserTypeAgent {
if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
return nil, errors.New(errors.CodeForbidden, "只能为直属下级分配套餐")
}
}
pkg, err := s.packageStore.GetByID(ctx, req.PackageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
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.CodeInvalidParam, "请先分配该套餐所属的系列")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取系列分配失败")
}
allocation := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: req.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: req.CostPrice,
SeriesAllocationID: &seriesAllocation.ID,
Status: constants.StatusEnabled,
}
allocation.Creator = currentUserID
if err := s.packageAllocationStore.Create(ctx, allocation); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建分配失败")
}
return s.buildResponse(ctx, allocation, targetShop.ShopName, pkg.PackageName, pkg.PackageCode)
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.ShopPackageAllocationResponse, error) {
allocation, err := s.packageAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, allocation.PackageID)
shopName := ""
packageName := ""
packageCode := ""
if shop != nil {
shopName = shop.ShopName
}
if pkg != nil {
packageName = pkg.PackageName
packageCode = pkg.PackageCode
}
return s.buildResponse(ctx, allocation, shopName, packageName, packageCode)
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopPackageAllocationRequest) (*dto.ShopPackageAllocationResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
allocation, err := s.packageAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
if req.CostPrice != nil {
allocation.CostPrice = *req.CostPrice
}
allocation.Updater = currentUserID
if err := s.packageAllocationStore.Update(ctx, allocation); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新分配失败")
}
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, allocation.PackageID)
shopName := ""
packageName := ""
packageCode := ""
if shop != nil {
shopName = shop.ShopName
}
if pkg != nil {
packageName = pkg.PackageName
packageCode = pkg.PackageCode
}
return s.buildResponse(ctx, allocation, shopName, packageName, packageCode)
}
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.packageAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "分配记录不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
if err := s.packageAllocationStore.Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除分配失败")
}
return nil
}
func (s *Service) List(ctx context.Context, req *dto.ShopPackageAllocationListRequest) ([]*dto.ShopPackageAllocationResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
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
}
allocations, total, err := s.packageAllocationStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询分配列表失败")
}
responses := make([]*dto.ShopPackageAllocationResponse, len(allocations))
for i, a := range allocations {
shop, _ := s.shopStore.GetByID(ctx, a.ShopID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, a.PackageID)
shopName := ""
packageName := ""
packageCode := ""
if shop != nil {
shopName = shop.ShopName
}
if pkg != nil {
packageName = pkg.PackageName
packageCode = pkg.PackageCode
}
resp, _ := s.buildResponse(ctx, a, shopName, packageName, packageCode)
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, "未授权访问")
}
// 任务 3.2:所有者校验 —— 代理只能修改自己创建的分配记录的 status
// 使用系统级查询(不带数据权限过滤),再做业务层权限判断以返回正确的 403
allocation, err := s.packageAllocationStore.GetByIDForSystem(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "分配记录不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeAgent {
callerShopID := middleware.GetShopIDFromContext(ctx)
// 代理用户只能修改自己作为 allocator 的记录
if allocation.AllocatorShopID != callerShopID {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
}
if err := s.packageAllocationStore.UpdateStatus(ctx, id, status, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新状态失败")
}
return nil
}
// UpdateShelfStatus 更新分配记录的上下架状态
// 代理独立控制自己客户侧套餐可见性,不影响平台全局状态
func (s *Service) UpdateShelfStatus(ctx context.Context, allocationID uint, shelfStatus int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
allocation, err := s.packageAllocationStore.GetByID(ctx, allocationID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "分配记录不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
// 上架时检查套餐全局状态:禁用的套餐不允许上架
if shelfStatus == constants.ShelfStatusOn {
pkg, err := s.packageStore.GetByID(ctx, allocation.PackageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "套餐不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
}
if pkg.Status == constants.StatusDisabled {
return errors.New(errors.CodeInvalidStatus, "套餐已禁用,无法上架")
}
}
if err := s.packageAllocationStore.UpdateShelfStatus(ctx, allocationID, shelfStatus, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败")
}
return nil
}
func (s *Service) buildResponse(ctx context.Context, a *model.ShopPackageAllocation, shopName, packageName, packageCode string) (*dto.ShopPackageAllocationResponse, error) {
var seriesID uint
seriesName := ""
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, a.PackageID)
if pkg != nil {
seriesID = pkg.SeriesID
series, _ := s.packageSeriesStore.GetByIDUnscoped(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,
SeriesID: seriesID,
SeriesName: seriesName,
SeriesAllocationID: a.SeriesAllocationID,
AllocatorShopID: a.AllocatorShopID,
AllocatorShopName: allocatorShopName,
CostPrice: a.CostPrice,
Status: a.Status,
ShelfStatus: a.ShelfStatus,
CreatedAt: a.CreatedAt.Format(time.RFC3339),
UpdatedAt: a.UpdatedAt.Format(time.RFC3339),
}, nil
}
func (s *Service) UpdateCostPrice(ctx context.Context, id uint, newCostPrice int64, changeReason string) (*dto.ShopPackageAllocationResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
allocation, err := s.packageAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
if allocation.CostPrice == newCostPrice {
return nil, errors.New(errors.CodeInvalidParam, "新成本价与当前成本价相同")
}
oldCostPrice := allocation.CostPrice
now := time.Now()
priceHistory := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldCostPrice,
NewCostPrice: newCostPrice,
ChangeReason: changeReason,
ChangedBy: currentUserID,
EffectiveFrom: now,
}
if err := s.priceHistoryStore.Create(ctx, priceHistory); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建价格历史记录失败")
}
allocation.CostPrice = newCostPrice
allocation.Updater = currentUserID
if err := s.packageAllocationStore.Update(ctx, allocation); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
}
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, allocation.PackageID)
shopName := ""
packageName := ""
packageCode := ""
if shop != nil {
shopName = shop.ShopName
}
if pkg != nil {
packageName = pkg.PackageName
packageCode = pkg.PackageCode
}
return s.buildResponse(ctx, allocation, shopName, packageName, packageCode)
}
func (s *Service) GetPriceHistory(ctx context.Context, allocationID uint) ([]*model.ShopPackageAllocationPriceHistory, error) {
_, err := s.packageAllocationStore.GetByID(ctx, allocationID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
history, err := s.priceHistoryStore.ListByAllocation(ctx, allocationID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "获取价格历史失败")
}
return history, nil
}

View File

@@ -1,353 +0,0 @@
package shop_series_allocation
import (
"context"
"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"
"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"
"gorm.io/gorm"
)
type Service struct {
seriesAllocationStore *postgres.ShopSeriesAllocationStore
packageAllocationStore *postgres.ShopPackageAllocationStore
shopStore *postgres.ShopStore
packageSeriesStore *postgres.PackageSeriesStore
}
func New(
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
packageAllocationStore *postgres.ShopPackageAllocationStore,
shopStore *postgres.ShopStore,
packageSeriesStore *postgres.PackageSeriesStore,
) *Service {
return &Service{
seriesAllocationStore: seriesAllocationStore,
packageAllocationStore: packageAllocationStore,
shopStore: shopStore,
packageSeriesStore: packageSeriesStore,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocationRequest) (*dto.ShopSeriesAllocationResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
userType := middleware.GetUserTypeFromContext(ctx)
allocatorShopID := middleware.GetShopIDFromContext(ctx)
if userType == constants.UserTypeAgent && allocatorShopID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
}
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
}
if userType == constants.UserTypeAgent {
if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
return nil, errors.New(errors.CodeForbidden, "只能为直属下级分配套餐系列")
}
}
series, err := s.packageSeriesStore.GetByID(ctx, req.SeriesID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
}
// 检查是否已存在分配
exists, err := s.seriesAllocationStore.ExistsByShopAndSeries(ctx, req.ShopID, req.SeriesID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "检查分配记录失败")
}
if exists {
return nil, errors.New(errors.CodeConflict, "该店铺已分配此套餐系列")
}
// 代理用户:检查自己是否有该系列的分配权限,且金额不能超过上级给的上限
// 平台用户:无上限限制,可自由设定金额
if userType == constants.UserTypeAgent {
allocatorAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, 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
}
if req.ForceRechargeAmount != nil {
allocation.ForceRechargeAmount = *req.ForceRechargeAmount
}
if req.ForceRechargeTriggerType != nil {
allocation.ForceRechargeTriggerType = *req.ForceRechargeTriggerType
}
allocation.Creator = currentUserID
if err := s.seriesAllocationStore.Create(ctx, allocation); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建分配失败")
}
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.seriesAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
}
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, seriesCode)
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesAllocationRequest) (*dto.ShopSeriesAllocationResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
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, "分配记录不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
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.OneTimeCommissionAmount = newAmount
}
if req.EnableOneTimeCommission != nil {
allocation.EnableOneTimeCommission = *req.EnableOneTimeCommission
}
if req.OneTimeCommissionTrigger != nil {
allocation.OneTimeCommissionTrigger = *req.OneTimeCommissionTrigger
}
if req.OneTimeCommissionThreshold != nil {
allocation.OneTimeCommissionThreshold = *req.OneTimeCommissionThreshold
}
if req.EnableForceRecharge != nil {
allocation.EnableForceRecharge = *req.EnableForceRecharge
}
if req.ForceRechargeAmount != nil {
allocation.ForceRechargeAmount = *req.ForceRechargeAmount
}
if req.ForceRechargeTriggerType != nil {
allocation.ForceRechargeTriggerType = *req.ForceRechargeTriggerType
}
if req.Status != nil {
allocation.Status = *req.Status
}
allocation.Updater = currentUserID
if err := s.seriesAllocationStore.Update(ctx, allocation); 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, seriesCode)
}
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.seriesAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "分配记录不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
count, err := s.packageAllocationStore.CountBySeriesAllocationID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "检查关联套餐分配失败")
}
if count > 0 {
return errors.New(errors.CodeInvalidParam, "存在关联的套餐分配,无法删除")
}
if err := s.seriesAllocationStore.Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除分配失败")
}
return nil
}
func (s *Service) List(ctx context.Context, req *dto.ShopSeriesAllocationListRequest) ([]*dto.ShopSeriesAllocationResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
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
}
allocations, total, err := s.seriesAllocationStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询分配列表失败")
}
responses := make([]*dto.ShopSeriesAllocationResponse, len(allocations))
for i, a := range allocations {
shop, _ := s.shopStore.GetByID(ctx, a.ShopID)
series, _ := s.packageSeriesStore.GetByID(ctx, a.SeriesID)
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, seriesCode)
responses[i] = resp
}
return responses, total, nil
}
func (s *Service) buildResponse(ctx context.Context, a *model.ShopSeriesAllocation, shopName, seriesName, seriesCode string) (*dto.ShopSeriesAllocationResponse, error) {
allocatorShopName := ""
if a.AllocatorShopID > 0 {
allocatorShop, _ := s.shopStore.GetByID(ctx, a.AllocatorShopID)
if allocatorShop != nil {
allocatorShopName = allocatorShop.ShopName
}
} else {
allocatorShopName = "平台"
}
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) GetByShopAndSeries(ctx context.Context, shopID, seriesID uint) (*model.ShopSeriesAllocation, error) {
return s.seriesAllocationStore.GetByShopAndSeries(ctx, shopID, seriesID)
}

View File

@@ -0,0 +1,722 @@
// Package shop_series_grant 提供代理系列授权的业务逻辑服务
// 包含授权创建、查询、更新、套餐管理、删除等功能
package shop_series_grant
import (
"context"
"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"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Service 代理系列授权业务服务
type Service struct {
db *gorm.DB
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopPackageAllocationPriceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore
shopStore *postgres.ShopStore
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
logger *zap.Logger
}
// New 创建代理系列授权服务实例
func New(
db *gorm.DB,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
shopPackageAllocationPriceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore,
shopStore *postgres.ShopStore,
packageStore *postgres.PackageStore,
packageSeriesStore *postgres.PackageSeriesStore,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
shopSeriesAllocationStore: shopSeriesAllocationStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopPackageAllocationPriceHistoryStore: shopPackageAllocationPriceHistoryStore,
shopStore: shopStore,
packageStore: packageStore,
packageSeriesStore: packageSeriesStore,
logger: logger,
}
}
// getParentCeilingFixed 查询固定模式佣金天花板
// allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount
// allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount
func (s *Service) getParentCeilingFixed(ctx context.Context, allocatorShopID uint, seriesID uint, config *model.OneTimeCommissionConfig) (int64, error) {
if allocatorShopID == 0 {
// 平台分配:天花板为套餐系列全局佣金金额
return config.CommissionAmount, nil
}
// 代理分配:天花板为分配者自身拥有的授权金额
allocatorAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, seriesID)
if err != nil {
return 0, errors.Wrap(errors.CodeNotFound, err, "分配者无此系列授权")
}
return allocatorAllocation.OneTimeCommissionAmount, nil
}
// getParentCeilingTiered 查询梯度模式各档位天花板map: threshold→amount
// allocatorShopID=0 → 读 PackageSeries 全局 Tiers 中各 threshold 的 amount
// allocatorShopID>0 → 读分配者自身 ShopSeriesAllocation.commission_tiers_json
func (s *Service) getParentCeilingTiered(ctx context.Context, allocatorShopID uint, seriesID uint, globalTiers []model.OneTimeCommissionTier) (map[int64]int64, error) {
ceilingMap := make(map[int64]int64)
if allocatorShopID == 0 {
// 平台分配:天花板来自套餐系列全局配置
for _, t := range globalTiers {
ceilingMap[t.Threshold] = t.Amount
}
return ceilingMap, nil
}
// 代理分配:天花板来自分配者的专属阶梯
allocatorAllocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, seriesID)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "分配者无此系列授权")
}
agentTiers, err := allocatorAllocation.GetCommissionTiers()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析分配者阶梯佣金失败")
}
for _, t := range agentTiers {
ceilingMap[t.Threshold] = t.Amount
}
return ceilingMap, nil
}
// buildGrantResponse 构建授权详情响应
func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.ShopSeriesAllocation, series *model.PackageSeries, config *model.OneTimeCommissionConfig) (*dto.ShopSeriesGrantResponse, error) {
resp := &dto.ShopSeriesGrantResponse{
ID: allocation.ID,
ShopID: allocation.ShopID,
SeriesID: allocation.SeriesID,
SeriesName: series.SeriesName,
SeriesCode: series.SeriesCode,
CommissionType: config.CommissionType,
AllocatorShopID: allocation.AllocatorShopID,
Status: allocation.Status,
CreatedAt: allocation.CreatedAt.Format(time.DateTime),
UpdatedAt: allocation.UpdatedAt.Format(time.DateTime),
}
// 查询店铺名称
shop, err := s.shopStore.GetByID(ctx, allocation.ShopID)
if err == nil {
resp.ShopName = shop.ShopName
}
// 查询分配者名称
if allocation.AllocatorShopID > 0 {
allocatorShop, err := s.shopStore.GetByID(ctx, allocation.AllocatorShopID)
if err == nil {
resp.AllocatorShopName = allocatorShop.ShopName
}
} else {
resp.AllocatorShopName = "平台"
}
// 强充状态first_recharge 或平台已启用 accumulated_recharge 强充时,锁定不可改
forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge
resp.ForceRechargeLocked = forceRechargeLocked
resp.ForceRechargeEnabled = allocation.EnableForceRecharge
resp.ForceRechargeAmount = allocation.ForceRechargeAmount
// 固定模式
if config.CommissionType == "fixed" {
resp.OneTimeCommissionAmount = allocation.OneTimeCommissionAmount
resp.CommissionTiers = []dto.GrantCommissionTierItem{}
} else {
// 梯度模式:将代理专属金额与全局 operator 合并
resp.OneTimeCommissionAmount = 0
agentTiers, err := allocation.GetCommissionTiers()
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析代理阶梯佣金失败")
}
// 按 threshold 索引代理金额
agentAmountMap := make(map[int64]int64)
for _, t := range agentTiers {
agentAmountMap[t.Threshold] = t.Amount
}
// 合并全局 operator 和代理 amount
tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers))
for _, globalTier := range config.Tiers {
tiers = append(tiers, dto.GrantCommissionTierItem{
Operator: globalTier.Operator,
Threshold: globalTier.Threshold,
Amount: agentAmountMap[globalTier.Threshold],
})
}
resp.CommissionTiers = tiers
}
// 查询已授权套餐列表
pkgAllocations, err := s.shopPackageAllocationStore.GetBySeriesAllocationID(ctx, allocation.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐分配失败")
}
packages := make([]dto.ShopSeriesGrantPackageItem, 0, len(pkgAllocations))
for _, pa := range pkgAllocations {
pkg, pkgErr := s.packageStore.GetByID(ctx, pa.PackageID)
if pkgErr != nil {
continue
}
packages = append(packages, dto.ShopSeriesGrantPackageItem{
PackageID: pa.PackageID,
PackageName: pkg.PackageName,
PackageCode: pkg.PackageCode,
CostPrice: pa.CostPrice,
ShelfStatus: pa.ShelfStatus,
Status: pa.Status,
})
}
resp.Packages = packages
return resp, nil
}
// Create 创建系列授权
// POST /api/admin/shop-series-grants
func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
operatorShopID := middleware.GetShopIDFromContext(ctx)
operatorType := middleware.GetUserTypeFromContext(ctx)
// 1. 查询套餐系列,确认佣金类型
series, err := s.packageSeriesStore.GetByID(ctx, req.SeriesID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
}
config, err := series.GetOneTimeCommissionConfig()
if err != nil || config == nil || !config.Enable {
return nil, errors.New(errors.CodeInvalidParam, "该系列未启用一次性佣金,无法创建授权")
}
// 2. 检查重复授权
exists, err := s.shopSeriesAllocationStore.ExistsByShopAndSeries(ctx, req.ShopID, req.SeriesID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "检查授权重复失败")
}
if exists {
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
}
// 3. 确定 allocatorShopID代理操作者必须自己有授权才能向下分配
var allocatorShopID uint
if operatorType == constants.UserTypeAgent {
allocatorShopID = operatorShopID
_, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID)
if err != nil {
return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配")
}
}
// 平台/超管 allocatorShopID = 0
// 4. 参数验证:固定模式 or 梯度模式
allocation := &model.ShopSeriesAllocation{
ShopID: req.ShopID,
SeriesID: req.SeriesID,
AllocatorShopID: allocatorShopID,
Status: constants.StatusEnabled,
CommissionTiersJSON: "[]",
}
allocation.Creator = operatorID
allocation.Updater = operatorID
if config.CommissionType == "fixed" {
if req.OneTimeCommissionAmount == nil {
return nil, errors.New(errors.CodeInvalidParam, "固定模式必须填写佣金金额")
}
ceiling, ceilErr := s.getParentCeilingFixed(ctx, allocatorShopID, req.SeriesID, config)
if ceilErr != nil {
return nil, ceilErr
}
if *req.OneTimeCommissionAmount > ceiling {
return nil, errors.New(errors.CodeInvalidParam, "佣金金额不能超过上级天花板")
}
allocation.OneTimeCommissionAmount = *req.OneTimeCommissionAmount
} else {
// 梯度模式
if len(req.CommissionTiers) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "梯度模式必须填写阶梯配置")
}
// 阶梯数量和 threshold 必须与全局完全一致
if len(req.CommissionTiers) != len(config.Tiers) {
return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯数量与系列配置不一致")
}
ceilingMap, ceilErr := s.getParentCeilingTiered(ctx, allocatorShopID, req.SeriesID, config.Tiers)
if ceilErr != nil {
return nil, ceilErr
}
agentTiers := make([]model.AllocationCommissionTier, 0, len(req.CommissionTiers))
for i, tier := range req.CommissionTiers {
if tier.Threshold != config.Tiers[i].Threshold {
return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯 threshold 与系列配置不匹配")
}
ceiling, ok := ceilingMap[tier.Threshold]
if !ok {
ceiling = 0
}
if tier.Amount > ceiling {
return nil, errors.New(errors.CodeInvalidParam, "某档位佣金金额超过上级天花板")
}
agentTiers = append(agentTiers, model.AllocationCommissionTier{
Threshold: tier.Threshold,
Amount: tier.Amount,
})
}
if err := allocation.SetCommissionTiers(agentTiers); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化阶梯佣金失败")
}
}
// 5. 强充配置first_recharge 或平台已开强充 → locked忽略代理传入
forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge
if !forceRechargeLocked && req.EnableForceRecharge != nil && *req.EnableForceRecharge {
allocation.EnableForceRecharge = true
if req.ForceRechargeAmount != nil {
allocation.ForceRechargeAmount = *req.ForceRechargeAmount
}
}
// 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
var result *dto.ShopSeriesGrantResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txSeriesStore := postgres.NewShopSeriesAllocationStore(tx)
if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败")
}
// 创建套餐分配
if len(req.Packages) > 0 {
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
for _, item := range req.Packages {
if item.Remove != nil && *item.Remove {
continue
}
// W1: 校验套餐归属于该系列,防止跨系列套餐混入
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != req.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
if allocatorShopID > 0 {
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID)
if authErr != nil {
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
}
}
pkgAlloc := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: item.CostPrice,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
ShelfStatus: constants.StatusEnabled,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
// 写成本价历史
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
ChangeReason: "初始授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}
}
var buildErr error
result, buildErr = s.buildGrantResponse(ctx, allocation, series, config)
return buildErr
})
if err != nil {
return nil, err
}
return result, nil
}
// Get 查询单条系列授权详情
// GET /api/admin/shop-series-grants/:id
func (s *Service) Get(ctx context.Context, id uint) (*dto.ShopSeriesGrantResponse, error) {
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
}
series, err := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败")
}
config, err := series.GetOneTimeCommissionConfig()
if err != nil || config == nil {
config = &model.OneTimeCommissionConfig{}
}
return s.buildGrantResponse(ctx, allocation, series, config)
}
// List 分页查询系列授权列表
// GET /api/admin/shop-series-grants
func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest) (*dto.ShopSeriesGrantPageResult, error) {
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
opts := &store.QueryOptions{
Page: page,
PageSize: pageSize,
OrderBy: "id DESC",
}
filters := make(map[string]interface{})
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
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
}
allocations, total, err := s.shopSeriesAllocationStore.List(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权列表失败")
}
// 批量查询店铺名称
shopIDs := make([]uint, 0)
seriesIDs := make([]uint, 0)
for _, a := range allocations {
shopIDs = append(shopIDs, a.ShopID)
if a.AllocatorShopID > 0 {
shopIDs = append(shopIDs, a.AllocatorShopID)
}
seriesIDs = append(seriesIDs, a.SeriesID)
}
shopMap := make(map[uint]string)
if len(shopIDs) > 0 {
shops, _ := s.shopStore.GetByIDs(ctx, shopIDs)
for _, sh := range shops {
shopMap[sh.ID] = sh.ShopName
}
}
seriesMap := make(map[uint]*model.PackageSeries)
if len(seriesIDs) > 0 {
seriesList, _ := s.packageSeriesStore.GetByIDs(ctx, seriesIDs)
for _, sr := range seriesList {
seriesMap[sr.ID] = sr
}
}
items := make([]*dto.ShopSeriesGrantListItem, 0, len(allocations))
for _, a := range allocations {
item := &dto.ShopSeriesGrantListItem{
ID: a.ID,
ShopID: a.ShopID,
ShopName: shopMap[a.ShopID],
SeriesID: a.SeriesID,
AllocatorShopID: a.AllocatorShopID,
OneTimeCommissionAmount: a.OneTimeCommissionAmount,
ForceRechargeEnabled: a.EnableForceRecharge,
Status: a.Status,
CreatedAt: a.CreatedAt.Format(time.DateTime),
}
if a.AllocatorShopID > 0 {
item.AllocatorShopName = shopMap[a.AllocatorShopID]
} else {
item.AllocatorShopName = "平台"
}
if sr, ok := seriesMap[a.SeriesID]; ok {
item.SeriesName = sr.SeriesName
config, _ := sr.GetOneTimeCommissionConfig()
if config != nil {
item.CommissionType = config.CommissionType
}
}
// 统计套餐数量
pkgCount, _ := s.shopPackageAllocationStore.CountBySeriesAllocationID(ctx, a.ID)
item.PackageCount = int(pkgCount)
items = append(items, item)
}
totalPages := int(total) / pageSize
if int(total)%pageSize != 0 {
totalPages++
}
return &dto.ShopSeriesGrantPageResult{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// Update 更新系列授权
// PUT /api/admin/shop-series-grants/:id
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
operatorShopID := middleware.GetShopIDFromContext(ctx)
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
}
// 代理只能修改自己分配出去的授权
if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID {
return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录")
}
series, err := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败")
}
config, err := series.GetOneTimeCommissionConfig()
if err != nil || config == nil {
return nil, errors.New(errors.CodeInternalError, "获取系列佣金配置失败")
}
// 更新固定模式佣金金额
if req.OneTimeCommissionAmount != nil && config.CommissionType == "fixed" {
ceiling, ceilErr := s.getParentCeilingFixed(ctx, allocation.AllocatorShopID, allocation.SeriesID, config)
if ceilErr != nil {
return nil, ceilErr
}
if *req.OneTimeCommissionAmount > ceiling {
return nil, errors.New(errors.CodeInvalidParam, "佣金金额不能超过上级天花板")
}
allocation.OneTimeCommissionAmount = *req.OneTimeCommissionAmount
}
// 更新梯度模式阶梯
if len(req.CommissionTiers) > 0 && config.CommissionType == "tiered" {
if len(req.CommissionTiers) != len(config.Tiers) {
return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯数量与系列配置不一致")
}
ceilingMap, ceilErr := s.getParentCeilingTiered(ctx, allocation.AllocatorShopID, allocation.SeriesID, config.Tiers)
if ceilErr != nil {
return nil, ceilErr
}
agentTiers := make([]model.AllocationCommissionTier, 0, len(req.CommissionTiers))
for i, tier := range req.CommissionTiers {
if tier.Threshold != config.Tiers[i].Threshold {
return nil, errors.New(errors.CodeInvalidParam, "梯度阶梯 threshold 与系列配置不匹配")
}
if ceiling, ok := ceilingMap[tier.Threshold]; ok && tier.Amount > ceiling {
return nil, errors.New(errors.CodeInvalidParam, "某档位佣金金额超过上级天花板")
}
agentTiers = append(agentTiers, model.AllocationCommissionTier{
Threshold: tier.Threshold,
Amount: tier.Amount,
})
}
if err := allocation.SetCommissionTiers(agentTiers); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化阶梯佣金失败")
}
}
// 更新强充配置(平台已锁定时忽略)
forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge
if !forceRechargeLocked && req.EnableForceRecharge != nil {
allocation.EnableForceRecharge = *req.EnableForceRecharge
if req.ForceRechargeAmount != nil {
allocation.ForceRechargeAmount = *req.ForceRechargeAmount
}
}
allocation.Updater = operatorID
if err := s.shopSeriesAllocationStore.Update(ctx, allocation); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新授权记录失败")
}
return s.buildGrantResponse(ctx, allocation, series, config)
}
// ManagePackages 管理授权套餐(新增/更新/删除)
// PUT /api/admin/shop-series-grants/:id/packages
func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
operatorShopID := middleware.GetShopIDFromContext(ctx)
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
}
// 代理只能操作自己分配的授权
if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID {
return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
for _, item := range req.Packages {
if item.Remove != nil && *item.Remove {
// 软删除已有的 active 分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr != nil {
// 找不到则静默忽略
continue
}
_ = txPkgStore.Delete(ctx, existing.ID)
continue
}
// 新增或更新套餐分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr == nil {
// 已有记录:更新成本价并写历史
oldPrice := existing.CostPrice
existing.CostPrice = item.CostPrice
existing.Updater = operatorID
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败")
}
if oldPrice != item.CostPrice {
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: existing.ID,
OldCostPrice: oldPrice,
NewCostPrice: item.CostPrice,
ChangeReason: "手动调价",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}
} else {
// W1: 校验套餐归属于该系列,防止跨系列套餐混入
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
if allocation.AllocatorShopID > 0 {
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID)
if authErr != nil {
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
}
}
// 新建分配
pkgAlloc := &model.ShopPackageAllocation{
ShopID: allocation.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocation.AllocatorShopID,
CostPrice: item.CostPrice,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
ShelfStatus: constants.StatusEnabled,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败")
}
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
ChangeReason: "新增授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
}
}
return nil
})
if err != nil {
return nil, err
}
// 重新查询最新状态
return s.Get(ctx, id)
}
// Delete 删除系列授权(软删除)
// DELETE /api/admin/shop-series-grants/:id
func (s *Service) Delete(ctx context.Context, id uint) error {
operatorShopID := middleware.GetShopIDFromContext(ctx)
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "授权记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
}
// 代理只能删除自己分配的授权
if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID {
return errors.New(errors.CodeForbidden, "无权限操作该授权记录")
}
// 检查是否有子级授权依赖(该店铺是否作为 allocator 向下分配了)
subAllocations, err := s.shopSeriesAllocationStore.GetByAllocatorShopID(ctx, allocation.ShopID)
for _, sub := range subAllocations {
if sub.SeriesID == allocation.SeriesID {
return errors.New(errors.CodeConflict, "该授权存在下级依赖,请先删除下级授权")
}
}
_ = err
// 事务软删除 ShopSeriesAllocation + 所有关联 ShopPackageAllocation
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txSeriesStore := postgres.NewShopSeriesAllocationStore(tx)
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
pkgAllocations, _ := txPkgStore.GetBySeriesAllocationID(ctx, id)
for _, pa := range pkgAllocations {
if delErr := txPkgStore.Delete(ctx, pa.ID); delErr != nil {
return errors.Wrap(errors.CodeDatabaseError, delErr, "删除套餐分配失败")
}
}
if delErr := txSeriesStore.Delete(ctx, id); delErr != nil {
return errors.Wrap(errors.CodeDatabaseError, delErr, "删除系列授权失败")
}
return nil
})
}

View File

@@ -0,0 +1,11 @@
-- 回滚:删除 commission_tiers_json不恢复已删除的 3 个死字段(数据不可逆)
-- 注意enable_one_time_commission、one_time_commission_trigger、one_time_commission_threshold 三列数据已丢失,无法恢复
ALTER TABLE tb_shop_series_allocation
DROP COLUMN IF EXISTS commission_tiers_json;
-- 以下仅恢复列结构,不恢复数据
ALTER TABLE tb_shop_series_allocation
ADD COLUMN IF NOT EXISTS enable_one_time_commission BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS one_time_commission_trigger VARCHAR(50),
ADD COLUMN IF NOT EXISTS one_time_commission_threshold BIGINT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,13 @@
-- 重构代理系列授权佣金字段
-- 删除 3 个语义重复的死字段(从未被计算引擎读取)
-- 新增 commission_tiers_json JSONB 字段(梯度模式专属阶梯金额)
ALTER TABLE tb_shop_series_allocation
DROP COLUMN IF EXISTS enable_one_time_commission,
DROP COLUMN IF EXISTS one_time_commission_trigger,
DROP COLUMN IF EXISTS one_time_commission_threshold;
ALTER TABLE tb_shop_series_allocation
ADD COLUMN commission_tiers_json JSONB NOT NULL DEFAULT '[]';
COMMENT ON COLUMN tb_shop_series_allocation.commission_tiers_json IS '梯度模式专属阶梯金额列表,格式:[{"threshold":100,"amount":80}],固定模式为空数组';

View File

@@ -9,7 +9,7 @@
},
"google": {
"options": {
"baseURL": "http://45.155.220.179:8317",
"baseURL": "http://45.155.220.179:8317/v1beta",
"apiKey": "sk-ZBGcMXCdwtSK7G35s"
}
}

View File

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

View File

@@ -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 取 tiersdimension、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.ForceAmountforce_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不影响已发放佣金 |

View File

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

View File

@@ -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=fixedone_time_commission_amount=500050元packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}]
- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocationone_time_commission_amount=5000和 2 条 ShopPackageAllocation响应返回包含 packages 列表的聚合视图
#### Scenario: 代理B 已存在此系列授权,重复创建
- **WHEN** 代理A 为代理B 创建系列授权但代理B 在此系列下已有 active 授权记录
- **THEN** 系统返回错误"该代理已存在此系列授权"
#### Scenario: 分配者自身无此系列授权
- **WHEN** 代理A 自身未被授权此套餐系列尝试为代理B 创建此系列授权
- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配"
#### Scenario: 平台成功创建固定模式授权
- **WHEN** 平台管理员为一级代理创建系列授权commission_type=fixedone_time_commission_amount=800080元系列总额 commission_amount=10000100元
- **THEN** 系统创建授权,响应中 allocator_shop_id=0allocator_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=80A 为 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=trueforce_recharge_amount=10000
- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=falseforce_recharge_enabled=trueforce_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=0commission_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=trueforce_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** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"

View File

@@ -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=trueforce_recharge_amount=平台设定值
#### Scenario: 平台未设强充,代理自设生效
- **WHEN** PackageSeries.enable_force_recharge=false客户在代理A 的渠道下购买代理A 的 ShopSeriesAllocation.enable_force_recharge=trueforce_recharge_amount=10000
- **THEN** 系统使用代理A 的强充配置need_force_recharge=trueforce_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=trueforce_recharge_amount=10000trigger_type="single_recharge"
#### Scenario: 累计充值启用强充(平台层)
- **WHEN** 客户查询卡钱包充值预检PackageSeries.enable_force_recharge=trueforce_amount=10000
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=10000trigger_type="accumulated_recharge"
#### Scenario: 代理自设累计充值强充(平台未设)
- **WHEN** PackageSeries.enable_force_recharge=false销售代理的 ShopSeriesAllocation.enable_force_recharge=trueforce_recharge_amount=8000
- **THEN** 系统返回 need_force_recharge=trueforce_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=9000need_force_recharge=falseactual_payment=9000wallet_credit=0
#### Scenario: 代理自设强充,套餐价低于强充金额
- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000
- **THEN** 系统返回 actual_payment=10000wallet_credit=5000
#### Scenario: 首次充值强充(平台层),套餐价低于阈值
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层)
- **THEN** 系统返回 total_package_amount=9000need_force_recharge=trueforce_recharge_amount=10000actual_payment=10000wallet_credit=1000
#### Scenario: 购买多个套餐
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount=12000actual_payment=12000wallet_credit=0

View File

@@ -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 个字段。

View File

@@ -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="<"` 时,销售统计低于阈值的代理命中该档位,高于阈值的代理不命中

View File

@@ -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=fixedone_time_commission_amount=500050元packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}]
- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocationone_time_commission_amount=5000和 2 条 ShopPackageAllocation响应返回包含 packages 列表的聚合视图
#### Scenario: 代理B 已存在此系列授权,重复创建
- **WHEN** 代理A 为代理B 创建系列授权但代理B 在此系列下已有 active 授权记录
- **THEN** 系统返回错误"该代理已存在此系列授权"
#### Scenario: 分配者自身无此系列授权
- **WHEN** 代理A 自身未被授权此套餐系列尝试为代理B 创建此系列授权
- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配"
#### Scenario: 平台成功创建固定模式授权
- **WHEN** 平台管理员为一级代理创建系列授权commission_type=fixedone_time_commission_amount=800080元系列总额 commission_amount=10000100元
- **THEN** 系统创建授权,响应中 allocator_shop_id=0allocator_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=80A 为 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=trueforce_recharge_amount=10000
- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=falseforce_recharge_enabled=trueforce_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=0commission_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=trueforce_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** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"

View File

@@ -6,21 +6,47 @@
## 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=trueforce_recharge_amount=平台设定值
#### Scenario: 平台未设强充,代理自设生效
- **WHEN** PackageSeries.enable_force_recharge=false客户在代理A 的渠道下购买代理A 的 ShopSeriesAllocation.enable_force_recharge=trueforce_recharge_amount=10000
- **THEN** 系统使用代理A 的强充配置need_force_recharge=trueforce_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降级处理不影响购买流程
---
### Requirement: 钱包充值预检
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。强充判断 MUST 按代理层级规则执行:优先使用平台强充,平台未设时使用销售代理自设强充。
#### Scenario: 无强充要求
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发且未启用强充
- **THEN** 系统返回 need_force_recharge = falsemin_amount = 1001元max_amount = null
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=false销售代理 ShopSeriesAllocation.enable_force_recharge=false
- **THEN** 系统返回 need_force_recharge=false
#### Scenario: 首次充值强充
- **WHEN** 客户查询卡钱包充值预检,配置为首次充值触发,阈值 10000 分100元,未发放佣金
- **THEN** 系统返回 need_force_recharge = trueforce_recharge_amount = 10000trigger_type = "single_recharge"message = "首次充值需充值100元"
#### Scenario: 首次充值强充(平台层)
- **WHEN** 客户查询卡钱包充值预检,PackageSeries 配置为首次充值触发,阈值 10000 分,未发放佣金
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=10000trigger_type="single_recharge"
#### Scenario: 累计充值启用强充
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发,启用强充,强充金额 10000 分100元
- **THEN** 系统返回 need_force_recharge = trueforce_recharge_amount = 10000trigger_type = "accumulated_recharge"message = "每次充值需充值100元"
#### Scenario: 累计充值启用强充(平台层)
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=trueforce_amount=10000
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=10000trigger_type="accumulated_recharge"
#### Scenario: 代理自设累计充值强充(平台未设)
- **WHEN** PackageSeries.enable_force_recharge=false销售代理的 ShopSeriesAllocation.enable_force_recharge=trueforce_recharge_amount=8000
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=8000
#### Scenario: 一次性佣金已发放
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
@@ -34,15 +60,19 @@
### Requirement: 套餐购买预检
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。强充判断 MUST 按代理层级规则执行。
#### Scenario: 无强充要求正常购买
- **WHEN** 客户购买 90 元套餐,无强充要求
- **THEN** 系统返回 total_package_amount = 9000need_force_recharge = falseactual_payment = 9000wallet_credit = 0
- **WHEN** 客户购买 90 元套餐,平台和销售代理均未设强充
- **THEN** 系统返回 total_package_amount=9000need_force_recharge=falseactual_payment=9000wallet_credit=0
#### Scenario: 首次充值强充,套餐价低于阈值
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100
- **THEN** 系统返回 total_package_amount = 9000need_force_recharge = trueforce_recharge_amount = 10000actual_payment = 10000wallet_credit = 1000message = "需充值100元购买套餐后余额10元"
#### Scenario: 代理自设强充,套餐价低于强充金额
- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000
- **THEN** 系统返回 actual_payment=10000wallet_credit=5000
#### Scenario: 首次充值强充(平台层),套餐价低于阈值
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层)
- **THEN** 系统返回 total_package_amount=9000need_force_recharge=trueforce_recharge_amount=10000actual_payment=10000wallet_credit=1000
#### Scenario: 首次充值强充,套餐价高于阈值
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元

View File

@@ -172,3 +172,47 @@
- 一次性佣金支持按销售数量或销售金额设置多个梯度档位
- API 请求中删除 `enable_tier_commission``tier_config` 字段
- API 响应中不再包含 `enable_tier_commission` 字段
---
### Requirement: /shop-series-allocations 接口
**❌ REMOVED** - 此 requirement 已废弃
**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 接口
**❌ REMOVED** - 此 requirement 已废弃
**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 等字段
**❌ REMOVED** - 此 requirement 已废弃
**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 个字段。

View File

@@ -39,10 +39,9 @@ func BuildDocHandlers() *bootstrap.Handlers {
Package: admin.NewPackageHandler(nil),
PackageUsage: admin.NewPackageUsageHandler(nil),
H5PackageUsage: h5.NewPackageUsageHandler(nil, nil),
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(nil),
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
AdminOrder: admin.NewOrderHandler(nil, nil),
H5Order: h5.NewOrderHandler(nil),
H5Recharge: h5.NewRechargeHandler(nil),