Compare commits
14 Commits
61155952a7
...
de9eacd273
| Author | SHA1 | Date | |
|---|---|---|---|
| de9eacd273 | |||
| f40abaf93c | |||
| e0cb4498e6 | |||
| c7b8ecfebf | |||
| 2ca33b7172 | |||
| 769f6b8709 | |||
| dd68d0a62b | |||
| c5018f110f | |||
| ad3a7a770a | |||
| beed9d25e0 | |||
| 163d01dae5 | |||
| e7d52db270 | |||
| 672274f9fd | |||
| b52744b149 |
260
.claude/skills/systematic-debugging/SKILL.md
Normal file
260
.claude/skills/systematic-debugging/SKILL.md
Normal 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 消失,无新增问题 |
|
||||
265
.opencode/skills/systematic-debugging/SKILL.md
Normal file
265
.opencode/skills/systematic-debugging/SKILL.md
Normal 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 消失,无新增问题 |
|
||||
@@ -17,6 +17,7 @@
|
||||
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
|
||||
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
|
||||
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
|
||||
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
|
||||
|
||||
### ⚠️ 新增 Handler 时必须同步更新文档生成器
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
|
||||
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
|
||||
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
|
||||
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
|
||||
|
||||
### ⚠️ 新增 Handler 时必须同步更新文档生成器
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
131
internal/handler/admin/shop_series_grant.go
Normal file
131
internal/handler/admin/shop_series_grant.go
Normal 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)
|
||||
}
|
||||
@@ -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:"分配者店铺ID,0表示平台分配"`
|
||||
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
|
||||
}
|
||||
@@ -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:"分配者店铺ID,0表示平台分配"`
|
||||
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
|
||||
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"该代理能拿的一次性佣金金额上限(分)"`
|
||||
EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"`
|
||||
OneTimeCommissionTrigger string `json:"one_time_commission_trigger" description:"一次性佣金触发类型"`
|
||||
OneTimeCommissionThreshold int64 `json:"one_time_commission_threshold" description:"一次性佣金触发阈值(分)"`
|
||||
EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强制充值"`
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
|
||||
ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型 (1:单次充值, 2:累计充值)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
119
internal/model/dto/shop_series_grant_dto.go
Normal file
119
internal/model/dto/shop_series_grant_dto.go
Normal 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:"分配者店铺ID,0 表示平台"`
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"`
|
||||
OneTimeCommissionAmount int64 `gorm:"column:one_time_commission_amount;type:bigint;default:0;not null;comment:该代理能拿的一次性佣金金额上限(分)" json:"one_time_commission_amount"`
|
||||
EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;default:false;not null;comment:是否启用一次性佣金" json:"enable_one_time_commission"`
|
||||
OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(50);comment:一次性佣金触发类型" json:"one_time_commission_trigger"`
|
||||
OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;not null;comment:一次性佣金触发阈值(分)" json:"one_time_commission_threshold"`
|
||||
EnableForceRecharge bool `gorm:"column:enable_force_recharge;default:false;not null;comment:是否启用强制充值" json:"enable_force_recharge"`
|
||||
ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;not null;comment:强制充值金额(分)" json:"force_recharge_amount"`
|
||||
ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;not null;comment:强充触发类型 1-单次充值 2-累计充值" json:"force_recharge_trigger_type"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
SeriesID uint `gorm:"column:series_id;index;not null;comment:套餐系列ID" json:"series_id"`
|
||||
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"`
|
||||
OneTimeCommissionAmount int64 `gorm:"column:one_time_commission_amount;type:bigint;default:0;not null;comment:该代理能拿的一次性佣金金额上限(分),固定模式有效" json:"one_time_commission_amount"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
62
internal/routes/shop_series_grant.go
Normal file
62
internal/routes/shop_series_grant.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
722
internal/service/shop_series_grant/service.go
Normal file
722
internal/service/shop_series_grant/service.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}],固定模式为空数组';
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"google": {
|
||||
"options": {
|
||||
"baseURL": "http://45.155.220.179:8317",
|
||||
"baseURL": "http://45.155.220.179:8317/v1beta",
|
||||
"apiKey": "sk-ZBGcMXCdwtSK7G35s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
@@ -0,0 +1,188 @@
|
||||
## Context
|
||||
|
||||
当前系统中,"给代理分配套餐"需要两步独立操作:先调用 `POST /shop-series-allocations` 创建系列分配,再多次调用 `POST /shop-package-allocations` 逐一分配套餐。
|
||||
|
||||
`ShopSeriesAllocation` 有 6 个字段存在问题:
|
||||
- 前 3 个死字段(`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold`)从未被计算引擎读取,与 PackageSeries 配置完全重复
|
||||
- 强充 3 个字段(`enable_force_recharge`、`force_recharge_amount`、`force_recharge_trigger_type`)语义正确,但 `checkForceRechargeRequirement` 只读 PackageSeries,代理配置完全无效
|
||||
|
||||
梯度模式下,`calculateChainOneTimeCommission` 直接把 PackageSeries 全局 tiers 传给 `matchOneTimeCommissionTier`,即所有代理用同一套阶梯金额。但业务模型要求每个代理有自己的专属阶梯金额(上级可以把金额压低给下级,但阈值不变),数据库中完全没有存储这个信息的字段。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 新增统一的"系列授权" API,一次请求原子性完成系列分配 + 套餐列表分配
|
||||
- 修复强充层级:平台未设强充时,销售代理自设的强充配置真正生效
|
||||
- 修复梯度模式数据模型:新增 `commission_tiers_json` 字段存储每代理专属阶梯金额
|
||||
- 修复梯度模式计算引擎:读取各代理自己的阶梯金额,而非全局 tiers
|
||||
- 新增金额上限校验:固定模式单值天花板;梯度模式每档位天花板
|
||||
- 清理 3 个语义重复的死字段(数据库迁移删除)
|
||||
- 删除旧的 `/shop-series-allocations` 和 `/shop-package-allocations` 接口,干净重构
|
||||
|
||||
**Non-Goals:**
|
||||
- 不重写现有的佣金计算链式逻辑(数学结构正确,只改数据读取来源)
|
||||
- 不修改 ShopPackageBatchAllocation、ShopPackageBatchPricing 这两个 Service(批量操作不在本次范围)
|
||||
- 不修改差价佣金(`CalculateCostDiffCommission`)逻辑
|
||||
- 不改变数据存储底层结构(仍是两张表)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:新接口策略——外观层
|
||||
|
||||
**选择**:新增 `ShopSeriesGrantService`,内部事务性地调用已有 `ShopSeriesAllocationStore` 和 `ShopPackageAllocationStore`。
|
||||
|
||||
**理由**:底层两张表的数据结构不变,对外以"授权"概念聚合呈现。
|
||||
|
||||
### 决策 2:梯度模式数据模型——JSONB 字段
|
||||
|
||||
**选择**:在 `ShopSeriesAllocation` 新增 `commission_tiers_json JSONB` 字段,存储该代理的专属阶梯金额列表。
|
||||
|
||||
数据格式:
|
||||
```json
|
||||
[
|
||||
{"threshold": 100, "amount": 80},
|
||||
{"threshold": 150, "amount": 120}
|
||||
]
|
||||
```
|
||||
|
||||
- `threshold` 必须与 PackageSeries 全局 tiers 的阈值一一对应(不允许创建不存在的阈值)
|
||||
- `amount` 由上级在授权时设定,不得超过上级同阈值的 amount
|
||||
- 固定模式下此字段为空数组 `[]`
|
||||
|
||||
**理由**:阶梯金额是每代理独立的配置,与分配记录 1:1 关联,JSONB 存储简洁无需额外联表;阈值条件(dimension、stat_scope、threshold 数值)全局一致,无需在分配层重复存储。
|
||||
|
||||
**备选方案**:新建 `tb_shop_series_allocation_tier` 子表 → 被否决,阈值不可修改且数量固定(跟随系列配置),子表增加了查询复杂度。
|
||||
|
||||
### 决策 3:梯度模式计算引擎改写
|
||||
|
||||
**原逻辑**(错误):
|
||||
```
|
||||
matchOneTimeCommissionTier(ctx, shopID, seriesID, allocationID, config.Tiers)
|
||||
↑ PackageSeries 全局阶梯金额
|
||||
```
|
||||
|
||||
**新逻辑**:
|
||||
```
|
||||
1. 从 PackageSeries 取 tiers(dimension、stat_scope、threshold 列表)
|
||||
2. 查询当前代理的 ShopSeriesAllocation.commission_tiers_json(专属金额)
|
||||
3. 根据该代理的销售统计匹配命中的 threshold
|
||||
4. 从步骤 2 的专属金额列表中取出该 threshold 对应的 amount → 即 myAmount
|
||||
```
|
||||
|
||||
阶梯金额的来源由全局改为各代理自己的记录,dimension/stat_scope/threshold 仍从全局读取。
|
||||
|
||||
### 决策 4:统一授权响应格式
|
||||
|
||||
`GET /shop-series-grants/:id` 返回聚合视图:
|
||||
|
||||
```
|
||||
ShopSeriesGrantResponse {
|
||||
id // ShopSeriesAllocation.ID
|
||||
shop_id / shop_name
|
||||
series_id / series_name / series_code
|
||||
commission_type // "fixed" 或 "tiered",从 PackageSeries 读取
|
||||
one_time_commission_amount // 固定模式:该代理的天花板;梯度模式:返回 0
|
||||
commission_tiers // 梯度模式:专属阶梯列表;固定模式:空数组
|
||||
force_recharge_locked // true=平台已设强充,前端只读
|
||||
force_recharge_enabled
|
||||
force_recharge_amount
|
||||
allocator_shop_id / allocator_shop_name
|
||||
status
|
||||
packages: [{
|
||||
package_id, package_name, package_code, cost_price, shelf_status, status
|
||||
}]
|
||||
created_at / updated_at
|
||||
}
|
||||
```
|
||||
|
||||
### 决策 5:金额上限校验规则
|
||||
|
||||
**固定模式**:
|
||||
```
|
||||
allocator_shop_id == 0(平台)→ one_time_commission_amount ≤ PackageSeries.commission_amount
|
||||
allocator_shop_id > 0(代理)→ one_time_commission_amount ≤ 分配者自身的 one_time_commission_amount
|
||||
```
|
||||
|
||||
**梯度模式**:
|
||||
```
|
||||
对请求中每一个 {threshold, amount}:
|
||||
allocator_shop_id == 0(平台)→ amount ≤ PackageSeries 同 threshold 的 amount
|
||||
allocator_shop_id > 0(代理)→ amount ≤ 分配者的 commission_tiers_json 中同 threshold 的 amount
|
||||
```
|
||||
|
||||
### 决策 6:强充层级的实现位置
|
||||
|
||||
在 `order/service.go` 的 `checkForceRechargeRequirement` 中增加:
|
||||
|
||||
```
|
||||
1. 从 PackageSeries 获取配置,config.Enable == false → 无强充
|
||||
|
||||
2. firstCommissionPaid == true → 无强充
|
||||
|
||||
3. config.TriggerType == "first_recharge":
|
||||
→ 直接返回需要强充(amount = config.Threshold)
|
||||
→ 首次充值本身即为强充机制,代理无法修改此行为
|
||||
→ 创建授权时忽略代理传入的 enable_force_recharge,响应 force_recharge_locked=true
|
||||
|
||||
4. config.TriggerType == "accumulated_recharge":
|
||||
a. config.EnableForceRecharge == true
|
||||
→ 使用平台强充(amount = config.ForceAmount),force_recharge_locked=true
|
||||
b. config.EnableForceRecharge == false
|
||||
→ 查询 result.Card.ShopID(或 result.Device.ShopID)的 ShopSeriesAllocation
|
||||
→ 若 allocation.EnableForceRecharge == true → 使用代理强充配置
|
||||
→ 否则 → 无强充
|
||||
```
|
||||
|
||||
**Grant API 强充字段的可写条件**:
|
||||
- `TriggerType == first_recharge`:`enable_force_recharge` 字段无效,创建/更新时忽略,响应 `force_recharge_locked=true`
|
||||
- `TriggerType == accumulated_recharge` + 平台已设:同上,`force_recharge_locked=true`
|
||||
- `TriggerType == accumulated_recharge` + 平台未设:`enable_force_recharge` 和 `force_recharge_amount` 生效,`force_recharge_locked=false`
|
||||
|
||||
### 决策 7:删除旧接口范围
|
||||
|
||||
删除以下内容(开发阶段干净重构):
|
||||
- `internal/handler/admin/shop_series_allocation.go`
|
||||
- `internal/handler/admin/shop_package_allocation.go`
|
||||
- `internal/routes/shop_series_allocation.go`
|
||||
- `internal/routes/shop_package_allocation.go`
|
||||
- `internal/model/dto/shop_series_allocation.go`
|
||||
- `internal/model/dto/shop_package_allocation.go`
|
||||
- `internal/service/shop_series_allocation/`
|
||||
- `internal/service/shop_package_allocation/`
|
||||
- 从 `bootstrap/types.go`、`bootstrap/handlers.go`、`bootstrap/services.go`、`pkg/openapi/handlers.go`、`routes/admin.go` 中移除相关引用
|
||||
|
||||
保留(不删除):
|
||||
- 两个 Store(`ShopSeriesAllocationStore`、`ShopPackageAllocationStore`)——被佣金计算、订单服务、新 Grant Service 使用
|
||||
- `ShopPackageBatchAllocation`、`ShopPackageBatchPricing` Service + Handler + routes(批量操作不在本次范围)
|
||||
|
||||
### 决策 8:数据库迁移策略
|
||||
|
||||
单次迁移文件完成:
|
||||
- 删除 3 列:`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold`
|
||||
- 新增 1 列:`commission_tiers_json JSONB NOT NULL DEFAULT '[]'`
|
||||
|
||||
迁移顺序:先部署代码(新代码不再读写 3 个死字段,新字段有默认值),再执行迁移,规避滚动部署期间的字段缺失问题。DOWN 脚本不可逆(不恢复数据)。
|
||||
|
||||
### 决策 9:梯度阶梯运算符支持
|
||||
|
||||
**背景**:当前 `matchOneTimeCommissionTier` 对所有阶梯固定使用 `>=` 做阈值比较,无法支持不同运算符(如"销量 < 100 给 X 元")。
|
||||
|
||||
**选择**:在 `OneTimeCommissionTier` 结构体新增 `Operator string` 字段,支持 `>`、`>=`、`<`、`<=`,默认值 `>=`(向前兼容)。
|
||||
|
||||
**存储位置**:
|
||||
- `Operator` 仅存于 `PackageSeries.config.Tiers`(全局,由套餐系列配置决定)
|
||||
- `ShopSeriesAllocation.commission_tiers_json` 不存 `Operator`(代理不能修改条件,只能修改金额)
|
||||
- Grant 响应的 `commission_tiers` 展示时,将 `Operator` 从 PackageSeries 全局 tiers 按 threshold 合并进来
|
||||
|
||||
**计算引擎**:`matchOneTimeCommissionTier` 根据 `Operator` 选择对应比较函数;`Operator` 为空时 fallback 到 `>=`。
|
||||
|
||||
**不修改**:PackageSeries 的创建/编辑 API 不在本次范围(通过直接写 JSONB 设置 operator)。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|----------|
|
||||
| 旧接口删除后前端需同步更新 | 开发阶段删除无历史数据顾虑,与前端对齐后统一上线 |
|
||||
| 现有梯度模式历史分配记录的 commission_tiers_json 为空 | 新字段 DEFAULT '[]',计算引擎遇到空数组时 fallback 到 one_time_commission_amount(存 0)→ 不发佣金,需在上线前批量补填历史数据或确认历史记录均为固定模式 |
|
||||
| checkForceRechargeRequirement 新增 DB 查询 | 函数已有 2 次查询,增加 1 次;量级可接受,后续可加缓存 |
|
||||
| 梯度模式计算引擎改写影响正在运行的佣金计算 | 新逻辑仅在 commission_tiers_json 有值时生效;历史空记录走 fallback,不影响已发放佣金 |
|
||||
@@ -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/`
|
||||
@@ -0,0 +1,181 @@
|
||||
# Capability: 代理系列授权管理
|
||||
|
||||
## Purpose
|
||||
|
||||
定义"系列授权"(Series Grant)的 CRUD 操作。系列授权将原本割裂的"系列分配"和"套餐分配"合并为一个原子操作,一次请求完成:授权代理可销售某系列下的指定套餐、设定每个套餐的成本价、配置一次性佣金(固定模式:单值天花板;梯度模式:每档位上限金额列表)和代理自设强充(平台未设时)。
|
||||
|
||||
底层仍使用 `tb_shop_series_allocation` 和 `tb_shop_package_allocation` 两张表,对外以 `ShopSeriesAllocation.ID` 作为 grant 主键。
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 创建系列授权(固定模式)
|
||||
|
||||
系统 SHALL 提供 `POST /shop-series-grants` 接口,在一次请求中原子性创建系列分配和套餐分配列表。固定模式下 `one_time_commission_amount` MUST 必填,且不得超过分配者自身的天花板。
|
||||
|
||||
#### Scenario: 代理成功创建固定模式授权
|
||||
- **WHEN** 代理A(自身天花板=80元)为直属下级代理B 创建系列授权,commission_type=fixed,one_time_commission_amount=5000(50元),packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}]
|
||||
- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocation(one_time_commission_amount=5000)和 2 条 ShopPackageAllocation,响应返回包含 packages 列表的聚合视图
|
||||
|
||||
#### Scenario: 代理B 已存在此系列授权,重复创建
|
||||
- **WHEN** 代理A 为代理B 创建系列授权,但代理B 在此系列下已有 active 授权记录
|
||||
- **THEN** 系统返回错误"该代理已存在此系列授权"
|
||||
|
||||
#### Scenario: 分配者自身无此系列授权
|
||||
- **WHEN** 代理A 自身未被授权此套餐系列,尝试为代理B 创建此系列授权
|
||||
- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配"
|
||||
|
||||
#### Scenario: 平台成功创建固定模式授权
|
||||
- **WHEN** 平台管理员为一级代理创建系列授权,commission_type=fixed,one_time_commission_amount=8000(80元),系列总额 commission_amount=10000(100元)
|
||||
- **THEN** 系统创建授权,响应中 allocator_shop_id=0,allocator_shop_name="平台"
|
||||
|
||||
#### Scenario: 固定模式 one_time_commission_amount 为必填
|
||||
- **WHEN** 请求中不包含 one_time_commission_amount
|
||||
- **THEN** 系统返回参数错误"固定模式下一次性佣金额度为必填项"
|
||||
|
||||
#### Scenario: 金额超过代理自身天花板
|
||||
- **WHEN** 代理A(天花板=8000分)为代理B 创建授权,one_time_commission_amount=10000
|
||||
- **THEN** 系统返回错误"一次性佣金额度不能超过上级限额"
|
||||
|
||||
#### Scenario: 平台金额超过系列总额
|
||||
- **WHEN** 平台为代理A 创建授权,one_time_commission_amount=12000,但 PackageSeries.commission_amount=10000
|
||||
- **THEN** 系统返回错误"一次性佣金额度不能超过套餐系列设定的总额"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 创建系列授权(梯度模式)
|
||||
|
||||
系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0,不可省略该档位。
|
||||
|
||||
#### Scenario: 代理成功创建梯度模式授权
|
||||
- **WHEN** 代理A 的专属阶梯为 [{operator:">=", threshold:100, amount:80}, {operator:">=", threshold:150, amount:120}],A 为代理B 创建授权,传入 commission_tiers=[{threshold:100, amount:50}, {threshold:150, amount:100}]
|
||||
- **THEN** 系统创建授权,commission_tiers_json 存储 [{threshold:100, amount:50}, {threshold:150, amount:100}],响应中 commission_tiers=[{operator:">=", threshold:100, amount:50}, {operator:">=", threshold:150, amount:100}](operator 从 PackageSeries 读取后合并)
|
||||
|
||||
#### Scenario: 平台成功创建梯度模式授权
|
||||
- **WHEN** 平台为顶级代理A 创建授权,PackageSeries 阶梯为 [{operator:">=", threshold:100, amount:100}, {operator:"<", threshold:50, amount:30}],传入 commission_tiers=[{threshold:100, amount:80}, {threshold:50, amount:20}]
|
||||
- **THEN** 系统创建授权,A 的专属阶梯存入 commission_tiers_json,响应中 commission_tiers 包含对应的 operator
|
||||
|
||||
#### Scenario: 梯度模式某档位金额超过父级
|
||||
- **WHEN** 代理A 的阶梯第一档 amount=80,A 为 B 创建授权时传入第一档 amount=90
|
||||
- **THEN** 系统返回错误"梯度佣金档位金额不能超过上级同档位限额"
|
||||
|
||||
#### Scenario: 梯度模式传入了不存在的阈值
|
||||
- **WHEN** PackageSeries 只有 threshold=100 和 150 两档,请求中传入 threshold=200
|
||||
- **THEN** 系统返回错误"阶梯阈值与系列配置不匹配"
|
||||
|
||||
#### Scenario: 梯度模式 commission_tiers 为必填
|
||||
- **WHEN** 请求中不包含 commission_tiers 或为空数组
|
||||
- **THEN** 系统返回参数错误"梯度模式下必须提供阶梯金额配置"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 强充配置的平台/代理层级
|
||||
|
||||
创建系列授权时,系统 SHALL 根据 PackageSeries 的触发类型和强充设置决定代理是否可自设强充。
|
||||
|
||||
#### Scenario: 首次充值触发类型,强充不可配置
|
||||
- **WHEN** PackageSeries.trigger_type=first_recharge,代理创建授权时传入任意 enable_force_recharge 值
|
||||
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true(首次充值本身即为强充机制,无需额外配置)
|
||||
|
||||
#### Scenario: 累计充值触发类型,平台已设强充,代理配置被忽略
|
||||
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=true,代理创建授权时传入 enable_force_recharge=false
|
||||
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true
|
||||
|
||||
#### Scenario: 累计充值触发类型,平台未设强充,代理可自设
|
||||
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=false,代理创建授权时传入 enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=false,force_recharge_enabled=true,force_recharge_amount=10000
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权详情
|
||||
|
||||
系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。
|
||||
|
||||
#### Scenario: 固定模式详情
|
||||
- **WHEN** 查询固定模式系列授权详情
|
||||
- **THEN** 响应包含 commission_type="fixed",one_time_commission_amount=有效值,commission_tiers=[]
|
||||
|
||||
#### Scenario: 梯度模式详情
|
||||
- **WHEN** 查询梯度模式系列授权详情
|
||||
- **THEN** 响应包含 commission_type="tiered",one_time_commission_amount=0,commission_tiers=[{threshold, amount}, ...]
|
||||
|
||||
#### Scenario: 查询不存在的授权
|
||||
- **WHEN** 查询不存在的授权 ID
|
||||
- **THEN** 系统返回错误"授权记录不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权列表
|
||||
|
||||
系统 SHALL 提供 `GET /shop-series-grants` 接口,支持分页和多维度筛选,响应内嵌套餐数量摘要(不含完整套餐列表)。
|
||||
|
||||
#### Scenario: 列表查询支持按店铺和系列筛选
|
||||
- **WHEN** 传入 shop_id、series_id、allocator_shop_id 等筛选条件
|
||||
- **THEN** 仅返回符合条件的授权记录,每条记录包含 package_count
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 更新系列授权配置
|
||||
|
||||
系统 SHALL 提供 `PUT /shop-series-grants/:id` 接口,支持更新一次性佣金配置和强充配置。
|
||||
|
||||
#### Scenario: 固定模式更新佣金额度
|
||||
- **WHEN** 更新 one_time_commission_amount,新值不超过分配者天花板
|
||||
- **THEN** 系统更新成功
|
||||
|
||||
#### Scenario: 梯度模式更新阶梯金额
|
||||
- **WHEN** 更新 commission_tiers,每档位金额不超过分配者同档位上限
|
||||
- **THEN** 系统更新 commission_tiers_json 字段
|
||||
|
||||
#### Scenario: 更新代理自设强充(平台未设时)
|
||||
- **WHEN** 平台未设强充,更新 enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统更新成功,后续该代理渠道下的客户须满足强充要求
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理授权内套餐
|
||||
|
||||
系统 SHALL 提供 `PUT /shop-series-grants/:id/packages` 接口,支持添加套餐、移除套餐、更新成本价,操作在事务中完成,成功后返回 HTTP 200(无需返回完整授权视图)。
|
||||
|
||||
#### Scenario: 向授权中添加新套餐
|
||||
- **WHEN** 请求包含新的 package_id 和 cost_price,且该套餐属于此系列
|
||||
- **THEN** 系统创建新的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: 更新套餐成本价
|
||||
- **WHEN** 请求中套餐的 cost_price 与当前值不同
|
||||
- **THEN** 系统更新 cost_price 并写价格历史记录
|
||||
|
||||
#### Scenario: 移除授权中的套餐
|
||||
- **WHEN** 请求中某套餐标记 remove=true,且该套餐在当前授权中存在
|
||||
- **THEN** 系统软删除对应的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: remove=true 但套餐已不在授权中
|
||||
- **WHEN** 请求中某套餐标记 remove=true,但该套餐已被软删除或从未在此授权中
|
||||
- **THEN** 系统静默忽略该条目,不报错,继续处理其他条目
|
||||
|
||||
#### Scenario: 重新添加曾被移除的套餐
|
||||
- **WHEN** 某套餐曾经被软删除,请求中再次包含该 package_id(无 remove 标志)
|
||||
- **THEN** 系统创建一条新的 ShopPackageAllocation 记录,不恢复旧记录
|
||||
|
||||
#### Scenario: 添加不属于该系列的套餐
|
||||
- **WHEN** 请求中包含不属于该系列的 package_id
|
||||
- **THEN** 系统返回错误"套餐不属于该系列,无法添加到此授权"
|
||||
|
||||
#### Scenario: 添加上级未授权的套餐
|
||||
- **WHEN** 代理A 尝试添加代理A 自己也未获授权的套餐
|
||||
- **THEN** 系统返回错误"无权限分配该套餐"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 删除系列授权
|
||||
|
||||
系统 SHALL 提供 `DELETE /shop-series-grants/:id` 接口,删除时同步软删除所有关联的套餐分配。
|
||||
|
||||
#### Scenario: 成功删除无下级依赖的授权
|
||||
- **WHEN** 删除一个下级代理未基于此授权再分配的记录
|
||||
- **THEN** 系统软删除 ShopSeriesAllocation 和所有关联的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: 有下级依赖时禁止删除
|
||||
- **WHEN** 删除一个已被下级代理用于创建子授权的记录
|
||||
- **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"
|
||||
@@ -0,0 +1,75 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理层强充层级判断
|
||||
|
||||
系统 SHALL 在强充预检时按层级判断生效的强充配置:平台在 PackageSeries 中设置的强充具有最高优先级;平台未设强充时,读取客户所属销售代理(`order.SellerShopID`)对应的 ShopSeriesAllocation 强充配置。
|
||||
|
||||
#### Scenario: 平台已设强充,代理自设被忽略
|
||||
- **WHEN** PackageSeries.enable_force_recharge=true(平台层),客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统使用平台强充规则,need_force_recharge=true,force_recharge_amount=平台设定值
|
||||
|
||||
#### Scenario: 平台未设强充,代理自设生效
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统使用代理A 的强充配置,need_force_recharge=true,force_recharge_amount=10000
|
||||
|
||||
#### Scenario: 平台未设强充,代理也未设强充
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,代理A 的 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统返回 need_force_recharge=false
|
||||
|
||||
#### Scenario: 平台未设强充,查询不到销售代理分配
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,系统查询不到 SellerShop 对应的 ShopSeriesAllocation
|
||||
- **THEN** 系统返回 need_force_recharge=false(降级处理,不影响购买流程)
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 钱包充值预检
|
||||
|
||||
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。强充判断 MUST 按代理层级规则执行:优先使用平台强充,平台未设时使用销售代理自设强充。
|
||||
|
||||
#### Scenario: 无强充要求
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=false,销售代理 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统返回 need_force_recharge=false
|
||||
|
||||
#### Scenario: 首次充值强充(平台层)
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries 配置为首次充值触发,阈值 10000 分,未发放佣金
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="single_recharge"
|
||||
|
||||
#### Scenario: 累计充值启用强充(平台层)
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=true,force_amount=10000
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="accumulated_recharge"
|
||||
|
||||
#### Scenario: 代理自设累计充值强充(平台未设)
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,销售代理的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=8000
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=8000
|
||||
|
||||
#### Scenario: 一次性佣金已发放
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
|
||||
- **THEN** 系统返回 need_force_recharge=false(不再强充)
|
||||
|
||||
#### Scenario: 未启用一次性佣金
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡关联系列未启用一次性佣金
|
||||
- **THEN** 系统返回 need_force_recharge=false
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐购买预检
|
||||
|
||||
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。强充判断 MUST 按代理层级规则执行。
|
||||
|
||||
#### Scenario: 无强充要求正常购买
|
||||
- **WHEN** 客户购买 90 元套餐,平台和销售代理均未设强充
|
||||
- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=false,actual_payment=9000,wallet_credit=0
|
||||
|
||||
#### Scenario: 代理自设强充,套餐价低于强充金额
|
||||
- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000
|
||||
- **THEN** 系统返回 actual_payment=10000,wallet_credit=5000
|
||||
|
||||
#### Scenario: 首次充值强充(平台层),套餐价低于阈值
|
||||
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层)
|
||||
- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=true,force_recharge_amount=10000,actual_payment=10000,wallet_credit=1000
|
||||
|
||||
#### Scenario: 购买多个套餐
|
||||
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount=12000,actual_payment=12000,wallet_credit=0
|
||||
@@ -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 个字段。
|
||||
@@ -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="<"` 时,销售统计低于阈值的代理命中该档位,高于阈值的代理不命中
|
||||
181
openspec/specs/agent-series-grant/spec.md
Normal file
181
openspec/specs/agent-series-grant/spec.md
Normal 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=fixed,one_time_commission_amount=5000(50元),packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}]
|
||||
- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocation(one_time_commission_amount=5000)和 2 条 ShopPackageAllocation,响应返回包含 packages 列表的聚合视图
|
||||
|
||||
#### Scenario: 代理B 已存在此系列授权,重复创建
|
||||
- **WHEN** 代理A 为代理B 创建系列授权,但代理B 在此系列下已有 active 授权记录
|
||||
- **THEN** 系统返回错误"该代理已存在此系列授权"
|
||||
|
||||
#### Scenario: 分配者自身无此系列授权
|
||||
- **WHEN** 代理A 自身未被授权此套餐系列,尝试为代理B 创建此系列授权
|
||||
- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配"
|
||||
|
||||
#### Scenario: 平台成功创建固定模式授权
|
||||
- **WHEN** 平台管理员为一级代理创建系列授权,commission_type=fixed,one_time_commission_amount=8000(80元),系列总额 commission_amount=10000(100元)
|
||||
- **THEN** 系统创建授权,响应中 allocator_shop_id=0,allocator_shop_name="平台"
|
||||
|
||||
#### Scenario: 固定模式 one_time_commission_amount 为必填
|
||||
- **WHEN** 请求中不包含 one_time_commission_amount
|
||||
- **THEN** 系统返回参数错误"固定模式下一次性佣金额度为必填项"
|
||||
|
||||
#### Scenario: 金额超过代理自身天花板
|
||||
- **WHEN** 代理A(天花板=8000分)为代理B 创建授权,one_time_commission_amount=10000
|
||||
- **THEN** 系统返回错误"一次性佣金额度不能超过上级限额"
|
||||
|
||||
#### Scenario: 平台金额超过系列总额
|
||||
- **WHEN** 平台为代理A 创建授权,one_time_commission_amount=12000,但 PackageSeries.commission_amount=10000
|
||||
- **THEN** 系统返回错误"一次性佣金额度不能超过套餐系列设定的总额"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 创建系列授权(梯度模式)
|
||||
|
||||
系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0,不可省略该档位。
|
||||
|
||||
#### Scenario: 代理成功创建梯度模式授权
|
||||
- **WHEN** 代理A 的专属阶梯为 [{operator:">=", threshold:100, amount:80}, {operator:">=", threshold:150, amount:120}],A 为代理B 创建授权,传入 commission_tiers=[{threshold:100, amount:50}, {threshold:150, amount:100}]
|
||||
- **THEN** 系统创建授权,commission_tiers_json 存储 [{threshold:100, amount:50}, {threshold:150, amount:100}],响应中 commission_tiers=[{operator:">=", threshold:100, amount:50}, {operator:">=", threshold:150, amount:100}](operator 从 PackageSeries 读取后合并)
|
||||
|
||||
#### Scenario: 平台成功创建梯度模式授权
|
||||
- **WHEN** 平台为顶级代理A 创建授权,PackageSeries 阶梯为 [{operator:">=", threshold:100, amount:100}, {operator:"<", threshold:50, amount:30}],传入 commission_tiers=[{threshold:100, amount:80}, {threshold:50, amount:20}]
|
||||
- **THEN** 系统创建授权,A 的专属阶梯存入 commission_tiers_json,响应中 commission_tiers 包含对应的 operator
|
||||
|
||||
#### Scenario: 梯度模式某档位金额超过父级
|
||||
- **WHEN** 代理A 的阶梯第一档 amount=80,A 为 B 创建授权时传入第一档 amount=90
|
||||
- **THEN** 系统返回错误"梯度佣金档位金额不能超过上级同档位限额"
|
||||
|
||||
#### Scenario: 梯度模式传入了不存在的阈值
|
||||
- **WHEN** PackageSeries 只有 threshold=100 和 150 两档,请求中传入 threshold=200
|
||||
- **THEN** 系统返回错误"阶梯阈值与系列配置不匹配"
|
||||
|
||||
#### Scenario: 梯度模式 commission_tiers 为必填
|
||||
- **WHEN** 请求中不包含 commission_tiers 或为空数组
|
||||
- **THEN** 系统返回参数错误"梯度模式下必须提供阶梯金额配置"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 强充配置的平台/代理层级
|
||||
|
||||
创建系列授权时,系统 SHALL 根据 PackageSeries 的触发类型和强充设置决定代理是否可自设强充。
|
||||
|
||||
#### Scenario: 首次充值触发类型,强充不可配置
|
||||
- **WHEN** PackageSeries.trigger_type=first_recharge,代理创建授权时传入任意 enable_force_recharge 值
|
||||
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true(首次充值本身即为强充机制,无需额外配置)
|
||||
|
||||
#### Scenario: 累计充值触发类型,平台已设强充,代理配置被忽略
|
||||
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=true,代理创建授权时传入 enable_force_recharge=false
|
||||
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true
|
||||
|
||||
#### Scenario: 累计充值触发类型,平台未设强充,代理可自设
|
||||
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=false,代理创建授权时传入 enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=false,force_recharge_enabled=true,force_recharge_amount=10000
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权详情
|
||||
|
||||
系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。
|
||||
|
||||
#### Scenario: 固定模式详情
|
||||
- **WHEN** 查询固定模式系列授权详情
|
||||
- **THEN** 响应包含 commission_type="fixed",one_time_commission_amount=有效值,commission_tiers=[]
|
||||
|
||||
#### Scenario: 梯度模式详情
|
||||
- **WHEN** 查询梯度模式系列授权详情
|
||||
- **THEN** 响应包含 commission_type="tiered",one_time_commission_amount=0,commission_tiers=[{threshold, amount}, ...]
|
||||
|
||||
#### Scenario: 查询不存在的授权
|
||||
- **WHEN** 查询不存在的授权 ID
|
||||
- **THEN** 系统返回错误"授权记录不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权列表
|
||||
|
||||
系统 SHALL 提供 `GET /shop-series-grants` 接口,支持分页和多维度筛选,响应内嵌套餐数量摘要(不含完整套餐列表)。
|
||||
|
||||
#### Scenario: 列表查询支持按店铺和系列筛选
|
||||
- **WHEN** 传入 shop_id、series_id、allocator_shop_id 等筛选条件
|
||||
- **THEN** 仅返回符合条件的授权记录,每条记录包含 package_count
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 更新系列授权配置
|
||||
|
||||
系统 SHALL 提供 `PUT /shop-series-grants/:id` 接口,支持更新一次性佣金配置和强充配置。
|
||||
|
||||
#### Scenario: 固定模式更新佣金额度
|
||||
- **WHEN** 更新 one_time_commission_amount,新值不超过分配者天花板
|
||||
- **THEN** 系统更新成功
|
||||
|
||||
#### Scenario: 梯度模式更新阶梯金额
|
||||
- **WHEN** 更新 commission_tiers,每档位金额不超过分配者同档位上限
|
||||
- **THEN** 系统更新 commission_tiers_json 字段
|
||||
|
||||
#### Scenario: 更新代理自设强充(平台未设时)
|
||||
- **WHEN** 平台未设强充,更新 enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统更新成功,后续该代理渠道下的客户须满足强充要求
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理授权内套餐
|
||||
|
||||
系统 SHALL 提供 `PUT /shop-series-grants/:id/packages` 接口,支持添加套餐、移除套餐、更新成本价,操作在事务中完成,成功后返回 HTTP 200(无需返回完整授权视图)。
|
||||
|
||||
#### Scenario: 向授权中添加新套餐
|
||||
- **WHEN** 请求包含新的 package_id 和 cost_price,且该套餐属于此系列
|
||||
- **THEN** 系统创建新的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: 更新套餐成本价
|
||||
- **WHEN** 请求中套餐的 cost_price 与当前值不同
|
||||
- **THEN** 系统更新 cost_price 并写价格历史记录
|
||||
|
||||
#### Scenario: 移除授权中的套餐
|
||||
- **WHEN** 请求中某套餐标记 remove=true,且该套餐在当前授权中存在
|
||||
- **THEN** 系统软删除对应的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: remove=true 但套餐已不在授权中
|
||||
- **WHEN** 请求中某套餐标记 remove=true,但该套餐已被软删除或从未在此授权中
|
||||
- **THEN** 系统静默忽略该条目,不报错,继续处理其他条目
|
||||
|
||||
#### Scenario: 重新添加曾被移除的套餐
|
||||
- **WHEN** 某套餐曾经被软删除,请求中再次包含该 package_id(无 remove 标志)
|
||||
- **THEN** 系统创建一条新的 ShopPackageAllocation 记录,不恢复旧记录
|
||||
|
||||
#### Scenario: 添加不属于该系列的套餐
|
||||
- **WHEN** 请求中包含不属于该系列的 package_id
|
||||
- **THEN** 系统返回错误"套餐不属于该系列,无法添加到此授权"
|
||||
|
||||
#### Scenario: 添加上级未授权的套餐
|
||||
- **WHEN** 代理A 尝试添加代理A 自己也未获授权的套餐
|
||||
- **THEN** 系统返回错误"无权限分配该套餐"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 删除系列授权
|
||||
|
||||
系统 SHALL 提供 `DELETE /shop-series-grants/:id` 接口,删除时同步软删除所有关联的套餐分配。
|
||||
|
||||
#### Scenario: 成功删除无下级依赖的授权
|
||||
- **WHEN** 删除一个下级代理未基于此授权再分配的记录
|
||||
- **THEN** 系统软删除 ShopSeriesAllocation 和所有关联的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: 有下级依赖时禁止删除
|
||||
- **WHEN** 删除一个已被下级代理用于创建子授权的记录
|
||||
- **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"
|
||||
@@ -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=true,force_recharge_amount=平台设定值
|
||||
|
||||
#### Scenario: 平台未设强充,代理自设生效
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统使用代理A 的强充配置,need_force_recharge=true,force_recharge_amount=10000
|
||||
|
||||
#### Scenario: 平台未设强充,代理也未设强充
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,代理A 的 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统返回 need_force_recharge=false
|
||||
|
||||
#### Scenario: 平台未设强充,查询不到销售代理分配
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,系统查询不到 SellerShop 对应的 ShopSeriesAllocation
|
||||
- **THEN** 系统返回 need_force_recharge=false(降级处理,不影响购买流程)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包充值预检
|
||||
|
||||
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。
|
||||
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。强充判断 MUST 按代理层级规则执行:优先使用平台强充,平台未设时使用销售代理自设强充。
|
||||
|
||||
#### Scenario: 无强充要求
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发且未启用强充
|
||||
- **THEN** 系统返回 need_force_recharge = false,min_amount = 100(1元),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 = true,force_recharge_amount = 10000,trigger_type = "single_recharge",message = "首次充值需充值100元"
|
||||
#### Scenario: 首次充值强充(平台层)
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries 配置为首次充值触发,阈值 10000 分,未发放佣金
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="single_recharge"
|
||||
|
||||
#### Scenario: 累计充值启用强充
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡配置为累计充值触发,启用强充,强充金额 10000 分(100元)
|
||||
- **THEN** 系统返回 need_force_recharge = true,force_recharge_amount = 10000,trigger_type = "accumulated_recharge",message = "每次充值需充值100元"
|
||||
#### Scenario: 累计充值启用强充(平台层)
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=true,force_amount=10000
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="accumulated_recharge"
|
||||
|
||||
#### Scenario: 代理自设累计充值强充(平台未设)
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,销售代理的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=8000
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=8000
|
||||
|
||||
#### Scenario: 一次性佣金已发放
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
|
||||
@@ -34,15 +60,19 @@
|
||||
|
||||
### Requirement: 套餐购买预检
|
||||
|
||||
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。
|
||||
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。强充判断 MUST 按代理层级规则执行。
|
||||
|
||||
#### Scenario: 无强充要求正常购买
|
||||
- **WHEN** 客户购买 90 元套餐,无强充要求
|
||||
- **THEN** 系统返回 total_package_amount = 9000,need_force_recharge = false,actual_payment = 9000,wallet_credit = 0
|
||||
- **WHEN** 客户购买 90 元套餐,平台和销售代理均未设强充
|
||||
- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=false,actual_payment=9000,wallet_credit=0
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价低于阈值
|
||||
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 9000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 10000,wallet_credit = 1000,message = "需充值100元,购买套餐后余额10元"
|
||||
#### Scenario: 代理自设强充,套餐价低于强充金额
|
||||
- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000
|
||||
- **THEN** 系统返回 actual_payment=10000,wallet_credit=5000
|
||||
|
||||
#### Scenario: 首次充值强充(平台层),套餐价低于阈值
|
||||
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层)
|
||||
- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=true,force_recharge_amount=10000,actual_payment=10000,wallet_credit=1000
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价高于阈值
|
||||
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元
|
||||
|
||||
@@ -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 个字段。
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user