迭代计划准备

This commit is contained in:
2026-07-16 15:07:59 +08:00
parent 1a9db9328e
commit c4f430ccb3
22 changed files with 2969 additions and 1569 deletions

2
.gitignore vendored
View File

@@ -107,3 +107,5 @@ docs/admin-openapi.yaml
# /teach skill 的个人学习工作区,不进入项目提交历史
.claude/teach-workspace/
scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv

View File

@@ -72,18 +72,23 @@ handlers := &bootstrap.Handlers{
- 使用 `net/http` 替代 Fiber
- 使用 `encoding/json` 替代 sonic除非必要
## 架构分层
## 架构演进MVC 与 DDD 共存
必须遵循以下分层架构:
项目采用**触碰式渐进迁移**,不安排一次性全仓库重构。详细规则见 `docs/7月迭代/DDD规范.md`
```
Handler → Service → Store → Model
```
### 三条执行通道
- **Handler**: 只处理 HTTP 请求/响应,不包含业务逻辑
- **Service**: 包含所有业务逻辑,支持跨模块调用
- **Store**: 统一管理所有数据访问,支持事务处理
- **Model**: 定义数据结构和 DTO
- **复杂写操作**`Handler → Application UseCase → Domain → Repository/Infrastructure`。适用于状态机、金额、库存、并发不变量、策略和可靠事件。
- **简单写操作**`Handler → Application 事务脚本 → Persistence`。单表 CRUD 不强行创建聚合。
- **读取操作**`Handler → Query → GORM/DTO`。列表、详情、联表、统计、报表和导出不经过聚合根。
### 触碰式迁移约束
1. 禁止主动迁移当前需求未触碰的旧模块;简单字段、筛选和局部修复默认沿用旧结构。
2. 迁移单位是**完整用例**,不是整个模块、文件或数据表;一旦迁移,相关业务不变量必须完整收口,禁止一半留在旧 Service、一半放在 Domain。
3. 当前需求触碰复杂写逻辑时,只迁移完成该需求所需的最小完整业务边界;旧 Service 可暂时作为内部代码迁移门面调用新 UseCase但这不等于必须保留旧 HTTP 接口。
4. 当前需求触碰复杂只读逻辑时,只迁移该查询到 `internal/query/<context>`Query 可直接使用 GORM 做联表、聚合、权限过滤和 DTO 投影。
5. 不为追求 DDD 形式创建无业务价值的接口、工厂和目录;架构选择不明确时先阅读 DDD 规范并写明判断依据。
## 核心原则
@@ -97,7 +102,7 @@ Handler → Service → Store → Model
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()``err.Error()`
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)``errors.Wrap(...)`
- Service/Application/Domain/Query 层禁止向接口调用方直接返回 `fmt.Errorf(...)`,必须转换为 `errors.New(...)``errors.Wrap(...)`
- 约定用法:`errors.New(code[, msg])``errors.Wrap(code, err[, msg])`
### 响应格式
@@ -194,6 +199,7 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
- 文档文件名和内容使用中文
- 同步更新 README.md
- 为导出的函数、类型编写文档注释
- 需要进入评审的标准/完整技术方案必须覆盖关键流程、前后端契约、异常闭环、发布回滚和待决策项;简单改动不强行画图。详细要求见 `docs/技术方案评审规范.md`
## 函数复杂度
@@ -225,14 +231,16 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
### 错误处理
- [ ] Service 层无 `fmt.Errorf` 对外返回
- [ ] Service/Application/Domain/Query 层无 `fmt.Errorf` 对外返回
- [ ] Handler 层参数校验不泄露细节
- [ ] 错误码使用正确4xx vs 5xx
- [ ] 错误日志完整(包含上下文)
### 代码质量
- [ ] 遵循 Handler → Service → Store → Model 分层
- [ ] 已按判断标准选择复杂写、简单写或 Query 通道,未扩大迁移范围
- [ ] 复杂写规则收口 Domain简单写使用 Application 事务脚本;只读逻辑不经过聚合根
- [ ] Query 负责读取权限、分页和 DTO 投影,写操作仍在 Domain 重新校验
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
@@ -309,7 +317,7 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
- 旧模块在 Service 层、新 DDD 模块在 Application UseCase 中注入审计能力,操作成功后调用 `LogOperation()`
- 必填字段:`OperatorID``OperationType``OperationDesc``BeforeData``AfterData`
- 异步写入Goroutine写入失败不影响业务失败时记录 Error 日志

343
CLAUDE.md
View File

@@ -1,343 +1,8 @@
# Claude 项目规则
---
@AGENTS.md
# junhong_cmp_fiber 项目开发规范
**重要**: 本文件包含核心规范。详细规范已提取为 Skills在特定任务时按需加载。
## 专项规范 Skills按需加载
以下规范在相关任务时**自动触发**,无需手动加载:
| 任务类型 | 触发 Skill | 说明 |
|---------|-----------|------|
| 创建/修改 DTO 文件 | `dto-standards` | description 标签、枚举字段、验证标签规范 |
| 创建/修改 Model 模型 | `model-standards` | GORM 模型结构、字段标签、TableName 规范 |
| 注册 API 路由 / **新增 Handler** | `api-routing` | Register() 函数、RouteSpec、**文档生成器更新** |
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
| 编写 Go 代码注释/文档注释 | `comment-standards` | 包/结构体/接口/函数/内联注释完整规范与示例 |
| 创建涉及接口的 OpenSpec 提案 | `openspec-api-contract` | 探索阶段引导清单、提案必填章节与完成标准 |
---
### ⚠️ 新增 Handler 时必须同步更新文档生成器
新增 Handler 后,接口不会自动出现在 OpenAPI 文档中。**必须手动更新以下两个文件**
```go
// cmd/api/docs.go 和 cmd/gendocs/main.go
handlers := &bootstrap.Handlers{
// ... 添加新 Handler
NewHandler: admin.NewXxxHandler(nil),
}
```
**完整检查清单**: 参见 [`docs/api-documentation-guide.md`](docs/api-documentation-guide.md#新增-handler-检查清单)
---
## 语言要求
**必须遵守:**
- 永远用中文交互
- 注释必须使用中文
- 文档必须使用中文
- 日志消息必须使用中文
- 用户可见的错误消息必须使用中文
- 变量名、函数名、类型名必须使用英文(遵循 Go 命名规范)
- GIT提交的commit必须使用中文
## 技术栈
**必须严格遵守,禁止替代方案:**
| 类型 | 技术 |
|------|------|
| HTTP 框架 | Fiber v2.x |
| ORM | GORM v1.25.x |
| 配置管理 | Viper |
| 日志 | Zap + Lumberjack.v2 |
| JSON 序列化 | sonic优先encoding/json必要时 |
| 验证 | Validator |
| 任务队列 | Asynq v0.24.x |
| 数据库 | PostgreSQL 14+ |
| 缓存 | Redis 6.0+ |
**禁止:**
- 直接使用 `database/sql`(必须通过 GORM
- 使用 `net/http` 替代 Fiber
- 使用 `encoding/json` 替代 sonic除非必要
## 架构分层
必须遵循以下分层架构:
```
Handler → Service → Store → Model
```
- **Handler**: 只处理 HTTP 请求/响应,不包含业务逻辑
- **Service**: 包含所有业务逻辑,支持跨模块调用
- **Store**: 统一管理所有数据访问,支持事务处理
- **Model**: 定义数据结构和 DTO
## 核心原则
### 错误处理
- 所有错误必须在 `pkg/errors/` 中定义
- 使用统一错误码系统
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
#### 错误报错规范(必须遵守)
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()``err.Error()`
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)``errors.Wrap(...)`
- 约定用法:`errors.New(code[, msg])``errors.Wrap(code, err[, msg])`
### 响应格式
- 所有 API 响应使用 `pkg/response/` 的统一格式
- 格式: `{code, msg, data, timestamp}`
### 常量管理
- 所有常量定义在 `pkg/constants/`
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
- 禁止硬编码字符串和 magic numbers
- **必须为所有常量添加中文注释**
### 枚举与状态字段(必须遵守)
**两个强制规则**
1. **int vs string**:状态类(生命周期)用 `int`,类型/方式类用 `string`
2. **DTO description 必须从 constants 原文抄写**,禁止凭记忆填写枚举值(历史上已有 description 与 constants 不一致导致前端显示错误的案例)
```go
// ✅ description 从 constants 原文抄,格式统一用冒号+逗号
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
StatusName string `json:"status_name" description:"状态名称(中文)"` // Response DTO 必须加
// ❌ 禁止:启用/禁用用 1=启用 2=禁用(全局约定是 0=禁用, 1=启用)
// ❌ 禁止description 枚举值与 constants 不一致
```
**完整规范**: 参见 [`docs/enum-status-standards.md`](docs/enum-status-standards.md)
### 注释规范
- **所有注释使用中文**,导出符号必须有文档注释(包、函数、类型、接口、常量)
- 复杂逻辑解释"为什么"而非"做了什么",禁止废话注释(复述代码本身)
- Handler 方法注释必须包含 HTTP 方法和路径(`// Create 创建账号` + `// POST /api/admin/accounts`
- 未导出函数:< 15 行可省略,≥ 15 行或非显而易见算法必须注释
- 修改代码时必须同步更新注释,过时注释比没有注释更有害
**详细规范与示例**:见 `comment-standards` skill
### Go 代码风格
- 使用 `gofmt` 格式化
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
- 包名: 简短、小写、单数、无下划线
- 接口命名: 使用 `-er` 后缀Reader、Writer、Logger
## 数据库设计
**核心规则:**
- ❌ 禁止建立外键约束
- ❌ 禁止使用 GORM 关联关系标签foreignKey、hasMany、belongsTo
- ✅ 关联通过存储 ID 字段手动维护
- ✅ 关联数据在代码层面显式查询
## Go 惯用法 vs Java 风格
### ✅ Go 风格(推荐)
- 扁平化包结构(最多 2-3 层)
- 小而专注的接口1-3 个方法)
- 直接访问导出字段(不用 getter/setter
- 组合优于继承
- 显式错误返回和检查
### ❌ Java 风格(禁止)
- 过度抽象(不必要的接口、工厂)
- Getter/Setter 方法
- 深层继承层次
- 异常处理panic/recover
- 类型前缀IService、AbstractBase、ServiceImpl
## ⚠️ 测试禁令(强制执行)
**本项目禁止任何形式的自动化测试**(单元/集成/E2E/`*_test.go` 文件),规划和文档中也不讨论测试。
**唯一例外**:用户明确说"请写测试"时。
**替代验证**PostgreSQL MCP 手动验证数据、Postman/curl 手动测试 API。
## 性能要求
- API P95 响应时间 < 200ms
- API P99 响应时间 < 500ms
- 数据库查询 < 50ms
- 列表查询必须分页(默认 20最大 100
- 避免 N+1 查询,使用批量操作
## 文档要求
- 每个功能在 `docs/{feature-id}/` 创建总结文档
- 文档文件名和内容使用中文
- 同步更新 README.md
- 为导出的函数、类型编写文档注释
## 函数复杂度
- 函数长度 ≤ 100 行(核心逻辑建议 ≤ 50 行)
- `main()` 函数只做编排,不含具体实现
- 遵循单一职责原则
## 访问日志
- 所有 HTTP 请求记录到 `access.log`
- 记录完整的请求/响应(限制 50KB
- 包含: method, path, query, status, duration, request_id, ip, user_agent, user_id, bodies
- 使用 JSON 格式,配置自动轮转
## OpenSpec 工作流
创建提案前的检查清单:
1. ✅ 技术栈合规
2. ✅ 架构分层正确
3. ✅ 使用统一错误处理
4. ✅ 常量定义在 pkg/constants/
5. ✅ Go 惯用法(非 Java 风格)
6. ✅ 性能考虑
7. ✅ 文档更新计划
8. ✅ 中文优先
## Code Review 检查清单
### 错误处理
- [ ] Service 层无 `fmt.Errorf` 对外返回
- [ ] Handler 层参数校验不泄露细节
- [ ] 错误码使用正确4xx vs 5xx
- [ ] 错误日志完整(包含上下文)
### 代码质量
- [ ] 遵循 Handler → Service → Store → Model 分层
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
### 枚举与状态
- [ ] 状态类字段用 `int`,类型/方式类字段用 `string`
- [ ] 禁用/启用使用 `0=禁用, 1=启用`(禁止 `1=启用, 2=禁用`
- [ ] DTO description 枚举列表已从 `pkg/constants/` 原文抄写,无遗漏、无错误
- [ ] Response DTO 的 int 状态字段有对应的 `_name` 文字字段
### 文档和注释
- [ ] 所有注释使用中文
- [ ] 导出函数/类型有文档注释
- [ ] API 路径注释与真实路由一致
### 幂等性
- [ ] 创建类写操作有 Redis 业务键防重
- [ ] 状态变更使用条件更新(`WHERE status = expected`
- [ ] 余额/库存变更使用乐观锁version 字段)
- [ ] 分布式锁使用 `defer` 确保释放
- [ ] Redis Key 定义在 `pkg/constants/redis.go`
### 越权防护规范
**三层防护机制**(基础设施已就绪,无需自建):
1. **路由层中间件**:粗粒度拦截(如企业账号禁止访问账号管理)
2. **Service 层业务检查**`middleware.CanManageShop()` / `middleware.CanManageEnterprise()` 细粒度验证;示例见 `internal/service/account/service.go`
3. **GORM Callback 自动过滤**代理按店铺层级、企业按企业ID已自动应用无需手动调用
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`(不区分"不存在"与"无权限",防止信息泄露)
### 幂等性规范
写操作按场景选择策略:
- **状态流转操作** → 状态条件更新(首选)
- **创建类操作(无状态可依赖)** → Redis 业务键防重 + 分布式锁(三层检测)
- **余额/库存数值更新** → 乐观锁(`version` 字段)
```go
// 策略1示例首选通过 WHERE 条件确保幂等RowsAffected=0 说明已被处理
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
Updates(map[string]any{"payment_status": model.PaymentStatusPaid})
if result.RowsAffected == 0 { /* 已处理,检查当前状态 */ }
```
- Redis Key 定义在 `pkg/constants/redis.go`,分布式锁必须用 `defer` 释放
- 参考实现:`internal/service/order/service.go`
### 异步任务载荷规范Asynq
**必须遵守:**
- ✅ 调用 `queueClient.EnqueueTask()` 时,`payload` 必须传入 **struct 或 map**
-**禁止传入 `[]byte`**`EnqueueTask` 内部统一调用 `sonic.Marshal`,若传入 `[]byte` 会被 base64 编码成字符串Handler 反序列化时类型不匹配直接崩溃
```go
// ✅ 正确:传 struct
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, MyPayload{OrderID: id})
// ❌ 错误:预先序列化后传 []byte二次 Marshal → base64 编码)
payloadBytes, _ := sonic.Marshal(MyPayload{OrderID: id})
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
```
直接用 `asynq.NewTask` 入队时(绕过 `EnqueueTask`)才需要自己 Marshal。
---
### 审计日志规范
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
- 必填字段:`OperatorID``OperationType``OperationDesc``BeforeData``AfterData`
- 异步写入Goroutine写入失败不影响业务失败时记录 Error 日志
**示例参考**`internal/service/account/service.go`
---
### ⚠️ 任务执行规范(必须遵守)
**提案中的 tasks.md 是契约,不可擅自变更:**
| 规则 | 说明 |
|------|------|
| ❌ 禁止跳过任务 | 每个任务都是经过规划的,不能因为"简单"或"显而易见"而跳过 |
| ❌ 禁止简化任务 | 不能将多个任务合并或简化执行,除非获得明确许可 |
| ❌ 禁止自作主张优化 | 发现可以优化的地方,必须先询问是否可以调整 |
| ✅ 必须逐项完成 | 按照 tasks.md 中的顺序逐一执行并标记完成 |
| ✅ 必须询问后变更 | 如需调整任务(简化/跳过/合并/优化),先询问用户确认 |
**询问示例**
> "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?"
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
---
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
通用项目规范、触碰式 DDD 迁移规则和审批流约束统一维护在 `AGENTS.md`,本文件只保留 Claude 专用补充,禁止复制两份规则正文。
## Agent skills
@@ -351,4 +16,4 @@ Issue 和 PRD 以本地 Markdown 文件形式存放在 `.scratch/<feature-slug>/
### Domain docs
单 Context 布局——根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`
单 Context 布局根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`

View File

@@ -230,6 +230,7 @@ default:
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
- **批量同步**:卡状态、实名状态、流量使用情况
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)

View File

@@ -1,91 +1,254 @@
# 7月迭代技术方案总览
> 状态:待评审
> 负责人:待指定
> 评审人:后端、前端、产品、验收负责人、运维待指定
> 讨论日期2026-07-11
> 最后更新2026-07-14
> 分支Iteration/7-11
> 系统junhong_cmp_fiber已上线迭代阶段
> 系统junhong_cmp_fiber已上线渐进迭代)
---
## 阶段划分
## 一、评审结论
当前方案的技术方向没有跑偏:渐进式 DDD、通用审批流、Outbox、异步任务复用和查询侧独立都符合当前系统约束。
此前版本缺少流程图、前端方案、发布方案和若干关键业务决策。本轮已经补齐这些评审材料,并确认以下口径:
- 平台员工不建立信用额度;信用额度只属于代理主钱包。
- 批量订购的支付方式按整批统一Excel 不携带支付方式。
- 角色级导出字段配置属于本期必做能力。
- Gateway 限速只面向卡号,设备场景先解析当前卡再调用 `cardNo`;本期仅人工设置/取消,不做套餐自动限速。
- 需求 16“代理分销码与佣金提现”已移出 7 月迭代,后续独立立项。
- 审批结论与退款、充值等业务处理结果分别建模和展示。
- 审批详情必须展示业务快照、业务资料、历史意见和审批附件;业务资料与审批附件分开存储和展示。
- 非代理钱包退款由财务人工处理;代理钱包退款仅回退原扣款代理主钱包。
- 本次采用停机发布,不保留旧审批接口兼容窗口。
本目录按 [技术方案评审规范](../技术方案评审规范.md) 整理。当前范围为 21 项实施需求,需求 16 仅保留移出记录。评审建议按“基础设施 → 资金与审批 → 批量任务 → 展示类需求”分组。
---
## 二、评审材料导航
| 材料 | 作用 |
|------|------|
| [DDD 规范](./DDD规范.md) | 判断复杂写、简单写和 Query 通道,以及旧模块如何渐进迁移 |
| [前端技术方案](./前端技术方案.md) | 跨需求的页面、状态、轮询、权限和停机发布约定 |
| [审批流](./基础设施/审批流.md) | 流程定义、实例、任务、状态机、并发、事件和业务接入 |
| [站内消息](./基础设施/站内消息.md) | 审批和临期提醒的站内通知基础设施 |
| [系统配置](./基础设施/系统配置.md) | C 端支付方式的受控动态配置 |
| 各需求专项文档 | 业务规则、接口、数据改动和专项前端差异 |
---
## 三、系统上下文
```mermaid
flowchart LR
Admin[后台管理端] --> API[Junhong API]
Client[C端 H5/公众号] --> API
API --> PG[(PostgreSQL)]
API --> Redis[(Redis)]
API --> Gateway[Gateway]
API --> Outbox[(Outbox)]
Relay[Outbox Relay] --> Outbox
Relay --> Asynq[Asynq]
Asynq --> Worker[Worker]
Worker --> Notification[站内消息]
Worker --> Business[退款/充值/临期等业务处理器]
Worker -. Phase 2 .-> WeCom[企业微信]
```
关键边界:
- API 负责同步校验、事务提交和返回可追踪的业务状态。
- Worker 负责通知、导出、批量任务和审批后的业务处理。
- Gateway 是本迭代唯一已确认可以直接接入的外部业务系统。
- 企业微信相关能力属于 Phase 2不得阻塞 Phase 1 的站内审批闭环。
---
## 四、依赖关系
```mermaid
flowchart TD
DDD[渐进式 DDD 规范]
Config[系统配置]
Notice[站内消息]
Approval[通用审批流]
Export[统一导出任务]
DDD --> Approval
Notice --> Approval
Approval --> R18[需求18 多人审批]
Approval --> R20[需求20 退款审批]
Approval --> R21[需求21 充值审批]
Config --> R09[需求09 支付限制]
Notice --> R22[需求22 临期提醒]
Export --> R14[需求14 导出]
Approval --> R14
```
---
## 五、阶段划分
| 阶段 | 范围 | 说明 |
|------|------|------|
| **Phase 1(本迭代)** | 需求 1-22除企微对接 | Gateway 接口已有,全部可做 |
| **Phase 2(下次迭代)** | 需求 18APR-009/22EXP-003)的企微对接部分 | 仅依赖企业微信审批/推送 API |
| Phase 1 | 需求 1-22 中除需求16外的本系统和 Gateway 能力 | 必须形成站内闭环、人工可追踪和可回滚的后台流程 |
| Phase 2 | 需求 18 APR-009、需求 22 EXP-003 | 企业微信审批/推送与状态同步,单独评审外部 API、安全和补偿策略 |
Phase 1 不建设 BPMN 全规范、拖拽流程设计器、任意回退、子流程和复杂规则引擎。
---
## 需求总览
## 六、需求总览
### 简单改动(无新表,影响范围小)
### 6.1 简单改动
| # | 需求 | 文档 | 实现要点 |
|---|------|------|---------|
| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | `ManualStartCard` 换用 `isRealnameOK()` |
| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-店铺搜索电话.md) | Shop Store 加 contact_phone 过滤 |
| 4 | 退款中资产禁止换货 | [需求04](./需求04-退款拦截换货.md) | 换货前检查活跃退款 |
| 7 | IoT卡/设备加实名筛选 | [需求07](./需求07-实名筛选.md) | 列表接口加 real_name_status filter |
| 11 | 资产详情-套餐到期时间高亮 | [需求11](./需求11-套餐到期字段高亮.md) | 接口新增字段 + 前端高亮 |
| 12 | 换货管理显示问题修复 | [需求12](./需求12-换货管理修复.md) | EXC-001~004字段显示对齐 |
| 13 | 列表字段新增 | [需求13](./需求13-列表字段新增.md) | 退款/代理充值/换号管理加提交人审批人 |
|---|------|------|----------|
| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | 复机规则修正,无新状态 |
| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-07-11-12-13-简单改动.md#需求03店铺列表搜索新增联系电话) | Query 增加过滤条件 |
| 4 | 退款中资产禁止换货 | [需求04](./需求04-06-退款拦截与最后到期时间.md#需求04退款中禁止换货) | 创建换货前校验活跃退款 |
| 7 | IoT卡/设备加实名筛选 | [需求07](./需求03-07-11-12-13-简单改动.md#需求07iot卡设备管理新增已实名未实名筛选) | 列表筛选和索引评估 |
| 11 | 当前套餐到期高亮 | [需求11](./需求03-07-11-12-13-简单改动.md#需求11资产详情-套餐到期时间字段--15天高亮) | 复用后端日期字段,前端统一颜色规则 |
| 12 | 换货显示修复 | [需求12](./需求03-07-11-12-13-简单改动.md#需求12换货管理显示修复) | 字段和检索口径对齐 |
| 13 | 列表字段新增 | [需求13](./需求03-07-11-12-13-简单改动.md#需求13列表字段新增) | 审批人改为动态摘要 |
### 中等复杂(有新字段或配置机制)
简单改动不要求单独绘制系统图;涉及条件分支时用短流程图或规则表即可。
| # | 需求 | 文档 | 实现要点 |
|---|------|------|---------|
| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 依赖[系统配置](./基础设施/系统配置.md) |
| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | ShopPackageAllocation 加 expiry_base_override |
| 6 | 资产最后到期时间 | [需求06](./需求06-资产最后到期时间.md) | 查 PackageUsage 计算 max(expires_at) |
| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 参考现有 Excel 导入体系 |
| 9 | C端支付方式限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 恢复已注释代码 + 系统配置 |
| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | Gateway `SetSpeedLimit` 已有,套餐加限速字段 |
| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 6 个模块导出,复用 ExportTask 体系 |
| 15 | 下架套餐允许续费 | [需求15](./需求15-套餐下架续费.md) | ShelfStatus 判断 + 续费入口 |
### 6.2 标准方案
### 复杂新模块DDD 结构)
| # | 需求 | 文档 | 关键评审点 |
|---|------|------|------------|
| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 资产视角、配置优先级、C端跳转 |
| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | 覆盖值到使用记录的快照链 |
| 6 | 资产最后到期时间 | [需求06](./需求04-06-退款拦截与最后到期时间.md#需求06资产详情-所有套餐的最后到期时间) | Query 聚合与排队套餐口径 |
| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 异步任务、部分成功、失败明细 |
| 9 | C端支付限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 前端隐藏与后端强校验一致 |
| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | 手动卡限速、`kbps` 单位和 Gateway 审计 |
| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 复用 ExportTask、动态字段权限 |
| 15 | 下架套餐允许续费 | [需求15](./需求15-16-18-19-20-21-复杂需求.md#需求15套餐下架后允许续费) | 续费身份和历史使用判断 |
| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 15/7/3 天规则、去重、三端展示 |
| # | 需求 | 文档 | 实现要点 |
|---|------|------|---------|
| 16 | 代理分销码与佣金提现 | [需求16](./需求16-代理分销码佣金提现.md) | 二维码注册 + 发展人体系 |
| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | Shop/角色信用字段 + 负余额支持 |
| 18 | 多人审批 | [需求18](./需求18-多人审批.md) | 依赖[审批流](./基础设施/审批流.md) + [站内消息](./基础设施/站内消息.md) |
| 19 | 批量订购套餐 | [需求19](./需求19-批量订购.md) | Excel 导入 + 代理余额扣款 |
| 20 | 退款审批 | [需求20](./需求20-退款审批.md) | 依赖审批流 |
| 21 | 充值审核流程 | [需求21](./需求21-充值审核流程.md) | 依赖审批流 |
| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 定时任务 + 站内消息 |
### 6.3 完整方案
| # | 需求 | 文档 | 关键评审点 |
|---|------|------|------------|
| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | 钱包不变量、主体模型、角色权限 |
| 18 | 多人审批 | [需求18](./需求15-16-18-19-20-21-复杂需求.md#需求18多人审批apr-001009) | 动态审批人、串行节点、通知 |
| 19 | 批量订购套餐 | [需求19](./需求15-16-18-19-20-21-复杂需求.md#需求19批量订购套餐bpo-001008) | 逐行审计、钱包幂等、部分成功 |
| 20 | 退款审批 | [需求20](./需求15-16-18-19-20-21-复杂需求.md#需求20退款审批) | 审批与实际退款的最终一致性 |
| 21 | 充值审批 | [需求21](./需求15-16-18-19-20-21-复杂需求.md#需求21充值审核流程) | 审批与钱包入账分离、停机切换旧接口 |
### 6.4 已移出本期
| # | 需求 | 状态 | 后续处理 |
|---|------|------|----------|
| 16 | 代理分销码与佣金提现 | 2026-07-14 移出 7 月迭代 | 独立立项,重新评审范围、技术方案和工时 |
---
## 基础设施文档
## 七、跨需求技术决策
| 文档 | 说明 | 被哪些需求依赖 |
|------|------|--------------|
| [DDD规范](./DDD规范.md) | 领域模型设计规范、目录结构、代码示例 | 复杂新模块全部 |
| [系统配置](./基础设施/系统配置.md) | tb_system_config + 管理接口 | 需求 2、9 |
| [站内消息](./基础设施/站内消息.md) | tb_notification + Asynq 异步分发 | 需求 18、20、21、22 |
| [审批流](./基础设施/审批流.md) | 通用多级审批引擎 | 需求 18、20、21 |
- 架构迁移按完整用例触碰式进行,禁止一次性重构旧模块。
- 管理端 API 使用 `/api/admin/*`C 端 API 使用 `/api/c/v1/*`
- 复杂查询进入 Query 模块,不通过聚合根拼装报表或导出。
- 审批人只支持角色或指定账号,当前系统不引入部门模型。
- 审批完成事件使用 Outbox + Asynq通知和业务处理均按至少一次投递设计。
- 导入、批量购买和导出必须有服务端任务状态,前端轮询只是展示手段。
- 资金、审批、导出和敏感配置必须记录操作人和审计信息。
- 信用额度只属于代理主钱包;平台员工是操作主体,不是结算和负债主体。
- 批量订购支付方式整批统一;线下支付按整批上传凭证,代理钱包按整批选择代理钱包。
- 角色管理必须提供导出字段配置,导出请求字段与角色授权字段取交集。
- 限速最终目标始终是卡Gateway 请求只传 `cardNo`;设备入口必须解析当前绑定卡,本期仅手动设置或取消。
- 退款和员工线下充值统一通过任务级审批接口处理。
- 审批状态与实际退款、钱包入账等处理状态分别返回;审批详情返回业务快照、业务资料和审批时间线。
- 本次停机发布并同时切换 API、Worker 和前端,不建设旧审批接口兼容门面。
---
## 执行顺序建议
## 八、已确认技术决策
| 编号 | 已确认结论 | 影响需求 |
|------|------------|----------|
| D-01 | 不实现平台员工信用额度,只保留代理主钱包授信 | 17、19 |
| D-02 | 角色级导出字段配置本期落地,服务端执行字段授权 | 14 |
| D-03 | 批量订购支付方式整批统一,不允许 Excel 行级混合支付 | 19 |
| D-04 | Gateway 限速统一按 `cardNo`;设备套餐取 `tb_device_sim_binding.is_current=true` 对应卡 | 10 |
| D-05 | 审批结论和实际业务处理状态分离 | 20、21 |
| D-06 | 采用停机发布,旧退款审批和线下充值直接入账/驳回接口同步下线 | 20、21 |
| D-07 | 需求16移出本期不创建相关表、接口、流程和前端页面 | 16 |
| D-08 | 第三方退款本期由财务人工完成;代理钱包仅回退原扣款代理主钱包,客户资产钱包不走自动回退 | 20 |
| D-09 | 设备批量分配代理与套餐系列拆为两个命令和两个前端入口 | 8 |
| D-10 | 当前套餐与排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只做通知 | 06、22 |
前端源码不在当前仓库。接口评审后仍需在前端仓库完成路由、权限指令、上传组件、任务轮询组件和动态表格能力的实现映射,但这不再是业务方案决策项。
---
## 九、发布与回滚总策略
```mermaid
flowchart LR
S[进入维护模式并停止写入] --> M[执行数据库迁移]
M --> D[同时发布 API、Worker 和前端]
D --> C[初始化流程定义和业务绑定]
C --> L[回填存量待审批单]
L --> V[人工验证核心链路]
V --> O[开放系统访问]
```
第一周:基础设施
1. tb_system_config + 管理接口
2. tb_notification + 任务 handler
3. 通用审批流引擎
第二周:简单改动批量完成
4. 需求 1/3/4/7/11/12/13均可并行
发布原则:
第三周:中等复杂
5. 需求 2/5/6/8/9依赖基础设施
6. 需求 14/15
1. 发布前进入维护模式,停止创建订单、退款、充值和审批操作。
2. 维护窗口内执行数据库迁移,并同时发布 API、Worker/Relay 和前端静态资源。
3. 初始化并发布 `refund_approval``recharge_approval`,完成业务绑定后再验证。
4. 使用一次性 Application 命令为存量待审批退款和员工线下充值创建流程实例;历史终态记录保持只读,不伪造审批时间线。
5. 旧退款业务单审批接口和线下充值 `offline-pay/reject` 在新版本中不再注册,不保留兼容门面。
6. 人工验证发起审批、存量审批、任务处理、业务回调、通知和查询后再开放访问。
7. 开放前验证失败可回滚应用版本和可逆迁移已经形成的审批历史、资金流水、Outbox 和审计日志不得通过清表回滚。
8. Worker 暂停时保留 Outbox 事件,恢复后继续消费。
第四周:复杂新模块
7. 需求 17信用额度
8. 需求 16分销码
9. 需求 18/20/21审批流对接
10. 需求 19批量订购
11. 需求 22临期提醒
---
## 十、可观测性与人工验收
关键日志和查询维度:
- `request_id``event_id``task_id``process_instance_id``biz_type``biz_id`
- Outbox 待投递数量、失败次数和最早积压时间。
- 审批业务处理失败、批量任务失败和导出任务失败。
- 钱包扣款前后余额、版本冲突和业务幂等键。
人工验收至少覆盖:
- 正常路径、无权限、重复点击、并发审批、退回重提。
- Worker 暂停后恢复,事件和任务可继续处理。
- 维护模式下不能产生新写入;开放前确认 API、Worker 和前端版本一致。
- 所有仍需审批的存量退款和员工线下充值均已生成流程实例,历史终态记录只读可查。
- 旧退款审批和线下充值 `offline-pay/reject` 路由不可访问。
- PostgreSQL 中业务状态、审批状态、任务状态、操作日志和资金流水一致。
---
## 十一、执行顺序建议
```text
评审批次 ADDD 边界、系统配置、站内消息、审批流
评审批次 B退款、充值、信用额度、批量订购、导出权限
评审批次 CH5 流程、限速、临期提醒、批量分配
评审批次 D简单字段、筛选和显示修复
实施顺序:
1. 增量迁移、TxManager/Outbox、系统配置和站内消息
2. 审批定义、实例、任务和前端待办
3. 退款和充值接入通用审批
4. 批量任务、代理信用额度和导出权限
5. 其他中小需求
```
每个评审批次独立形成结论,未通过的复杂需求不阻塞已经闭环的简单需求实施。

View File

@@ -1,5 +1,6 @@
# 项目 DDD 设计规范
> 状态:待评审
> 务实型 DDD——不是学院派不强制 Event Sourcing不追求完美追求可维护、可扩展、AI 辅助友好。
> 基准文档:`docs/改造方向.md`(绞杀者模式)
@@ -17,17 +18,71 @@
不是重写,是**渐进替换**
1. **新功能按新结构写**,不往旧 Service 加代码
2. **旧功能迁移**每次迁移一个用例,跑通后再下一个
3. **AI 辅助**结构清晰AI 生成代码时自动落入正确位置
1. **复杂写功能**:进入 Application + Domain不继续堆入旧 Service
2. **简单写功能**使用 Application 事务脚本,不强行创建聚合
3. **读取功能**进入 Query 读取模型,不通过聚合根
4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写
5. **AI 辅助**结构清晰AI 生成代码时自动落入正确位置
---
## 二、目录结构
## 二、什么时候进入 DDD
DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。
### 2.1 优先使用 DDD 的场景
当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块:
1. 有明确生命周期、状态机、状态转换限制或并发不变量。
2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。
3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。
4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。
5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。
审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。
### 2.2 通常不迁移的场景
以下需求通常保持原有 `Handler → Service → Store → Model`
1. 单表 CRUD、字段增删、简单列表筛选和格式转换。
2. 局部缺陷修复,且不改变业务边界或核心规则。
3. 没有独立领域语言、生命周期或跨模块不变量。
4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。
### 2.3 触碰式迁移
- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。
- 迁移单位是**完整用例**,不是整个模块、文件或数据表。
- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。
- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。
- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。
- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。
- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。
### 2.4 查询侧规则
复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型:
```text
Handler → Query → GORM/DTO
```
- 列表、详情、联表、统计、报表和导出属于 Query。
- Query 可以直接使用 GORM、CTE、子查询和批量查询并负责读取权限与分页。
- Query 返回专用 DTO/Projection禁止返回后用于业务写入。
- 只有查询结果参与写操作判定时Domain 必须重新校验,不能信任读取快照。
- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/<context>`
- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。
---
## 三、目录结构
```
internal/
├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM
├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM
│ ├── wallet/
│ │ ├── wallet.go ← 聚合根(含业务方法)
│ │ ├── events.go ← 领域事件定义
@@ -35,7 +90,7 @@ internal/
│ │ └── vo.go ← 值对象Money, CreditLimit
│ ├── approval/ ← 新:审批流领域
│ ├── notification/ ← 新:通知领域
│ ├── distribution/ ← 新:分销领域
│ ├── distribution/ ← 后续预留需求16独立立项后再创建
│ └── package/ ← 套餐领域(迁移中)
├── application/ ← 应用层(用例,只做编排,不含业务判断)
@@ -50,8 +105,17 @@ internal/
│ └── notification/
│ └── send_notification.go
├── query/ ← 读取侧(可直接使用 GORM返回 DTO/Projection
│ ├── order/
│ ├── refund/
│ ├── approval/
│ ├── dashboard/
│ └── report/
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface
── persistence/ ← 原来的 store/postgres/(保持不动)
── persistence/ ← GORM Repository可委托现有 Store
│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费
│ └── adapter/ ← 外部系统和旧模块防腐层
├── handler/ ← 原有 handler保持不动
├── service/ ← 原有 service保持不动新模块不在这里
@@ -60,12 +124,15 @@ internal/
**过渡期规则**
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
- 新模块`internal/domain/xxx` + `internal/application/xxx`
- Handler 层统一调 Application 层(新)或 Service 层(旧)
- 复杂写用例`internal/domain/xxx` + `internal/application/xxx`
- 简单写用例:`internal/application/xxx`
- 读取用例:`internal/query/xxx`
- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model但不得让 Fiber、Redis 或 GORM 查询进入业务方法
---
## 、领域模块划分
## 、领域模块划分
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|------|--------|------------|------|
@@ -74,15 +141,15 @@ internal/
| **Package套餐** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
| **Wallet钱包** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
| **Shop代理** | `Shop` | 层级关系、信用策略 | 部分迁移 |
| **Approval审批** | `ApprovalFlow` | 多级审批、推进、驳回 | **全新 DDD** |
| **Approval审批** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** |
| **Notification通知** | `Notification` | 分发、已读状态 | **全新 DDD** |
| **Distribution分销** | `DistributionRelation` | 发展人体系、二维码注册 | **全新 DDD** |
| **Distribution分销** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 |
---
## 、聚合根设计原则(富模型)
## 、聚合根设计原则(富模型)
### 4.1 核心原则
### 5.1 核心原则
业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。
@@ -113,24 +180,22 @@ func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) err
wallet, err := uc.repo.GetByID(ctx, cmd.WalletID)
if err != nil { return err }
if err := wallet.Debit(cmd.Amount); err != nil { return err }
// 完整事务和 Outbox 写入方式见“应用服务(用例)”章节
return uc.repo.Save(ctx, wallet)
}
```
### 4.2 聚合根必须包含
### 5.2 聚合根必须包含
```go
type AggregateRoot struct {
// 内嵌 GORM model过渡期
gorm.Model
// 业务字段...
// 未发布领域事件内存中Save 后由框架分发)
domainEvents []DomainEvent `gorm:"-" json:"-"`
// 未发布领域事件,由 Application 在事务内写入 Outbox
domainEvents []DomainEvent
}
// 获取并清空领域事件(由 Repository.Save 调用)
// PopEvents 获取并清空领域事件
func (a *AggregateRoot) PopEvents() []DomainEvent {
events := a.domainEvents
a.domainEvents = nil
@@ -142,17 +207,17 @@ func (a *AggregateRoot) recordEvent(e DomainEvent) {
}
```
### 4.3 与现有 GORM 的共存
### 5.3 与现有 GORM 的共存
过渡期,聚合根 **就是** 现有 GORM Model(加业务方法),不需要单独建领域对象
迁移旧模块时,聚合根可以临时包装现有 GORM Model,减少一次性重构成本
```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
model.AgentWallet // 内嵌原有 GORM 模型
domainEvents []DomainEvent `gorm:"-"`
model.AgentWallet // 迁移期复用持久化字段
domainEvents []DomainEvent
}
// Debit 从钱包扣款(含信用额度)
@@ -167,9 +232,11 @@ func (w *AgentWallet) Debit(amount Money) error {
}
```
这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。
---
## 、值对象设计
## 、值对象设计
值对象:没有 ID靠值来判断相等不可变。
@@ -201,9 +268,9 @@ type CreditLimit struct {
---
## 、领域事件(用 Asynq 实现)
## 、领域事件与可靠投递
领域事件不用消息队列,直接复用现有 Asynq 体系
领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox再由 Relay 异步投递到 Asynq
```go
// internal/domain/wallet/events.go
@@ -230,29 +297,45 @@ func (e WalletDebitedEvent) EventType() string { return "wallet.debited" }
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
```
**Repository.Save 发布事件**
**事务边界**
```text
数据库事务
├── 保存聚合
├── 保存操作日志
└── 保存 Outbox 事件
提交事务
Outbox Relay → Asynq → 事件处理器
```
**Application UseCase 保存事件**
```go
// internal/infrastructure/persistence/wallet_repo.go
func (r *WalletRepository) Save(ctx context.Context, wallet *domainWallet.AgentWallet) error {
err := r.db.WithContext(ctx).Save(&wallet.AgentWallet).Error
if err != nil {
return err
}
// 发布领域事件
for _, evt := range wallet.PopEvents() {
if err := r.publishEvent(ctx, evt); err != nil {
r.logger.Error("领域事件发布失败", zap.Error(err), zap.String("type", evt.EventType()))
// 事件发布失败不回滚业务(异步,可补偿)
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
}
return nil
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
- Asynq 暂时不可用不回滚已提交业务Relay 后续重试。
- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。
---
## 、仓储接口
## 、仓储接口
```go
// internal/domain/wallet/repository.go
@@ -262,17 +345,20 @@ type WalletRepository interface {
GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error)
GetByID(ctx context.Context, id uint) (*AgentWallet, error)
Save(ctx context.Context, wallet *AgentWallet) error
SaveInTx(ctx context.Context, tx *gorm.DB, wallet *AgentWallet) error
}
```
实现在 `internal/infrastructure/persistence/wallet_repo.go`,复用现有 Store 逻辑。
实现在 `internal/infrastructure/persistence/wallet_repo.go`可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`
---
## 、应用服务(用例)
## 、应用服务(用例)
一个文件 = 一个用例。用例只做编排,不含业务判断。
一个文件 = 一个用例。
- 复杂写用例Application 只做事务和跨聚合编排,业务不变量位于 Domain。
- 简单写用例Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。
- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。
```go
// internal/application/wallet/debit_wallet.go
@@ -289,29 +375,36 @@ type DebitCommand struct {
// DebitWalletUseCase 扣款用例
type DebitWalletUseCase struct {
walletRepo domainWallet.WalletRepository
outboxRepo OutboxRepository
txManager TxManager
}
// Execute 执行扣款
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
wallet, err := uc.walletRepo.GetByID(ctx, cmd.WalletID)
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
return uc.walletRepo.SaveInTx(ctx, tx, wallet)
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
---
## 、Handler 层调用方式
## 、Handler 层调用方式
新模块Handler → Application UseCase(不经过 Service 层)
- 复杂写、简单写Handler → Application UseCase
- 读取Handler → Query。
- 尚未迁移的旧用例Handler → Service。
- Handler 不直接访问 GORM也不承载业务规则或复杂 DTO 拼装。
```go
// internal/handler/admin/wallet_handler.go
@@ -333,25 +426,31 @@ func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
---
## 十、禁止事项
## 十、禁止事项
| 禁止 | 原因 |
|------|------|
| 在 Application 层做业务判断 | 业务规则必须在领域对象里 |
| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 |
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 |
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
| 领域事件发布失败时回滚业务 | 事件是异步补偿,不是事务一部分 |
| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox |
| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 |
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application |
| 使用 Query 快照替代写侧校验 | 查询结果可能已过期Domain 必须重新验证不变量 |
| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query |
| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 |
---
## 十、本次迭代 DDD 落地计划
## 十、本次迭代 DDD 落地计划
| 新模块 | 领域路径 | 用例路径 |
|--------|---------|---------|
| 信用额度需求17 | `internal/domain/wallet/` | `internal/application/wallet/` |
| 审批流需求18/20/21 | `internal/domain/approval/` | `internal/application/approval/` |
| 审批流需求18/20/21 | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox |
| 站内消息需求18/22 | `internal/domain/notification/` | `internal/application/notification/` |
| 分销码需求16 | `internal/domain/distribution/` | `internal/application/distribution/` |
| 批量订购需求19 | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。

View File

@@ -1,3 +1,5 @@
> 本文保留原始业务需求措辞。涉及架构、字段和接口的最终技术口径,以同目录的专项技术方案和 `基础设施/` 文档为准。
其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段
@@ -10,8 +12,13 @@
6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间
7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件
8.设备批量分配代理和套餐系列:因设备号不是连号故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为设备号
> 评审结论:拆成“批量分配代理”和“批量分配套餐系列”两个独立命令、两个前端入口和两个任务类型;可复用 Excel 解析与任务基础设施,但单个任务不得同时修改两个字段。
9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝
10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供
> 技术口径说明Gateway 只支持按 `cardNo` 限速,不存在设备级限速。单卡直接使用 ICCID设备场景先查 `tb_device_sim_binding.is_current=true` 的当前卡,再使用该卡 ICCID。取消限速仍重复调用同一个限速接口只是发送取消参数。本期仅提供后台手动设置/取消,内部单位为 `kbps`;不做套餐字段和自动限速规则。
11. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。
12. 换货管理:
| 编号 | 需求 |
@@ -27,9 +34,9 @@
| COL-002 | 代理充值列表 | 提交人、审批人 |
| COL-003 | 换号管理列表 | 提交人 |
14. 导出功能:
#### 6.8.1 lot 卡导出
## 6.8.1 lot 卡导出
##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
| 编号 | 需求 |
| -------- | ---------------------------- |
@@ -37,7 +44,7 @@
| EXPD-002 | lot 卡导出字段新增使用流量。 |
| EXPD-003 | lot 卡导出字段新增剩余流量。 |
#### 6.8.2 代理资金概况-预充值钱包流水导出
## 6.8.2 代理资金概况-预充值钱包流水导出
导出字段:
@@ -58,7 +65,7 @@
| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 |
| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 |
#### 6.8.3 套餐列表导出
## 6.8.3 套餐列表导出
导出字段:
@@ -90,7 +97,7 @@
| 更新时间 |
| 删除时间 |
#### 6.8.4 退款管理退款列表导出
## 6.8.4 退款管理退款列表导出
导出字段:
@@ -119,9 +126,9 @@
| 财务审批人 |
| 退款凭证 |
#### 6.8.5 换货管理导出
## 6.8.5 换货管理导出
##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
导出字段:
@@ -143,7 +150,7 @@
| 创建人 |
| 创建时间 |
#### 6.8.6 代理充值导出
## 6.8.6 代理充值导出
导出字段:
@@ -176,7 +183,7 @@
这个好像没有
##### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款?
### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款?
| 部门领导审批人 |
@@ -184,22 +191,27 @@
这两个好像也没有
##### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增
### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增
| 支付通道 |
这是啥玩意
##### 去掉
### 去掉
"导出功能可以根据不同权限显示的字段不同且可以自己选择需要导出的字段。像现在这样全量导出,大家都可以看到虚流量等不想让所有人都看到的字段。" 什么叫不同权限,这个不同权限显示字段不同是固定的吗,平台就是固定的,代理就是固定的等等,如果是这样的话需要标记对应的导出字段的权限划分
##### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置
### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置
> 评审结论:角色级导出字段配置属于本期范围。后端返回当前角色允许导出的字段,最终导出字段取“角色授权字段”与“用户本次选择字段”的交集,前端不能绕过服务端授权。
15. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。
16. 代理分销码与佣金提现
##### 员工可作为代理发展人进行标识。(在系统中如何体现?)
> 迭代范围变更2026-07-14需求 16 整体移出 7 月迭代,后续独立立项和评审。以下内容仅保留原始需求记录,本期不开发、不迁移、不发布。
### 员工可作为代理发展人进行标识。(在系统中如何体现?)
| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
@@ -210,6 +222,9 @@
| DST-005 | 佣金提现需上传法人身份证。(必填) |
| DST-006 | 佣金提现可选上传门头照。(可选) |
| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) |
> 当前结论:原技术方案不再属于 7 月迭代范围。后续重新立项时再评审分销关系、代理申请审批、开店幂等和提现材料。
17. 不同渠道信用额度,钱包支持信用额度支付
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
@@ -217,6 +232,9 @@
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |
> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额;角色仅控制谁可以查看或调整代理额度。
18. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
@@ -229,6 +247,11 @@
| APR-007 | 审核通过后通知申请人。 |
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
| APR-009 | 审核流程需对接企业微信审批流程。 |
> 技术口径说明:当前系统没有部门组织模型。“部门领导审核、财务审核”作为默认流程节点名称处理,实际审批人由流程定义配置的角色或指定账号产生,不根据提交人部门自动推导,也不在代码中固定角色名称。每次通过、驳回、退回都形成不可修改的审批意见记录,并可附带最多 5 个审批附件;驳回和退回意见必填。
> 审批详情必须展示业务单号、提交人、审批关键字段、业务资料、此前审批人的意见和审批附件。业务资料与审批附件分开存储和展示;流程启动时固化业务快照,实时业务页仍按原业务数据范围校验。
19.批量订购套餐
内部员工进入批量订购页面。
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
@@ -277,6 +300,8 @@ excel表导入字段修改为以下
如果用批量订购应当上传对应凭证
> 评审结论支付方式按整批统一。页面选择“线下支付”或“代理钱包支付”Excel 不再包含支付方式列;线下支付按整批上传凭证,混合支付必须拆成不同批次。
20.退款审批
(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
1. 员工提交退款申请。
@@ -284,6 +309,8 @@ excel表导入字段修改为以下
3. 按部门领导、财务顺序审批。
4. 当前环节审批完成后,下一环节审批人收到消息提示。
5. 审批通过或驳回后通知申请人。
> 评审结论:微信、支付宝和线下退款由财务在系统外人工完成后确认;本期不接入第三方自动退款。代理钱包支付的退款仅自动回退原扣款代理主钱包,客户资产钱包不在本期自动回退范围。
21. 充值审核流程
代理自己充值:
1. 代理在系统提交充值申请。
@@ -296,6 +323,9 @@ excel表导入字段修改为以下
3. 财务在上一审批人通过后收到待办提醒并审批。
4. 审批通过后通知申请人。
5. 审批驳回后通知申请人,并展示驳回原因。
> 技术口径说明:审批通过只表示审批结论成立,不表示退款已经到账或充值已经入账。退款、充值分别维护业务处理状态并支持异步重试。此次采用停机发布,旧退款业务单审批接口和线下充值 `offline-pay/reject` 不保留兼容窗口。
22. 套餐临期提醒
1. 系统每日计算卡/设备套餐剩余有效期。
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
@@ -303,7 +333,7 @@ excel表导入字段修改为以下
4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒并显示立即续费按钮。
6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。
#### 6.1.1 企业客户临期提醒
## 6.1.1 企业客户临期提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-001 | 后台每日生成企业客户临期列表。 |
@@ -311,7 +341,7 @@ excel表导入字段修改为以下
| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 |
| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 |
#### 6.1.2 代理端临期提醒
## 6.1.2 代理端临期提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
@@ -320,7 +350,7 @@ excel表导入字段修改为以下
| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 |
| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 |
#### 6.1.3 C 端公众号首页提醒
## 6.1.3 C 端公众号首页提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
# 基础设施站内消息Notification
> 状态:待评审
> 被依赖:需求 18/20/21审批流通知、需求 22临期提醒
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
@@ -7,9 +8,28 @@
## 一、设计原则
- **业务代码不直接写通知**只发 Asynq 任务,异步处理
- **业务代码不直接写通知**统一通过通知发布器或领域事件异步处理
- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq禁止事务提交后直接入队
- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束Asynq 重试不得重复生成站内消息
- **不过度抽象**:不用 interface用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
- **前端轮询**30秒一次 `/admin/notifications/unread-count`,不用 WebSocket
- **前端轮询**30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket
```mermaid
sequenceDiagram
participant Biz as 业务事务
participant Outbox as Outbox
participant Relay as Relay
participant Worker as Notification Worker
participant DB as tb_notification
participant Web as 后台前端
Biz->>Outbox: 同事务写领域事件
Relay->>Outbox: 拉取待投递事件
Relay->>Worker: 至少一次投递
Worker->>DB: 按 event_id + recipient 幂等插入
Web->>DB: 经 API 轮询未读数/通知列表
Web->>DB: 经 API 标记已读
```
---
@@ -19,12 +39,13 @@
-- 迁移文件YYYYMMDD_create_tb_notification.sql
CREATE TABLE tb_notification (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID用于幂等
recipient_id BIGINT NOT NULL, -- 用户IDadmin user 或 agent user
recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义)
title VARCHAR(200) NOT NULL, -- 标题
body TEXT NOT NULL DEFAULT '', -- 正文(支持简单 HTML
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | package
body TEXT NOT NULL DEFAULT '', -- 纯文本正文
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device
ref_id BIGINT, -- 关联业务ID
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
@@ -32,8 +53,11 @@ CREATE TABLE tb_notification (
expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用)
);
CREATE INDEX idx_notification_recipient ON tb_notification (recipient_id, recipient_type, is_read);
CREATE INDEX idx_notification_recipient
ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
CREATE UNIQUE INDEX uq_notification_event_recipient
ON tb_notification (event_id, recipient_id, recipient_type);
COMMENT ON TABLE tb_notification IS '站内消息通知表';
```
@@ -72,8 +96,9 @@ const (
// Notification 站内消息模型
type Notification struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
RecipientID uint `gorm:"column:recipient_id;not null;index" json:"recipient_id"`
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin'" json:"recipient_type"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"`
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"`
Type string `gorm:"column:type;type:varchar(50);not null" json:"type"`
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
@@ -92,13 +117,16 @@ func (Notification) TableName() string { return "tb_notification" }
## 五、发布侧NotificationPublisher
业务代码调这个发布通知,内部异步入队:
非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated``ProcessApproved` 等事件处理器转换为通知载荷。
领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。
```go
// internal/service/notification/publisher.go
// internal/infrastructure/messaging/notification_publisher.go
// SendPayload 发送通知的参数
type SendPayload struct {
EventID string // 领域事件ID或调用方请求ID同一次重试必须保持不变
RecipientIDs []uint // 接收人ID列表
RecipientType string // admin | agent
Type string // 通知类型常量
@@ -115,29 +143,37 @@ type NotificationPublisher struct {
logger *zap.Logger
}
// Publish 发布通知(异步,不阻塞业务
// 失败只记录日志,不影响业务事务
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) {
// Publish 发布通知(异步)
// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error {
if payload.EventID == "" {
return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空")
}
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
return err
}
return nil
}
```
业务代码调用示例(审批推进后通知下一审批人):
审批事件处理器调用示例(节点激活后通知候选审批人):
```go
p.notifyPublisher.Publish(ctx, notification.SendPayload{
return h.notifyPublisher.Publish(ctx, notification.SendPayload{
EventID: event.EventID,
RecipientIDs: []uint{nextApproverID},
RecipientType: constants.NotifyRecipientAdmin,
Type: constants.NotifyTypeApprovalPending,
Title: "您有一条待审批记录",
Body: fmt.Sprintf("代理「%s」提交了充值申请请及时审批。", shopName),
RefType: "approval",
RefID: approvalFlowID,
RefID: processInstanceID,
})
```
审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。
---
## 六、消费侧Asynq Task Handler
@@ -164,6 +200,7 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T
ref_id = &payload.RefID
}
records = append(records, model.Notification{
EventID: payload.EventID,
RecipientID: uid,
RecipientType: payload.RecipientType,
Type: payload.Type,
@@ -175,7 +212,9 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T
})
}
if err := h.db.WithContext(ctx).CreateInBatches(records, 100).Error; err != nil {
if err := h.db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
CreateInBatches(records, 100).Error; err != nil {
return fmt.Errorf("批量写入通知失败: %w", err)
}
@@ -193,7 +232,7 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T
### 7.1 未读数量前端轮询用30秒一次
```
GET /admin/notifications/unread-count
GET /api/admin/notifications/unread-count
```
响应:
@@ -204,7 +243,7 @@ GET /admin/notifications/unread-count
### 7.2 通知列表
```
GET /admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
```
请求参数:
@@ -244,13 +283,13 @@ type NotificationListRequest struct {
### 7.3 标记已读
```
PUT /admin/notifications/{id}/read
PUT /api/admin/notifications/{id}/read
```
### 7.4 全部标记已读
```
PUT /admin/notifications/read-all
PUT /api/admin/notifications/read-all
```
可传 `type` 过滤(只把某类型全部已读):
@@ -298,21 +337,22 @@ type ReadAllRequest struct {
### 顶部导航栏铃铛
```
组件挂载 → 轮询 GET /admin/notifications/unread-count30秒一次
组件挂载 → 轮询 GET /api/admin/notifications/unread-count30秒一次
→ count > 0 时铃铛显示红点 + 数字
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
→ 打开时调 GET /admin/notifications?is_read=false
→ 点击某条通知 → PUT /admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
→ 打开时调 GET /api/admin/notifications?is_read=false
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
```
### 跳转逻辑ref_type
| ref_type | 跳转页面 |
|----------|---------|
| `approval` | `/approval/{ref_id}` 审批详情 |
| `recharge` | `/recharge/{ref_id}` 充值单详情 |
| `refund` | `/refund/{ref_id}` 退款单详情 |
| `package` | `/assets/{ref_id}` 资产详情(临期提醒) |
| `approval` | `/approvals/instances/{ref_id}` 审批详情 |
| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 |
| `refund` | `/refunds/{ref_id}` 退款单详情 |
| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情(临期提醒) |
| `device` | `/devices/{ref_id}` 设备详情(临期提醒) |
### 通知列表页(/notifications
@@ -321,6 +361,8 @@ type ReadAllRequest struct {
列表字段:类型、标题、时间、已读状态
点击行:跳转关联业务详情
前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。
---
## 九、Phase 2 扩展预留

View File

@@ -1,6 +1,7 @@
# 基础设施系统配置tb_system_config
> 被依赖:需求 02H5流程配置、需求 09C端支付限制
> 状态:待评审
> 被依赖:需求 09C端支付限制
---
@@ -8,6 +9,25 @@
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
```mermaid
sequenceDiagram
actor Admin as 平台超管
participant Web as 后台配置页
participant API as SystemConfig Application
participant DB as PostgreSQL
participant Redis as Redis
participant Biz as 业务读取方
Admin->>Web: 修改受控配置表单
Web->>API: PUT /api/admin/system/config/{config_key}
API->>API: 按配置 key 注册规则校验类型和值域
API->>DB: 更新值并写审计日志
API->>Redis: 删除对应缓存
API-->>Web: 返回最新配置和更新时间
Biz->>Redis: 下次读取缓存未命中
Biz->>DB: 读取最新配置并回填缓存
```
---
## 二、数据库
@@ -35,14 +55,11 @@ COMMENT ON TABLE tb_system_config IS '系统全局配置表';
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES
-- C端支付限制需求9
('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式alipay/wechat/wallet'),
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式'),
-- H5流程顺序需求2per-asset 覆盖,此处是全局默认)
-- 注意per-asset 的 realname_policy 已在 iot_card.realname_policy 字段存储
-- 此处仅存"H5绑定手机后的默认流程"
('h5.flow.default_realname_policy', 'after_order', 'string', 'h5.flow', 'H5新用户默认实名策略none/before_order/after_order');
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式');
```
需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。
---
## 三、Model
@@ -123,7 +140,7 @@ allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_met
### 6.1 获取配置列表
```
GET /admin/system/config?module=c2b.payment
GET /api/admin/system/config?module=c2b.payment
```
响应:
@@ -148,7 +165,7 @@ GET /admin/system/config?module=c2b.payment
### 6.2 更新配置
```
PUT /admin/system/config/{config_key}
PUT /api/admin/system/config/{config_key}
```
请求体:
@@ -187,6 +204,8 @@ type UpdateSystemConfigRequest struct {
}
```
更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。
---
## 七、前端对接
@@ -196,10 +215,10 @@ type UpdateSystemConfigRequest struct {
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
调用流程:
1. 进入页面 → `GET /admin/system/config`(不传 module = 返回全部)
1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部)
2. 按 module 分组展示,`is_readonly=true` 的配置只读
3. 修改某项 → `PUT /admin/system/config/{config_key}`body: `{config_value}`
4. 修改成功后提示"配置已更新约5分钟后生效"Redis 缓存 TTL
3. 修改某项 → `PUT /api/admin/system/config/{config_key}`body: `{config_value}`
4. 更新成功后后端立即删除该 key 的缓存,前端提示配置已更新”并刷新当前值
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
@@ -214,3 +233,5 @@ type UpdateSystemConfigRequest struct {
```
前端把选中项序列化成 `["alipay","wallet"]` 提交。
通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。

View File

@@ -1,5 +1,7 @@
# 需求01行业卡后台手动复机允许未实名
> 状态:待评审
---
## 背景

View File

@@ -1,5 +1,7 @@
# 需求02H5 流程顺序配置化
> 状态:待评审
---
## 背景
@@ -120,36 +122,36 @@ type UpdateAssetRealnamePolicyRequest struct {
#### 2a. 批量修改卡实名策略
```
POST /admin/iot-cards/batch-update-realname-policy
POST /api/admin/iot-cards/batch-update-realname-policy
```
请求体:
```go
type BatchUpdateIotCardRealnamePolicy struct {
IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1" description:"卡ID列表"`
IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1,max=500,dive,gt=0" description:"卡ID列表最多500条"`
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
Service`UPDATE tb_iot_cards SET realname_policy = ? WHERE id IN (...)`,逐条写审计日志。
Service在一个事务中校验最多 500 条 ID 均存在且均在当前账号数据范围内,再执行 `UPDATE tb_iot_card SET realname_policy = ? WHERE id IN (...)`。任一记录不合法则整批回滚,并写一条包含目标策略和 ID 数量的批量审计日志。
#### 2b. 批量修改设备实名策略
```
POST /admin/devices/batch-update-realname-policy
POST /api/admin/devices/batch-update-realname-policy
```
请求体:
```go
type BatchUpdateDeviceRealnamePolicy struct {
DeviceIDs []uint `json:"device_ids" validate:"required,min=1" description:"设备ID列表"`
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,gt=0" description:"设备ID列表最多500条"`
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
Service`UPDATE tb_devices SET realname_policy = ? WHERE id IN (...)`,逐条写审计日志
Service与卡批量接口相同,单次最多 500 条、事务内全成全败;任一设备不存在或越权则不更新任何记录
### 3. 全局默认值(兜底)
@@ -164,7 +166,7 @@ Service`UPDATE tb_devices SET realname_policy = ? WHERE id IN (...)`,逐条
"操作"列或批量操作下拉增加"设置实名策略"
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /admin/iot-cards/batch-update-realname-policy`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/iot-cards/batch-update-realname-policy`
> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。
@@ -173,7 +175,7 @@ Service`UPDATE tb_devices SET realname_policy = ? WHERE id IN (...)`,逐条
"操作"列或批量操作下拉增加"设置实名策略"
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /admin/devices/batch-update-realname-policy`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/devices/batch-update-realname-policy`
### 字段展示
@@ -205,7 +207,7 @@ H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳
| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` |
| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 |
| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 |
| 批量修改卡接口 | ❌ 待建 | `POST /admin/iot-cards/batch-update-realname-policy` |
| 批量修改设备接口 | ❌ 待建 | `POST /admin/devices/batch-update-realname-policy` |
| 批量修改卡接口 | ❌ 待建 | `POST /api/admin/iot-cards/batch-update-realname-policy` |
| 批量修改设备接口 | ❌ 待建 | `POST /api/admin/devices/batch-update-realname-policy` |
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |

View File

@@ -1,5 +1,7 @@
# 需求03/07/11/12/13简单改动合集
> 状态:待评审
---
## 需求03店铺列表搜索新增联系电话
@@ -56,11 +58,14 @@ if req.RealNameStatus != nil {
#### 迁移
`tb_devices` 新增字段:
`tb_device` 新增字段:
```sql
ALTER TABLE tb_devices ADD COLUMN real_name_status int NOT NULL DEFAULT 0
COMMENT '实名状态快照(0=未实名,1=已实名)任意绑定卡已实名则为1由轮询异步维护';
ALTER TABLE tb_device
ADD COLUMN real_name_status INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_device.real_name_status
IS '实名状态快照(0=未实名,1=已实名)任意绑定卡已实名则为1由轮询异步维护';
```
**Model**`internal/model/device.go`
@@ -255,7 +260,9 @@ if req.OldAssetKeyword != "" {
### 核心原则
提交人、审批人均在**写入时快照**到单据本身,读取时直接返回,不做额外查询
- 提交人账号名在业务单创建时快照到业务表
- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。
- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。
---
@@ -287,35 +294,43 @@ SubmitterName string `json:"submitter_name" description:"提交人账号名"`
### COL-001退款管理列表新增提交人、审批人依赖审批流
**迁移**`tb_refund` 新增字段
**迁移**`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加
```sql
ALTER TABLE tb_refund ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
ALTER TABLE tb_refund ADD COLUMN dept_leader_name varchar(50) NOT NULL DEFAULT '';
ALTER TABLE tb_refund ADD COLUMN finance_approver_name varchar(50) NOT NULL DEFAULT '';
ALTER TABLE tb_refund_request
ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
```
- `submitter_name`:创建退款单时快照操作人 username
- `dept_leader_name` / `finance_approver_name`:审批流各环节通过时快照对应审批人 username
- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取
> **实施依赖**`dept_leader_name` / `finance_approver_name` 在审批流需求20实现后才能写入未实现前返回空字符串。`submitter_name` 本迭代即可实现。
> **实施依赖**动态审批摘要依赖审批流需求20`submitter_name` 可独立实现。
**响应 DTO**(退款列表响应新增):
```go
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
DeptLeaderName string `json:"dept_leader_name" description:"部门领导审批人账号名"`
FinanceApproverName string `json:"finance_approver_name" description:"财务审批人账号名"`
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"`
ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"`
ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"`
CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"`
ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"`
ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"`
ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"`
```
`ApprovalRecordSummary` 动态返回 `node_name``approval_mode``status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。
停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id``processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。
---
### COL-002代理充值列表新增提交人、审批人依赖审批流
与 COL-001 同理,`tb_agent_recharge`(充值单表)新增同样三个字段,快照逻辑一致
与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息
> **实施依赖**`submitter_name` 本迭代可实现;审批人字段依赖需求21充值审批流
> **实施依赖**`submitter_name` 本迭代可实现;动态审批摘要依赖需求21充值审批流
---
### 前端
各列表页新增对应字段列,展示即可,无额外交互
退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮

View File

@@ -1,6 +1,8 @@
# 需求04退款中资产禁止换货
# 需求06资产最后到期时间
> 状态:待评审
---
## 需求04退款中禁止换货
@@ -9,8 +11,13 @@
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
未结束状态1=待审批、4=已退回(退回给提交人修改中)。
终态不拦截2=已通过、3=已拒绝。
拦截范围:
- `status=1` 待审批。
- `status=4` 已退回,等待提交人修改。
- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。
不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。
### 数据模型
@@ -41,10 +48,17 @@ RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL",
Where(`iot_card_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
cardID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
@@ -53,10 +67,17 @@ func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (b
func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL",
Where(`device_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
deviceID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
@@ -94,9 +115,11 @@ func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *res
### 业务规则
资产详情页展示:**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。
资产详情页展示:**当前生效套餐 + 所有待生效套餐按队列接续后的预计最后到期时间**
(即所有排队套餐依次激活完毕后,资产最终什么时候到期)
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
### 接口
@@ -115,69 +138,66 @@ GET /api/admin/assets/resolve/:identifier
**DTO 新增字段**`internal/model/dto/asset_dto.go``AssetResolveResponse`
```go
// 所有套餐(生效中+待生效)中最晚的到期时间,无套餐时为 null
LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"所有套餐(生效中+待生效)的最后到期时间,无套餐时为 null"`
// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null
LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为null"`
```
**Store 新增方法**`internal/store/postgres/package_usage_store.go`
```go
// GetActiveAndQueuedByCardID 获取卡的生效中和待生效套餐
func (s *PackageUsageStore) GetActiveAndQueuedByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐
func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", cardID,
Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("created_at ASC").
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐
func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐
func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", deviceID,
Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("created_at ASC").
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
```
> 注:待生效套餐的 `expires_at` 可能为 null实名即生效尚未实名取已知 expires_at 中的最大值
新建 `PackageUsage` 必须快照 `calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容
**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加):
```go
var lastExpiry *time.Time
var usages []*model.PackageUsage
if card != nil {
usages, _ = s.packageUsageStore.GetActiveAndQueuedByCardID(ctx, card.ID)
} else if device != nil {
usages, _ = s.packageUsageStore.GetActiveAndQueuedByDeviceID(ctx, device.ID)
}
for _, u := range usages {
if u.ExpiresAt == nil {
continue
}
if lastExpiry == nil || u.ExpiresAt.After(*lastExpiry) {
lastExpiry = u.ExpiresAt
}
// 1. 找当前主套餐的 expires_at 作为 cursor。
// 2. 按 priority、created_at、id 遍历排队主套餐。
// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。
// 4. cursor 即预计最后到期时间。
lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now)
if err != nil {
return nil, err
}
resp.LastPackageExpiresAt = lastExpiry
```
`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。
### 前端
资产详情"套餐信息"板块新增展示:
```
最后到期时间2027-01-01
预计最后到期时间2027-01-01
```
读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。

View File

@@ -1,5 +1,7 @@
# 需求05套餐分配生效条件ExpiryBase 覆盖)
> 状态:待评审
---
## 背景
@@ -12,12 +14,17 @@
## 快照链设计
```
ShopPackageAllocation.expiry_base_override分配时设置覆盖值
↓ 订单创建时读取取有效值override 优先,否则用套餐默认)
PackageUsage.expiry_base_snapshot快照进去
↓ 激活时读取
activation_service 读 usage.ExpiryBaseSnapshot不再直接读 pkg.ExpiryBase
```mermaid
flowchart TD
Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?}
Allocation[ShopPackageAllocation.expiry_base_override] --> Effective
Effective -->|有覆盖| Override[使用分配覆盖值]
Effective -->|无覆盖| Default[使用套餐默认值]
Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot]
Default --> Snapshot
Snapshot --> Activation[套餐激活只读快照]
Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值]
Fallback --> Activation
```
遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。
@@ -30,16 +37,29 @@ activation_service 读 usage.ExpiryBaseSnapshot不再直接读 pkg.ExpiryBase
```sql
ALTER TABLE tb_shop_package_allocation
ADD COLUMN expiry_base_override VARCHAR(30) DEFAULT NULL
COMMENT '生效条件覆盖NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)';
ADD COLUMN expiry_base_override VARCHAR(30);
COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override
IS '生效条件覆盖NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)';
```
### 2. PackageUsage 新增快照字段
```sql
ALTER TABLE tb_package_usage
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT ''
COMMENT '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)';
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '',
ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '',
ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0,
ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot
IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot
IS '周期类型快照(空字符串=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.duration_months_snapshot
IS '月数快照0=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.duration_days_snapshot
IS '天数快照0=旧数据兜底读套餐原值)';
```
旧数据不回填,默认空字符串,激活时自动兜底。
@@ -61,6 +81,11 @@ ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);c
```go
// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值)
ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"`
// 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。
CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"`
DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"`
DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"`
```
---
@@ -77,26 +102,38 @@ expiryBase := pkg.ExpiryBase
if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" {
expiryBase = *allocation.ExpiryBaseOverride
}
// 创建 PackageUsage 时写入快照
// 创建 PackageUsage 时一次性写入计时快照
usage.ExpiryBaseSnapshot = expiryBase
usage.CalendarTypeSnapshot = pkg.CalendarType
usage.DurationMonthsSnapshot = pkg.DurationMonths
usage.DurationDaysSnapshot = pkg.DurationDays
```
### 2. 激活时读快照(`internal/service/package/activation_service.go`
```go
// 修改前:直接读套餐原值
expiryBase := pkg.ExpiryBase
// 修改后:优先读快照,旧数据兜底
expiryBase := pkg.ExpiryBase // 兜底(旧数据 ExpiryBaseSnapshot 为空)
if usage.ExpiryBaseSnapshot != "" {
expiryBase = usage.ExpiryBaseSnapshot
// 新订单只读购买快照;旧记录兼容回退套餐当前值。
expiryBase := usage.ExpiryBaseSnapshot
if expiryBase == "" {
expiryBase = pkg.ExpiryBase
}
calendarType := usage.CalendarTypeSnapshot
if calendarType == "" {
calendarType = pkg.CalendarType
}
durationMonths := usage.DurationMonthsSnapshot
if durationMonths == 0 {
durationMonths = pkg.DurationMonths
}
durationDays := usage.DurationDaysSnapshot
if durationDays == 0 {
durationDays = pkg.DurationDays
}
```
同文件另一处(`activation_service.go:556`)同理修改
同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段
`internal/service/order/service.go:2290` 中后台囤货路径的 `ExpiryBase` 判断也需同样改为读快照(兜底逻辑一致)
`internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照需求06的“预计最后到期时间”同样只读这组快照保证购买后套餐配置变更不会改写历史预测
---
@@ -105,7 +142,7 @@ if usage.ExpiryBaseSnapshot != "" {
### 1. 分配套餐接口(新增参数)
```
POST /admin/shop-package-allocations
POST /api/admin/shop-package-allocations
```
请求 DTO 新增字段:
@@ -117,7 +154,7 @@ ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneo
### 2. 修改已分配套餐的生效条件(新接口)
```
PATCH /admin/shop-package-allocations/{id}/expiry-base
PATCH /api/admin/shop-package-allocations/{id}/expiry-base
```
请求 DTO
@@ -155,4 +192,4 @@ type UpdateAllocationExpiryBaseRequest struct {
| `from_activation` | 实名即生效(已覆盖) |
| `from_purchase` | 购买即生效(已覆盖) |
操作列增加"修改生效条件"按钮,调用 `PATCH /admin/shop-package-allocations/{id}/expiry-base`
操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`

View File

@@ -1,13 +1,40 @@
# 需求08设备批量分配代理套餐系列Excel导入
# 需求08设备批量分配代理套餐系列Excel导入
> 状态:待评审
> 模板:前端静态资源,后端仅负责上传校验和异步处理。
---
## 背景
设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)来批量指定分配目标。
设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作:
1. 批量分配设备给代理。
2. 批量分配设备给套餐系列。
两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id``series_id`
`Device` 表已有 `shop_id *uint``series_id *uint` 字段,分配即更新这两个字段。
```mermaid
sequenceDiagram
actor User as 平台员工
participant Web as 设备管理前端
participant API as BatchAllocation API
participant DB as PostgreSQL
participant Worker as Asynq Worker
User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel
Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series
API->>DB: 创建任务并保存上传文件引用
API-->>Web: task_id
API->>Worker: 入队处理任务
Worker->>DB: 按设备号批量查询并条件更新
Worker->>DB: 写成功数、失败数和失败明细
Web->>API: 轮询任务详情
API-->>Web: 处理结果
```
---
## 数据库变更
@@ -23,8 +50,9 @@ CREATE TABLE tb_device_batch_allocation_task (
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL,
shop_id BIGINT NOT NULL,
series_id BIGINT,
source_file_key VARCHAR(500) NOT NULL,
operation_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
@@ -36,10 +64,13 @@ CREATE TABLE tb_device_batch_allocation_task (
);
CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC);
```
状态常量1=处理中2=已完成3=失败。
`operation_type``assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。
失败明细存 JSONB`failed_items`),失败条数有限,无需单独明细表。
---
@@ -52,8 +83,9 @@ type DeviceBatchAllocationTask struct {
gorm.Model
BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"`
ShopID uint `gorm:"column:shop_id;not null;comment:分配目标代理店铺ID" json:"shop_id"`
SeriesID *uint `gorm:"column:series_id;comment:套餐系列ID可选" json:"series_id,omitempty"`
SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"`
OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"`
TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID代理店铺或套餐系列" json:"target_id"`
OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"`
TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"`
SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"`
@@ -75,38 +107,56 @@ func (DeviceBatchAllocationTask) TableName() string {
## API 设计
### 1. 下载 Excel 模板
### 1. 前端静态 Excel 模板
```
GET /admin/devices/batch-allocation/template
```
模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。
响应Excel 文件,表头为"设备号"(一列)。
- 文件建议命名:`设备批量分配模板-v1.xlsx`
- 表头:`设备号`(一列)
- 前端“下载模板”按钮直接下载静态文件。
- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。
模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。
### 2. 上传并提交
```
POST /admin/devices/batch-allocation
POST /api/admin/devices/batch-assign-shop
Content-Type: multipart/form-data
字段:
shop_id: 123 (必填,分配给哪个代理)
series_id: 45 (可选,同时分配套餐系列)
file: <Excel文件>
```
```text
POST /api/admin/devices/batch-assign-series
Content-Type: multipart/form-data
字段:
series_id: 45 (必填,分配给哪个套餐系列)
file: <Excel文件>
```
两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。
**处理逻辑Asynq 异步)**
1. 解析 Excel,读取"设备号"列
2. 逐行查找 `tb_device` 中匹配的设备(按 `virtual_no``imei`
3. 对找到的设备:更新 `shop_id` + `series_id`
4. 未找到的:记录失败原因"设备号不存在"
5. 已分配给其他代理的:记录"该设备已分配,请先回收"
1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务Worker 从对象存储下载并解析 Excel
2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no``imei`,禁止逐行查询
3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id``assign_series` 仅更新 `series_id`
4. 未找到的:记录失败原因"设备号不存在"
5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收"`assign_series` 不改变代理归属。
6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。
临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。
限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。
### 3. 查询任务状态
```
GET /admin/devices/batch-allocation/{task_id}
GET /api/admin/devices/batch-allocation/{task_id}
```
响应:
@@ -114,6 +164,8 @@ GET /admin/devices/batch-allocation/{task_id}
```json
{
"task_no": "DBA20240101001",
"operation_type": "assign_shop",
"target_id": 123,
"status": 2,
"status_name": "已完成",
"total_count": 100,
@@ -134,18 +186,18 @@ GET /admin/devices/batch-allocation/{task_id}
### 入口
设备管理页 > 批量操作 > "批量分配代理"
设备管理页 > 批量操作
- "批量分配代理"
- "批量分配套餐系列"
### 操作流程
1. 点击"批量分配代理"
2. 弹框内:
- 代理选择器(搜索+选择目标代理,必填)
- 套餐系列选择器(可选)
- Excel 上传区域(含模板下载链接)
3. 上传后点击"提交" → `POST /admin/devices/batch-allocation`
1. 点击其中一个批量操作入口。
2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。
3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop``POST /api/admin/devices/batch-assign-series`
4. 提示"任务提交成功,正在处理..."
5. 轮询 `GET /admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
6. 展示结果成功X条失败X条失败明细在页面展示
### Excel 规范

View File

@@ -1,5 +1,6 @@
# 需求09C端支付方式限制配置化
> 状态:待评审
> 依赖:[系统配置](./基础设施/系统配置.md)
---
@@ -25,6 +26,21 @@
**钱包支付对所有资产类型均允许。**
```mermaid
flowchart TD
Pay[用户选择支付方式] --> Asset{资产类型}
Asset -->|IoT卡| Card[读取卡允许方式]
Asset -->|设备| Device[读取设备允许方式]
Asset -->|未知| RejectUnknown[拒绝:资产类型无效]
Card --> ConfigOK{配置可用?}
Device --> ConfigOK
ConfigOK -->|是| Match{支付方式在允许集合?}
ConfigOK -->|否| SafeDefault[使用代码内安全默认集合]
SafeDefault --> Match
Match -->|是| Continue[继续支付]
Match -->|否| Reject[拒绝并返回对应中文提示]
```
---
## 实现方案
@@ -56,14 +72,16 @@ func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, p
case model.AssetTypeDevice: // "device",定义在 internal/model/asset_identifier.go
configKey = "c2b.payment.device_allowed_methods"
default:
return nil // 未知资产类型不限制
return errors.New(errors.CodeInvalidParam, "资产类型无效")
}
allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey)
if err != nil || len(allowedMethods) == 0 {
// 配置读取失败,降级为允许(防止配置问题影响支付)
s.logger.Warn("读取支付方式配置失败,降级为允许", zap.String("config_key", configKey))
return nil
// 配置异常时回退到代码内安全默认值,禁止放开全部支付方式。
allowedMethods = defaultAllowedMethods(assetType)
s.logger.Error("读取支付方式配置失败,已使用安全默认值",
zap.String("config_key", configKey),
zap.Error(err))
}
for _, m := range allowedMethods {
@@ -77,6 +95,8 @@ func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, p
`client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。
系统配置更新时必须保证 `wallet` 始终存在于两个允许集合中;前端将钱包选项显示为勾选且不可取消,后端再次校验,避免配置破坏业务规则。
### 3. 错误信息
用户端错误提示(友好文案):
@@ -97,13 +117,17 @@ case model.AssetTypeDevice:
### C端支付页面
前端在展示支付方式时,根据资产类型**过滤不可用的支付方式**(前端主动隐藏,后端兜底拦截)。
前端不维护另一份固定规则。资产初始化/详情接口返回后端已经计算好的:
前端需要从哪里知道"当前资产是卡还是设备"?登录/初始化时返回 `asset_type`,前端保存上下文。
```go
AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式"`
```
支付页只展示该集合中的方式,后端支付接口再次执行相同校验。配置变化后重新进入支付页或刷新资产信息即可获得新集合。
```
资产类型 = "iot_card" → 展示支付方式:支付宝、余额
资产类型 = "device" → 展示支付方式:微信、余额
allowed_payment_methods = ["alipay", "wallet"] → 展示支付宝、余额
allowed_payment_methods = ["wechat", "wallet"] → 展示微信、余额
```
### 后台配置页面
@@ -115,6 +139,8 @@ case model.AssetTypeDevice:
设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信
```
修改后调用 `PUT /admin/system/config/c2b.payment.card_allowed_methods`
余额选项固定勾选且禁用,不允许管理员取消
修改后调用 `PUT /api/admin/system/config/c2b.payment.card_allowed_methods`
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。

View File

@@ -1,194 +1,136 @@
# 需求10限速规则
# 需求10Gateway 手动卡限速
> 状态:待评审
> 已确认边界:本期只提供手动设置/取消限速Gateway 最终对象始终是卡,统一使用 `cardNo`。
---
## 背景
## 一、范围
Gateway 已有 `SetSpeedLimit` 接口,支持卡和设备:
本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。
- 单卡资产:使用 `IotCard.ICCID` 作为 `cardNo`
- 设备资产:查询 `tb_device_sim_binding.is_current=true` 的当前绑定卡,再使用该卡 ICCID。
- `speed_kbps > 0` 表示设置限速;`speed_kbps = 0` 表示取消限速。
- 内部业务单位固定为 `kbps`,前端也按 `kbps` 输入和展示。
本期不做:
- 套餐限速字段或套餐编辑页限速配置。
- 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。
- 设备 IMEI 限速。
- “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。
取消限速仍调用同一个 Gateway 方法。`speed_kbps=0` 是本系统语义Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。
---
## 二、流程
```mermaid
sequenceDiagram
actor User as 平台员工
participant Web as 资产详情页
participant API as SpeedLimit Application
participant DB as PostgreSQL
participant Resolver as 当前卡解析器
participant Gateway as Gateway Adapter
User->>Web: 输入限速值或点击取消
Web->>API: asset identifier + speed_kbps + request_id
API->>Resolver: 解析实际 cardNo
Resolver-->>API: ICCID 或无当前卡错误
API->>DB: 创建或复用限速操作记录
API->>Gateway: SetSpeedLimit(cardNo, speedKbps)
Gateway-->>API: 成功或失败
API->>DB: 写结果、错误摘要和操作审计
API-->>Web: 返回本次操作结果
```
设备不存在当前绑定卡时,接口返回业务错误并记录失败原因;绝不把设备 IMEI、设备 ID 或空字符串发送给 Gateway。
---
## 三、应用端口与数据
```go
// internal/gateway/device.go
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error
// GatewaySpeedLimitPort 设置或取消卡限速。
// speedKbps=0 表示取消,具体上游参数由适配器转换。
type GatewaySpeedLimitPort interface {
SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error
}
// internal/gateway/models.go
type SpeedLimitReq struct {
CardNo string `json:"cardNo,omitempty"` // 卡(与 DeviceID 二选一)
DeviceID string `json:"deviceId,omitempty"` // 设备(与 CardNo 二选一)
SpeedLimit int `json:"speedLimit"` // 限速值KB/s0=不限速
Extend string `json:"extend,omitempty"`
// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。
type SpeedLimitCardResolver interface {
ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error)
}
```
**"根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则"**
设计思路:限速规则配置在**套餐**上(不同运营商、不同流量档位对应不同套餐,套餐本身就是差异载体)。套餐激活时自动调用 Gateway 设置限速。
---
## 数据库变更
新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试:
```sql
-- 套餐表增加限速字段
ALTER TABLE tb_package
ADD COLUMN speed_limit_kbps INT NOT NULL DEFAULT 0
COMMENT '限速值KB/s0=不限速';
CREATE TABLE tb_gateway_speed_limit_operation (
id BIGSERIAL PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_id BIGINT NOT NULL,
card_no VARCHAR(100) NOT NULL,
desired_speed_kbps INT NOT NULL,
status INT NOT NULL DEFAULT 1,
error_summary TEXT NOT NULL DEFAULT '',
operator_id BIGINT NOT NULL,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_gateway_speed_limit_operation_request
ON tb_gateway_speed_limit_operation(request_id);
CREATE INDEX idx_gateway_speed_limit_operation_asset
ON tb_gateway_speed_limit_operation(asset_type, asset_id, created_at DESC);
```
状态:`1=处理中, 2=成功, 3=失败`。同一 `request_id` 重试时任务、操作人、资产和目标限速一致才返回或继续原记录不一致返回冲突。Gateway 网络超时后允许使用相同目标值重试,因为 `SetSpeedLimit` 是“设为目标状态”的幂等操作。
---
## Model 变更
## 四、接口与前端
```go
// internal/model/package.go
type Package struct {
// ...原有字段...
SpeedLimitKbps int `gorm:"column:speed_limit_kbps;type:int;not null;default:0;comment:限速值KB/s0=不限速" json:"speed_limit_kbps"`
```text
POST /api/admin/assets/{identifier}/speed-limit
```
```json
{
"request_id": "01JZ...",
"speed_kbps": 1024
}
```
- `speed_kbps` 必须为整数且大于等于 0。
- 取消操作提交 `speed_kbps=0`,不新增第二个取消接口。
- 后端根据 `identifier` 解析资产类型和数据范围,前端不得传 `cardNo`、设备 ID 或资产类型作为权威依据。
资产详情页提供两个独立命令:
- 数字输入框加“保存”用于设置限速,单位固定显示 `kbps`
- 取消按钮用于提交 `speed_kbps=0`,并使用确认弹窗避免误操作。
设备详情页显示“当前使用卡 ICCID”。没有当前卡时禁用两个命令并展示后端返回的原因。页面可展示最近一次操作的目标限速、结果、操作人和时间但不能把该记录描述为 Gateway 的实时状态。
---
## 后端实现
## 五、审计与人工验收
### 1. 套餐激活时设置限速
每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。
`internal/service/package/activation_service.go` 中,套餐激活(`ActivatedAt` 赋值)时调用限速
人工验收覆盖
```go
// activatePackageUsage 套餐激活核心逻辑(已有函数,追加限速调用)
func (s *ActivationService) activatePackageUsage(ctx context.Context, usage *model.PackageUsage, pkg *model.Package) error {
// ...现有激活逻辑...
1. 单卡按 ICCID 设置限速。
2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。
3. 设备无当前卡时拒绝调用 Gateway。
4. `speed_kbps=0` 经同一 Gateway 方法取消限速。
5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。
6. Gateway 失败记录错误并允许相同目标值重试。
// 套餐有限速配置时,调用 Gateway 设置
if pkg.SpeedLimitKbps > 0 {
if err := s.applySpeedLimit(ctx, usage, pkg.SpeedLimitKbps); err != nil {
// 限速失败不阻断套餐激活,记录错误日志
s.logger.Error("设置限速失败", zap.Error(err),
zap.Uint("package_usage_id", usage.ID),
zap.Int("speed_limit", pkg.SpeedLimitKbps))
}
}
return nil
}
// applySpeedLimit 调用 Gateway 设置限速
func (s *ActivationService) applySpeedLimit(ctx context.Context, usage *model.PackageUsage, speedKbps int) error {
req := &gateway.SpeedLimitReq{SpeedLimit: speedKbps}
switch usage.UsageType {
case constants.PackageUsageTypeSingleCard: // "single_card",定义在 pkg/constants/iot.go
// 查卡的 ICCID
card, err := s.iotCardStore.GetByID(ctx, usage.IotCardID)
if err != nil { return err }
req.CardNo = card.ICCID
case constants.PackageUsageTypeDevice: // "device",定义在 pkg/constants/iot.go
// 查设备的 IMEI
device, err := s.deviceStore.GetByID(ctx, usage.DeviceID)
if err != nil { return err }
req.DeviceID = device.IMEI
}
return s.gatewayClient.SetSpeedLimit(ctx, req)
}
```
### 2. 套餐到期/失效时取消限速
当套餐状态变为 `已过期(3)``已失效(4)` 时,重置限速为 0不限速
在套餐过期处理任务中追加:
```go
// 套餐过期时取消限速SpeedLimit=0 表示不限速)
if expiredPkg.SpeedLimitKbps > 0 {
s.applySpeedLimit(ctx, usage, 0) // 0 = 不限速
}
```
> **注意**:如果有续费的排队套餐立即生效,应以新套餐的限速为准,而不是先取消再设置。
### 3. 手动操作限速(后台管理)
```
POST /admin/iot-cards/{id}/speed-limit
POST /admin/devices/{id}/speed-limit
```
请求体:
```go
type SetSpeedLimitRequest struct {
SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值KB/s0=取消限速"`
}
```
直接调 Gateway不更新套餐配置临时操作
---
## API 变更(套餐管理)
### 创建/更新套餐新增字段
```
POST /admin/packages
PUT /admin/packages/{id}
```
新增参数:
```go
SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值KB/s0=不限速"`
```
### 套餐列表/详情响应新增字段
```go
type PackageResponse struct {
// ...原有字段...
SpeedLimitKbps int `json:"speed_limit_kbps" description:"限速值KB/s0=不限速"`
SpeedLimitDesc string `json:"speed_limit_desc" description:"限速描述(如 '512 KB/s'0时为'不限速'"`
}
```
---
## 前端对接
### 页面:套餐创建/编辑
在"套餐配置"区域新增限速项:
```
限速配置:
限速值:[____] KB/s 0 或留空 = 不限速)
提示文案:留空或填 0 表示不限速;建议根据运营商规则填写
```
前端换算提示可选512 KB/s ≈ 4 Mbps
### 页面:套餐列表
新增"限速"列:
- `0` → 显示"不限速"
- `>0` → 显示"XXX KB/s"
### 页面:资产详情 > 操作区
新增"手动设置限速"按钮(仅当有生效套餐时显示):
```
弹框:
当前限速xxx KB/s来自套餐配置
临时设置:[____] KB/s [0=取消限速]
[确认设置] → POST /admin/iot-cards/{id}/speed-limit
```
---
## 注意事项
1. **Gateway 调用失败不阻断业务**:限速设置失败只记录日志,套餐仍然激活。
2. **限速精度**Gateway `SpeedLimit` 单位是 KB/s最小值为 1填 0 表示不限速。
3. **运营商差异**:不同运营商通过创建不同套餐来体现限速差异,不需要在套餐外额外维护运营商-限速映射表。
4. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。
上线前置条件Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。

View File

@@ -1,6 +1,7 @@
# 需求14导出功能
> 复用现有 ExportTask 体系(`tb_export_task` + Asynq
> 状态:待评审
> 复用现有 ExportTask 体系(`tb_export_task` + Asynq不为每个业务模块新增一套导出路由。
---
@@ -14,6 +15,7 @@
| 6.8.4 | 退款管理退款列表导出 | **全新** |
| 6.8.5 | 换货管理导出 | **全新** |
| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) |
| 需求22 | 临期资产列表导出 | **全新**`scene=expiring_asset` |
---
@@ -26,11 +28,47 @@
- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行
- 前端轮询任务状态后下载
```mermaid
sequenceDiagram
actor User as 后台用户
participant Web as 前端
participant API as ExportTask API
participant DB as PostgreSQL
participant Worker as Asynq Worker
participant Storage as 对象存储
User->>Web: 选择导出字段并确认
Web->>API: POST /api/admin/export-tasks
API->>API: 计算数据范围和字段权限交集
API->>DB: 保存查询、范围和字段快照
API-->>Web: task_id
API->>Worker: 入队导出任务
Worker->>DB: 按 scene 分片查询
Worker->>Storage: 上传导出文件
Worker->>DB: 更新进度和 download file_key
Web->>API: 轮询 GET /api/admin/export-tasks/{id}
API-->>Web: 状态、进度、download_url
```
新增导出模块需要:
1.`pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量
2.`internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`
3.`registry.go``NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()`
4. 新增对应的 Export API创建导出任务传入 `scene` 字段)
4. 扩展统一 `CreateExportTaskRequest.Scene` 的允许值,通过 `POST /api/admin/export-tasks` 创建任务
5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限
统一创建请求扩展为:
```go
type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required" description:"导出场景"`
Format string `json:"format" validate:"required,oneof=xlsx csv" description:"导出格式"`
Query map[string]any `json:"query,omitempty" description:"与列表一致的筛选条件"`
Fields []string `json:"fields" validate:"required,min=1" description:"申请导出的字段key"`
}
```
新增场景:`agent_wallet_transaction``package``refund``exchange``agent_recharge``expiring_asset`。DTO description 和 `pkg/constants/` 必须同步维护。
---
@@ -66,7 +104,7 @@ DataLimitMB int64 `gorm:"column:data_limit_mb"`
## 6.8.2:代理资金概况-预充值钱包流水导出
**新增接口**`POST /admin/agent-wallet-transactions/export`
**新增场景**`scene=agent_wallet_transaction`
支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。
@@ -83,7 +121,7 @@ DataLimitMB int64 `gorm:"column:data_limit_mb"`
| 交易时间 | `created_at` |
| 交易前金额 | `balance_before / 100` |
| 交易后金额 | `balance_after / 100` |
| 购买套餐名称 | `metadata` 中 JSON 字段 `package_name`或 JOIN 订单 |
| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`禁止关联当前套餐名称 |
| 操作人 | JOIN `tb_account``creator` 字段关联 `tb_account.id`,取 `username` |
| 交易 ID | `id` |
| 关联业务订单号 | `reference_id` 对应的单号JOIN 对应表) |
@@ -93,11 +131,11 @@ DataLimitMB int64 `gorm:"column:data_limit_mb"`
## 6.8.3:套餐列表导出
**新增接口**`POST /admin/packages/export`
**新增场景**`scene=package`
支持现有套餐列表筛选条件。
导出字段(按需求文档 24字段
导出字段(按实际列举的 25稳定字段 Key
```go
type PackageExportRow struct {
@@ -133,9 +171,9 @@ type PackageExportRow struct {
## 6.8.4:退款管理退款列表导出
**新增接口**`POST /admin/refund-orders/export`
**新增场景**`scene=refund`
导出字段(去掉"退款到账方式",保留其余字段,部门领导/财务审批人依赖审批流
导出字段(去掉退款到账方式”,审批信息使用动态摘要,不固定具体节点
```go
type RefundExportRow struct {
@@ -153,21 +191,25 @@ type RefundExportRow struct {
Status string `xlsx:"状态"`
RefundReason string `xlsx:"退款原因"`
Remark string `xlsx:"备注"`
ApprovalRemark string `xlsx:"审批备注"`
ApprovalSource string `xlsx:"审批来源"`
ApprovalStatus string `xlsx:"审批状态"`
ProcessingStatus string `xlsx:"退款处理状态"`
CurrentNodeName string `xlsx:"当前审批节点"`
ApprovalRecords string `xlsx:"审批记录"`
AppliedAt string `xlsx:"退款申请时间"`
CompletedAt string `xlsx:"退款完成时间"`
SubmitterName string `xlsx:"提交人"`
DeptLeaderName string `xlsx:"部门领导审批人"`
FinanceName string `xlsx:"财务审批人"`
VoucherURLs string `xlsx:"退款凭证"`
}
```
`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。
---
## 6.8.5:换货管理导出
**新增接口**`POST /admin/exchange-orders/export`
**新增场景**`scene=exchange`
```go
type ExchangeExportRow struct {
@@ -193,7 +235,7 @@ type ExchangeExportRow struct {
## 6.8.6:代理充值导出
**新增接口**`POST /admin/agent-recharge-orders/export`
**新增场景**`scene=agent_recharge`
去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:
@@ -215,13 +257,17 @@ type AgentRechargeExportRow struct {
PaidAt string `xlsx:"支付时间"`
CompletedAt string `xlsx:"完成时间"`
SubmitterName string `xlsx:"提交人"`
DeptLeaderName string `xlsx:"部门领导审批人"`
FinanceName string `xlsx:"财务审批人"`
ApprovalSource string `xlsx:"审批来源"`
ApprovalStatus string `xlsx:"审批状态"`
CurrentNodeName string `xlsx:"当前审批节点"`
ApprovalRecords string `xlsx:"审批记录"`
VoucherURLs string `xlsx:"支付凭证"`
Remark string `xlsx:"备注"`
}
```
充值导出的审批记录格式和批量查询规则与退款导出一致。
---
## 前端对接(通用模式)
@@ -229,19 +275,138 @@ type AgentRechargeExportRow struct {
各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。
调用流程:
1. 点击"导出" → 携带当前筛选条件 → `POST /admin/{module}/export`
2. 返回 `export_task_id`
3. 前端轮询 `GET /admin/export-tasks/{id}` 直到 `status=completed`
4. 下载 `download_url`
1. 点击导出”后请求当前账号在该场景可导出的字段目录。
2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。
3. 提交 `scene + format + query + fields``POST /api/admin/export-tasks`
4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。
5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。
(与现有导出体系完全一致,复用现有前端导出 Hook
---
## 权限导出配置(预留)
## 导出字段权限
需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。
**本次迭代**:统一导出全量字段,不做权限差异化(复杂度高,单独排期)
评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留
**预留方案**:在导出 Handler 里预留 `filterFieldsByRole(userRoles, rows)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。
该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。
### 1. 字段目录
每个场景在代码中注册稳定字段 key
```go
// ExportFieldDefinition 导出字段定义
type ExportFieldDefinition struct {
Key string
Header string
DefaultSelected bool
Required bool
}
```
示例:
```text
scene=package
package_code 套餐编码 默认选择
package_name 套餐名称 默认选择
real_data_mb 真流量额度 敏感字段
virtual_data_mb 虚流量额度 默认选择
cost_price 成本价 敏感字段
```
表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。
### 2. 角色字段授权表
```sql
CREATE TABLE tb_role_export_field_permission (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL,
scene VARCHAR(50) NOT NULL,
field_key VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
creator BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX idx_role_export_field_permission
ON tb_role_export_field_permission(role_id, scene, field_key);
```
账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。
### 3. 权限 API
```text
GET /api/admin/export-fields?scene=package
```
返回当前账号可选择的字段:
```json
{
"scene": "package",
"fields": [
{
"key": "package_code",
"header": "套餐编码",
"default_selected": true,
"required": true
}
]
}
```
角色管理:
```text
GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields
```
更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。
### 4. 创建任务时的权限快照
创建任务时计算:
```text
resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields
```
- 必选字段由后端自动补齐。
- 交集为空时拒绝创建任务。
- `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`Worker 不重新根据后来变化的角色权限扩大字段。
- Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。
### 5. 前端角色配置
- 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。
- 普通列表的导出弹框只显示当前账号被授权的字段。
- 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。
- 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。
---
## 查询与性能约束
- 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。
- 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。
- DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。
- 动态字段不代表动态拼接不受控 SQL字段 key 必须通过服务端白名单映射到固定查询列。
---
## 发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。
2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。
3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。
4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。
禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。

View File

@@ -1,5 +1,8 @@
# 需求15/16/18/19/20/21 技术方案
> 状态:待评审
> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。
---
## 需求15套餐下架后允许续费
@@ -10,145 +13,81 @@
- 下架套餐**可以续费**(已在使用该套餐的客户)
- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户)
```mermaid
flowchart TD
Start[请求购买套餐] --> Enabled{套餐是否启用?}
Enabled -->|否| RejectDisabled[拒绝:套餐已禁用]
Enabled -->|是| Shelf{是否已下架?}
Shelf -->|否| Allow[允许继续下单]
Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?}
Renewal -->|否| RejectNew[拒绝:下架套餐不可新购]
Renewal -->|是| Actor{是否客户本人续费?}
Actor -->|是| Allow
Actor -->|否| RejectProxy[拒绝:下架套餐不可代购]
```
“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 `is_renewal=true`
### 后端
**当前逻辑**:下架套餐在购买时被拦截。
修改:在订单创建校验中,区分"新购"和"续费"场景
修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 `is_renewal`
```go
// internal/service/order/service.go 或 client_order/service.go
func (s *Service) validatePackageAvailability(
ctx context.Context,
pkg *model.Package,
assetID uint,
isRenewal bool,
asset *ResolvedAsset,
actor *PurchaseActor,
) error {
if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go
return errors.New(errors.CodeForbidden, "套餐已禁用")
}
if pkg.ShelfStatus == constants.ShelfStatusOff && !isRenewal { // 2=下架
return errors.New(errors.CodeForbidden, "套餐已下架,不可新购")
if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架
if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) {
return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费")
}
}
return nil
}
```
**isRenewal 判断**:查当前资产是否有该套餐的历史生效记录:
**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效记录不能作为续费资格
```go
func (s *Service) isRenewal(ctx context.Context, assetType string, assetID uint, packageID uint) bool {
count, _ := s.packageUsageStore.CountByAssetAndPackage(ctx, assetType, assetID, packageID)
return count > 0
func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool {
return actor.OwnsAsset(asset) &&
s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID)
}
```
### 前端
C端续费页面:下架套餐不在"新购"列表中展示,但在"续费"入口中仍可展示
C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表
后台代购时:下架套餐的"代购"按钮禁用tooltip 提示"套餐已下架,不可代购"。
---
## 需求16代理分销码与佣金提现
## 需求16代理分销码与佣金提现已移出7月迭代
### 业务规则
> 状态:已移出本期
> 决策日期2026-07-14
> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布
**DST-001**:新建代理时自动建立分销归属关系(指定发展人)
**DST-002**:员工可作为代理发展人进行标识
**二维码**:代理或员工生成推广二维码 → 扫码进入H5填写信息 → 提交成为代理申请 → 平台审批
本次范围调整包含整个需求 16
### 数据库变更
- 代理/员工分销码和推广二维码。
- H5 代理申请、进度查询和退回重提。
- 代理申请接入通用审批及审批后自动开店。
- 店铺发展人关系和代理申请来源字段。
- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。
```sql
-- 1. Shop 表新增发展人字段
ALTER TABLE tb_shop
ADD COLUMN referrer_type VARCHAR(20) DEFAULT NULL
COMMENT '发展人类型 shop=代理介绍 admin=员工介绍',
ADD COLUMN referrer_id BIGINT DEFAULT NULL
COMMENT '发展人IDshop_id 或 tb_account.id',
ADD COLUMN referrer_name VARCHAR(50) DEFAULT NULL
COMMENT '发展人姓名快照';
因此 7 月迭代不创建 `tb_distribution_code``tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API不发布 `agent_application_approval`,也不建设对应前端页面。
-- 2. 分销码表
CREATE TABLE tb_distribution_code (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
code VARCHAR(32) NOT NULL,
owner_type VARCHAR(20) NOT NULL, -- shop=代理 | admin=员工
owner_id BIGINT NOT NULL,
owner_name VARCHAR(50) NOT NULL DEFAULT '',
qr_code_url TEXT,
use_count INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1, -- 1=有效 0=禁用
expires_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_distribution_code ON tb_distribution_code(code) WHERE deleted_at IS NULL;
-- 3. 代理申请表H5扫码提交
CREATE TABLE tb_agent_application (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
apply_no VARCHAR(30) NOT NULL,
distribution_code VARCHAR(32) NOT NULL,
referrer_type VARCHAR(20) NOT NULL,
referrer_id BIGINT NOT NULL,
applicant_name VARCHAR(50) NOT NULL,
applicant_phone VARCHAR(20) NOT NULL,
shop_name VARCHAR(100) NOT NULL,
province VARCHAR(50),
city VARCHAR(50),
district VARCHAR(50),
address VARCHAR(255),
status INT NOT NULL DEFAULT 1, -- 1=待审批 2=已通过 3=已拒绝
reject_reason TEXT,
reviewed_by BIGINT,
reviewed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_agent_application_no ON tb_agent_application(apply_no) WHERE deleted_at IS NULL;
```
### API 设计
**生成分销码**
```
POST /admin/distribution-codes
body: { owner_type: "admin"|"shop", owner_id: 123 }
```
**H5 扫码获取分销码信息**
```
GET /app/distribution-codes/{code}
```
**H5 提交代理申请**
```
POST /app/agent-applications
body: { distribution_code, applicant_name, applicant_phone, shop_name, ... }
```
**后台代理申请列表/审批**
```
GET /admin/agent-applications?status=1
POST /admin/agent-applications/{id}/approve
POST /admin/agent-applications/{id}/reject
body: { reject_reason: "..." }
```
审批通过时:自动创建 `tb_shop` + `tb_account`,设置 `referrer_type``referrer_id`
**佣金提现DST-003~007**
现有 commission 框架基础上新增材料上传字段,提现申请时一次性上传所有材料(对象存储 Key 列表)。
通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。
---
@@ -160,46 +99,99 @@ body: { reject_reason: "..." }
APR-009企微审批对接= Phase 2。
```mermaid
sequenceDiagram
actor Applicant as 提交人
participant Biz as 退款/充值业务
participant Approval as 审批流
participant Notice as 站内消息
actor Approver1 as 当前节点审批人
actor Approver2 as 下一节点审批人
Applicant->>Biz: 提交申请
Biz->>Approval: 同事务创建流程实例和首任务
Approval->>Notice: TaskCreated
Notice-->>Approver1: 待审批提醒
Approver1->>Approval: 审批通过
Approval->>Notice: 下一节点 TaskCreated
Notice-->>Approver2: 待审批提醒
Approver2->>Approval: 通过/驳回/退回
Approval->>Notice: 流程结果事件
Notice-->>Applicant: 结果和原因
```
### 实现要点
| 编号 | 需求 | 实现 |
|------|------|------|
| APR-001~003 | 充值/退款多级审核 | 见需求20/21 |
| APR-004 | 审核环节:部门领导→财务 | 审批流固定两步step=1 部门领导step=2 财务 |
| APR-001~003 | 充值/退款多级审核 | 见需求20/21需求16已移出本期 |
| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 |
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
| APR-006 | 上一级完成后才提示下一级 | `ApprovalFlowAggregate.Approve()` 触发 `ApprovalStepAdvancedEvent` |
| APR-007 | 通过后通知申请人 | `ApprovalCompletedEvent` handler |
| APR-008 | 驳回/退回后通知申请人含原因 | `ApprovalRejectedEvent` / `ApprovalReturnedEvent` handler |
| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 |
| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 |
| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 |
| APR-009 | 对接企微 | **Phase 2** |
### 默认流程与可配置边界
- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。
- 每个节点可配置 `role``user` 审批人来源,以及 `any``all` 完成方式。
- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。
- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。
- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。
- 已发起实例始终使用发起时保存的流程定义快照。
### 业务表与审批流的关联
充值单退款单接入审批流,各自新增 `approval_flow_id` 字段:
充值单退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。
```sql
ALTER TABLE tb_refund_request ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
COMMENT '当前审批流ID已退回后重新提交会更新为新流程ID';
业务表只保存当前审批实例 ID历史实例通过 `biz_type + biz_id` 查询。接入协议详见 [审批流文档 - 业务接入协议](./基础设施/审批流.md#十一业务接入协议)。
ALTER TABLE tb_agent_recharge_record ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
COMMENT '当前审批流ID已退回后重新提交会更新为新流程ID';
```
退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。
接入协议详见 [审批流文档 - 接入协议](./基础设施/审批流.md#六接入协议业务层如何接入审批流)。
### 前端技术方案
- 新增“待我审批”和动态审批详情,具体页面、状态和请求幂等规则见 [前端技术方案](./前端技术方案.md#六统一审批交互)。
- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。
- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。
- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。
- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。
- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。
---
## 需求19批量订购套餐BPO-001~008
### 支付方式粒度
评审结论:支付方式按**整批统一**设计:
- 页面选择 `offline``agent_wallet`Excel 不再重复填写支付方式。
- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
- Excel 不包含支付方式列,后端拒绝同一批次混合支付。
### 业务流程
1. 员工进入批量订购页面
2. 选择代理 + 上传 Excel字段资产类型/资产标识/套餐编码/套餐名称/支付方式)
3. 系统解析并校验每一行
4. 支付方式:`offline=线下支付(默认成功)` / `agent_wallet=代理钱包支付`
5. 代理钱包支付时从代理余额扣款(含信用额度)
6. 线下支付时标记为已支付(不走钱包)
7. 校验失败的行展示在页面,带失败原因
8. **线下支付需上传整批次的支付凭证**
```mermaid
flowchart TD
Start[员工选择代理和支付方式] --> Upload[上传 Excel]
Upload --> Parse[解析并持久化逐行明细]
Parse --> Validate[校验资产、套餐、归属和重复行]
Validate --> Item{处理下一条有效明细}
Item -->|代理钱包| Lock[锁定钱包并校验可用余额]
Item -->|线下支付| Voucher[校验整批支付凭证]
Lock --> Order[单条事务创建订单并扣款]
Voucher --> OrderOffline[单条事务创建已支付订单]
Order --> Result[记录订单 ID 和成功状态]
OrderOffline --> Result
Result --> More{还有待处理明细?}
More -->|是| Item
More -->|否| Summary[汇总任务结果]
Validate -->|校验失败| Failed[记录结构化失败原因]
Failed --> More
```
任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。
### 数据库变更
@@ -212,45 +204,121 @@ CREATE TABLE tb_bulk_purchase_task (
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL,
source_file_key VARCHAR(500) NOT NULL,
shop_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL, -- offline | agent_wallet
voucher_keys JSONB, -- 线下支付凭证列表
payment_method VARCHAR(20) NOT NULL,
voucher_keys JSONB NOT NULL DEFAULT '[]',
total_amount BIGINT NOT NULL DEFAULT 0,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1, -- 1=处理中 2=已完成
failed_items JSONB -- 失败明细
status INT NOT NULL DEFAULT 1,
error_message TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_task_no ON tb_bulk_purchase_task(task_no) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_bulk_purchase_task_no
ON tb_bulk_purchase_task(task_no)
WHERE deleted_at IS NULL;
CREATE TABLE tb_bulk_purchase_item (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
task_id BIGINT NOT NULL,
row_no INT NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_identifier VARCHAR(100) NOT NULL,
package_code VARCHAR(50) NOT NULL,
package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
package_id BIGINT,
amount BIGINT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
order_id BIGINT,
failure_code VARCHAR(50) NOT NULL DEFAULT '',
failure_reason VARCHAR(500) NOT NULL DEFAULT '',
idempotency_key VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row
ON tb_bulk_purchase_item(task_id, row_no);
CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency
ON tb_bulk_purchase_item(idempotency_key);
CREATE INDEX idx_bulk_purchase_item_task_status
ON tb_bulk_purchase_item(task_id, status);
```
> 失败明细存 JSONB`failed_items`),与现有 DeviceImportTask 模式一致,无需单独明细表。
状态建议:
- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`
- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`
### 处理与幂等
1. API 校验文件、代理、支付方式和凭证,将 Excel 保存到对象存储并创建任务,返回 `task_id`
2. Worker 根据 `source_file_key` 下载并解析 Excel将每一行先写入明细表再开始业务处理。
3. 同一任务按行顺序处理,避免对同一代理钱包制造不必要的乐观锁冲突。
4. 每行使用独立事务。代理钱包支付时锁定钱包记录,校验 `balance - frozen_balance + credit_limit` 后,在同一事务扣款、创建订单、资金流水并更新明细。
5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。
6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。
7. 任务汇总从明细表计算,不信任内存计数。
Asynq 载荷只传任务 ID不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理确保 Worker 重试期间仍可读取。
建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间。
### API 设计
**下载 Excel 模板**
```
GET /admin/bulk-purchases/template
**前端静态 Excel 模板**
模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为:
```text
资产类型 | 资产标识 | 套餐编码 | 套餐名称
```
`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
**上传并提交**
```
POST /admin/bulk-purchases
```text
POST /api/admin/bulk-purchases
Content-Type: multipart/form-data
字段:
shop_id: 123
payment_method: offline | agent_wallet
voucher_keys: ["key1","key2"] (offline 时必填)
file: <Excel文件>
shop_id: 123
payment_method: offline | agent_wallet
voucher_keys: ["key1","key2"]
file: <Excel文件>
```
`offline``voucher_keys` 必填,`agent_wallet` 时忽略该字段。
**查询任务状态**
```text
GET /api/admin/bulk-purchases/{task_id}
```
GET /admin/bulk-purchases/{task_id}
**分页查询任务明细**
```text
GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
```
### 前端技术方案
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。
- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。
- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。
- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。
- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。
- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。
---
## 需求20退款审批
@@ -265,32 +333,137 @@ GET /admin/bulk-purchases/{task_id}
1=待审批 2=已通过 3=已拒绝 4=已退回
```
退款单本身的状态含义不变,审批进度由 `tb_approval_flow` 管理,两者通过 `approval_flow_id` 关联。
退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态:
```text
processing_status0=待处理 1=处理中 2=已完成 3=处理失败
```
`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者。
### 数据库变更
```sql
ALTER TABLE tb_refund_request
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_operator_id BIGINT,
ADD COLUMN manual_refund_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_remark TEXT NOT NULL DEFAULT '',
ADD COLUMN manual_refund_voucher_key JSONB NOT NULL DEFAULT '[]';
CREATE INDEX idx_refund_request_approval_instance
ON tb_refund_request(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
`processing_error` 只保存可运维排查的摘要。`manual_refund_*` 只在人工退款确认时写入,退款申请时提交的 `refund_voucher_key` 仍是申请业务资料,不能混用为财务完成凭证。
现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0且不超过申请退款金额和订单实收金额并写入退款单和审批操作日志。会签需要指定金额决策人时流程定义增加其专属最终节点禁止第一位会签人预先锁定金额。
### 流程
```
提交退款申请POST /admin/refund-requests
→ 创建 RefundRequeststatus=1 待审批)
→ 调 SubmitApprovalUseCase 创建 ApprovalFlowbiz_type=refund
→ 把 approval_flow_id 写入 RefundRequest
```mermaid
sequenceDiagram
actor Applicant as 提交人
actor Approver as 审批人
participant Refund as Refund Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as AgentWalletRefundHandler
actor Finance as 财务人员
审批人操作(POST /admin/approvals/{flow_id}/approve|reject|return
→ ApprovalCompletedEvent → RefundRequest.status=2已通过→ 触发实际退款
→ ApprovalRejectedEvent → RefundRequest.status=3已拒绝
ApprovalReturnedEvent → RefundRequest.status=4已退回→ 通知提交人可修改
Applicant->>Refund: POST /api/admin/refunds
Refund->>DB: 同事务创建退款单(status=1)
Refund->>Approval: StartProcess(refund, refund_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Refund->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额
Approval->>DB: 提交 ProcessApproved/Rejected/Returned
alt 代理钱包支付且审批通过
DB-->>Worker: Outbox + Asynq 至少一次投递
Worker->>DB: claim processing_status=1status=2
Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水
Worker->>DB: processing_status=2
else 非代理钱包支付且审批通过
Refund->>DB: status=2, processing_status=0待人工退款
Finance->>Refund: POST manual-complete
Refund->>DB: 条件更新处理状态并记录确认信息
else 审批拒绝
Worker->>DB: status 从 1 更新为 3
else 退回修改
Worker->>DB: status 从 1 更新为 4
end
```
`AgentWalletRefundHandler` 仅处理代理钱包订单,使用 `refund:{refund_id}` 作为业务幂等键,并读取审批事务已经持久化的 `approved_refund_amount`。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 `processing_status=3` 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。
处理器通过条件更新领取任务:`processing_status IN (0,3)`,或状态为处理中但 `processing_started_at` 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。
本期不调用第三方退款 API也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。
人工退款确认接口:
```text
POST /api/admin/refunds/{id}/manual-complete
```
请求包含 `request_id`、可选 `remark` 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 `status=2 AND processing_status IN (0,3)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。
### 退回后重新提交
```
PUT /admin/refund-requests/{id} 仅 status=4 时可编辑退款单内容
POST /admin/refund-requests/{id}/resubmit
POST /api/admin/refunds/{id}/resubmit
→ 校验 status=4
新建 ApprovalFlow
更新 approval_flow_idstatus 回到 1待审批
请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason
在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待审批
→ processing_status 重置为 0清空本次处理错误和人工确认记录
→ 旧审批实例保留为历史记录
```
复用当前真实路由 `POST /api/admin/refunds/{id}/resubmit`,不新增单独 `PUT`。重提命令沿用现有 `ResubmitRefundRequest` 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。
### API 响应与前端
退款列表和详情增加:
```json
{
"approval_instance_id": 1001,
"approval_source": "workflow",
"approval_status": 2,
"approval_status_name": "已通过",
"current_node_name": "",
"processing_status": 0,
"processing_status_name": "待人工退款",
"processing_error": ""
}
```
前端展示规则:
| 审批状态 | 处理状态 | 展示 |
|----------|----------|------|
| 审批中 | 待处理 | 待审批 + 当前节点 |
| 已通过 + 代理钱包 | 处理中 | 审批已通过,代理钱包回退处理中 |
| 已通过 + 非代理钱包 | 待处理 | 审批已通过,待人工退款;财务可确认完成 |
| 已通过 | 已完成 | 退款已完成 |
| 已通过 + 代理钱包 | 处理失败 | 系统重试中;管理员可查看错误摘要 |
| 已拒绝 | 待处理 | 已拒绝 + 原因 |
| 已退回 | 待处理 | 已退回,可编辑并重新提交 |
列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 `available_actions` 渲染通过、驳回和退回操作。
决策节点根据 `action_form` 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。
停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 `task_id`,避免绕过审批人快照、并发控制和操作日志。
维护窗口内需要为 `status=1 AND approval_instance_id IS NULL` 的存量退款单执行幂等回填,从 `refund_approval` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。
---
## 需求21充值审核流程
@@ -301,48 +474,114 @@ POST /admin/refund-requests/{id}/resubmit
tb_agent_recharge_record1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
```
员工线下充值走审批流,在现有状态基础上**新增"已退回"状态**
代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流,在现有状态基础上追加 `7=已退回`
```sql
-- status 说明更新(不改原有值语义,追加新状态)
-- 6=已退回(审批人退回给提交人修改)
-- 现有1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回
-- 新增7=已退回
ALTER TABLE tb_agent_recharge_record
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT '';
CREATE INDEX idx_agent_recharge_approval_instance
ON tb_agent_recharge_record(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
充值业务的状态语义:
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 approval_flow_id 判断是否在审批中
- `2=已支付`支付成功(线下充值审批通过后跳到已完成,不经过此状态)
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 `approval_instance_id` 查询审批状态
- `2=已支付`在线支付已确认,或线下充值审批通过后正在执行钱包入账
- `3=已完成`:充值到账
- `4=已关闭`:取消/超时
- `5=已退款`:退款
- `6=已退回`:审批人退回给提交人修改
- `6=已回`:审批流程拒绝
- `7=已退回`:审批人退回给提交人修改
`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。
充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。
### 流程
**代理自行充值(不走审批)**
```
代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包
```mermaid
flowchart LR
A[代理提交充值申请] --> B[系统生成收款码]
B --> C[代理扫码支付]
C --> D[支付回调幂等入账]
```
**员工线下代充值(走审批)**
```
POST /admin/agent-recharge-recordspayment_method=offline
→ 创建 AgentRechargeRecordstatus=1
→ 调 SubmitApprovalUseCase 创建 ApprovalFlowbiz_type=recharge
→ 把 approval_flow_id 写入记录
现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。
审批通过 → AgentRechargeRecord.status=3已完成→ 实际充值到钱包
审批驳回 → AgentRechargeRecord.status=4已关闭
审批退回 → AgentRechargeRecord.status=6已退回→ 通知提交人可修改
```mermaid
sequenceDiagram
actor Staff as 平台员工
actor Approver as 审批人
participant Recharge as Recharge Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as RechargeApprovalHandler
Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline)
Recharge->>DB: 同事务创建充值单(status=1)
Recharge->>Approval: StartProcess(recharge, recharge_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Recharge->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批
DB-->>Worker: 投递流程结果事件
alt 审批通过
Worker->>DB: status 从 1 更新为 2processing_status=1
Worker->>DB: 幂等增加钱包余额并写流水
Worker->>DB: status 从 2 更新为 3processing_status=2
else 审批拒绝
Worker->>DB: status 从 1 更新为 6写 rejection_reason
else 退回修改
Worker->>DB: status 从 1 更新为 7写 return_reason
end
```
充值接口独立返回审批状态和业务处理状态。`RechargeApprovalHandler` 使用 `recharge:{recharge_no}` 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。
充值处理同样使用 `processing_started_at` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。
### 退回后重新提交
```
PUT /admin/agent-recharge-records/{id} 仅 status=6 时可编辑
POST /admin/agent-recharge-records/{id}/resubmit
校验 status=6
新建 ApprovalFlow
→ 更新 approval_flow_idstatus 回到 1待支付/待审批)
POST /api/admin/agent-recharges/{id}/resubmit
→ 校验 status=7
请求体可修改 amount、payment_voucher_key、remark
在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待支付/待审批)
→ processing_status 重置为 0清空处理错误和 return_reason
→ 旧审批实例保留为历史记录
```
新增 `resubmit` 路由时沿用现有 `/api/admin/agent-recharges` 资源名,不另建 `/agent-recharge-records` 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。
### 停机切换
现有 `POST /api/admin/agent-recharges/{id}/offline-pay``POST /api/admin/agent-recharges/{id}/reject` 都会绕过通用审批任务,本次不保留兼容窗口:
1. 发布前进入维护模式,停止创建和处理线下充值。
2. 执行审批关联字段迁移,同时发布新 API、Worker 和前端。
3. 初始化并启用 `recharge → recharge_approval` 绑定。
4. 为存量“平台员工创建 + 线下支付 + 尚未入账”的充值记录幂等创建流程实例,代理在线充值不回填审批。
5. 新前端创建线下充值后直接进入审批详情,不再展示“确认线下充值”按钮。
6. 新版本不注册 `offline-pay` 和业务单级 `reject` 路由;线下充值只能由 `ProcessApproved` 事件触发幂等入账,驳回统一由任务级审批接口产生 `ProcessRejected`
7. 验证审批通过、驳回、退回、处理失败重试和钱包流水后再解除维护模式。
### 前端技术方案
- 代理自行充值保留现有收款码和支付状态页面,不显示审批信息。
- 平台员工选择 `offline` 时,提交成功进入充值详情并展示审批时间线。
- `status=6` 展示“已驳回”,`status=7` 展示“已退回”;两者按钮不同,只有已退回可编辑和重新提交。
- 审批通过但 `processing_status=1` 时显示“充值处理中”;状态为 3 且处理成功后才显示最新钱包余额。
- `processing_status=3` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。

View File

@@ -1,264 +1,327 @@
# 需求17信用额度
> DDD 结构:`internal/domain/wallet/`
> 状态:待评审
> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query
> 关联需求BPO-009~012、需求19批量订购
---
## 业务规则
## 一、评审结论
### 两类主体
代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。
| 主体 | 信用额度来源 | 修改条件 |
|------|------------|---------|
| 代理(店铺) | 创建店铺时设置 `credit_limit` | 修改前必须先清零欠款(`balance >= 0`|
| 平台员工 | 基于**角色**,不是账号 | 角色权限表里配置 |
平台员工信用额度不实施,原因如下:
### 可用余额公式
- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。
- 角色表达权限范围,不表达资产、余额和债务,给角色配置资金会混淆 RBAC 与财务账本。
- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。
- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。
- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。
```
可用额度 = balance + credit_limit - frozen_balance
```
- `balance` 可以为负(表示欠款)
- `balance < 0` 时表示已在使用信用额度
- `balance < -credit_limit` 表示超额使用(理论上不可达,系统保护)
### BPO-009 "是否可授权额度"开关
- 代理创建时,新增 `enable_credit` 开关
- 开关关闭 = `credit_limit` 强制为 0不允许使用信用额度
- 平台员工账号通过角色自动拥有额度,无需开关
### BPO-012 负余额显示
- 代理前台AgentWallet`balance` 正常展示(可为负)
- 前端不做"不能为负"的前端校验,由后端控制
因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。
---
## 数据库变更
## 二、已确认范围:代理主钱包授信
### 1. Shop 表加信用额度字段
### 2.1 业务规则
```sql
ALTER TABLE tb_shop
ADD COLUMN enable_credit BOOLEAN NOT NULL DEFAULT FALSE
COMMENT '是否可使用信用额度',
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
COMMENT '授信额度上限(分)';
- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。
- 可用金额:`balance - frozen_balance + effective_credit_limit`
- `credit_enabled=false` 时,`effective_credit_limit=0`
- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`
- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。
- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。
- 金额统一使用分,禁止浮点数入库。
```mermaid
flowchart TD
Debit[请求扣款] --> Lock[按钱包ID和version加载]
Lock --> Calc[计算 balance - frozen + effective_credit]
Calc --> Enough{可用金额足够?}
Enough -->|否| Reject[拒绝:可用余额不足]
Enough -->|是| Update[条件更新余额和version]
Update --> Tx[同事务写资金流水]
Tx --> Success[返回扣款后余额]
```
### 2. AgentWallet 表加信用额度字段
### 2.2 信用开关
- 创建代理时可以提交 `enable_credit``credit_limit`
- 关闭开关时额度必须为 0。
- 打开开关时额度必须大于 0。
- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`
---
## 三、数据库变更
信用额度属于钱包,不在 `tb_shop``tb_agent_wallet` 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。
```sql
ALTER TABLE tb_agent_wallet
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
COMMENT '信用额度(分),与 shop.credit_limit 同步,余额可到 -credit_limit';
ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效';
COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效';
ALTER TABLE tb_agent_wallet
DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance;
ALTER TABLE tb_agent_wallet
ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative
CHECK (frozen_balance >= 0),
ADD CONSTRAINT chk_agent_wallet_credit_nonnegative
CHECK (credit_limit >= 0),
ADD CONSTRAINT chk_agent_wallet_available_nonnegative
CHECK (
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0
);
```
> 钱包上冗余一份,是为了避免每次扣款都要 join shop 表。通过 Shop 修改额度时同步更新钱包
### 3. 角色信用额度配置(平台员工)
```sql
ALTER TABLE tb_role
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
COMMENT '该角色的信用额度(分),仅对平台员工角色有效';
```
必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变
---
## Model 变更
## 四、领域模型
```go
// internal/model/shop.go
type Shop struct {
// ...原有字段...
EnableCredit bool `gorm:"column:enable_credit;not null;default:false;comment:是否可使用信用额度" json:"enable_credit"`
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:授信额度上限(分)" json:"credit_limit"`
}
// internal/model/agent_wallet.go
// AgentWallet 代理主钱包聚合根
type AgentWallet struct {
// ...原有字段Balance, FrozenBalance 已存在)...
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:信用额度(分)" json:"credit_limit"`
ID uint
WalletType string
Balance int64
FrozenBalance int64
CreditEnabled bool
CreditLimit int64
Version int64
}
// GetAvailableBalance 更新:可用余额 = 余额 - 冻结 + 信用额度
func (w *AgentWallet) GetAvailableBalance() int64 {
return w.Balance - w.FrozenBalance + w.CreditLimit
}
```
---
## 领域层DDD
```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根(扩展现有模型)
type AgentWalletAggregate struct {
model.AgentWallet
domainEvents []DomainEvent `gorm:"-"`
}
// Debit 扣款(含信用额度)
func (w *AgentWalletAggregate) Debit(amountCents int64, refType string, refID uint) error {
available := w.Balance - w.FrozenBalance + w.CreditLimit
if available < amountCents {
return ErrInsufficientBalance
// AvailableBalance 返回当前可用金额。
func (w *AgentWallet) AvailableBalance() int64 {
credit := int64(0)
if w.CreditEnabled {
credit = w.CreditLimit
}
before := w.Balance
w.Balance -= amountCents
w.recordEvent(WalletDebitedEvent{
WalletID: w.ID,
ShopID: w.ShopID,
Amount: amountCents,
BalanceBefore: before,
BalanceAfter: w.Balance,
RefType: refType,
RefID: refID,
})
return w.Balance - w.FrozenBalance + credit
}
// Debit 执行钱包扣款并维护信用边界。
func (w *AgentWallet) Debit(amount int64) error {
if amount <= 0 {
return ErrInvalidAmount
}
if w.AvailableBalance() < amount {
return ErrInsufficientAvailableBalance
}
w.Balance -= amount
return nil
}
// ChangeCredit 修改信用配置。
func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error {
if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) {
return ErrInvalidCreditConfig
}
nextCredit := int64(0)
if enabled {
nextCredit = limit
}
if w.Balance-w.FrozenBalance+nextCredit < 0 {
return ErrCreditLimitBelowDebt
}
w.CreditEnabled = enabled
w.CreditLimit = limit
return nil
}
```
领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。
---
## 应用层
## 五、应用用例与持久化
### 5.1 修改代理信用额度
```text
Handler
→ ChangeShopCreditUseCase
→ 校验操作人和代理数据权限
→ 加载代理主钱包
→ 调用 wallet.ChangeCredit()
→ 按 version 条件更新钱包
→ 写操作日志和信用变更流水
```
条件更新示例:
```sql
UPDATE tb_agent_wallet
SET credit_enabled = ?,
credit_limit = ?,
version = version + 1,
updated_at = NOW()
WHERE id = ?
AND wallet_type = 'main'
AND version = ?
AND balance - frozen_balance +
CASE WHEN ? THEN ? ELSE 0 END >= 0;
```
受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。
### 5.2 钱包扣款
所有现有主钱包扣款语句必须从:
```text
balance - frozen_balance >= amount
```
统一改为:
```text
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount
```
扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。
### 5.3 受影响用例
- 后台订单和批量订购的代理钱包支付。
- C端/代理端使用代理主钱包的订单支付。
- 钱包冻结与解冻。
- 退款回充和员工线下代充值。
- 资金概况、钱包详情和导出 Query。
实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。
---
## 六、查询方案
资金概况、钱包详情、列表和导出使用 Query 直接读取:
```sql
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance
```
响应统一增加:
```go
// internal/application/wallet/grant_credit.go
// GrantCreditCommand 授信命令
type GrantCreditCommand struct {
ShopID uint
CreditLimit int64 // 分
OperatorID uint
}
// GrantCreditUseCase 设置代理信用额度
type GrantCreditUseCase struct {
shopStore ShopStore
walletStore WalletStore
txManager TxManager
}
func (uc *GrantCreditUseCase) Execute(ctx context.Context, cmd GrantCreditCommand) error {
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
shop, err := uc.shopStore.GetByID(ctx, cmd.ShopID)
if err != nil { return err }
// 修改信用额度前,必须当前余额 >= 0无欠款
wallet, err := uc.walletStore.GetMainWallet(ctx, cmd.ShopID)
if err != nil { return err }
if wallet.Balance < 0 {
return errors.New(errors.CodeForbidden, "存在欠款,请先清零后再修改信用额度")
}
// 同步更新 shop 和 wallet
if err := uc.shopStore.UpdateCreditLimit(ctx, tx, cmd.ShopID, cmd.CreditLimit); err != nil {
return err
}
return uc.walletStore.UpdateCreditLimit(ctx, tx, wallet.ID, cmd.CreditLimit)
})
}
CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"`
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"`
IsInDebt bool `json:"is_in_debt" description:"余额是否为负"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"`
```
`debt_amount = max(-balance, 0)`,不包含冻结金额。
---
## API 设计
## 七、API 设计
### 1. 创建店铺(新增字段)
### 7.1 创建代理
```
POST /admin/shops
现有:
```text
POST /api/admin/shops
```
新增参数:
```go
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit int64 `json:"credit_limit" description:"信额度(分)enable_credit=true 时有效"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信额度(分)"`
```
### 2. 修改信用额度
Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。
```
PUT /admin/shops/{id}/credit-limit
### 7.2 修改信用额度
```text
PUT /api/admin/shops/{id}/credit-limit
```
```go
type UpdateCreditLimitRequest struct {
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新授信额度(分),修改前需余额>=0"`
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`
Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"`
}
```
错误场景:余额为负时,返回 `CodeForbidden`msg="存在欠款(-XX元请先清零后再修改信用额度"
### 7.3 查询展示
### 3. 角色信用额度配置(平台员工)
```
PUT /admin/roles/{id}/credit-limit
```
```go
type UpdateRoleCreditLimitRequest struct {
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"该角色的信用额度(分)"`
}
```
### 4. 查询钱包(余额展示更新)
现有 `GET /admin/agent-wallets/{shopID}` 响应新增字段:
```go
type AgentWalletResponse struct {
// ...原有字段...
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用余额 = balance - frozen + credit_limit可为负"`
IsInDebt bool `json:"is_in_debt" description:"是否有欠款balance < 0"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额balance<0时有值"`
}
```
现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。
---
## 前端对接
## 八、前端技术方案
### 页面:创建/编辑代理
### 8.1 创建/编辑代理
新增"信用额度"区块:
```
信用额度
[☑ 开启信用额度]
授信上限:[____] 元 enable_credit=true 时展示)
```text
信用额度
[开关] 允许使用信用额度
授信上限 [金额输入,单位元]
```
提交时:`enable_credit: true, credit_limit: 100000`(单位:分,前端转换)
- 开关关闭时清空输入并提交 `credit_limit=0`
- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
- 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。
### 页面:代理详情 > 钱包信息
### 8.2 资金概况和详情
```
钱包余额:-¥200.00 (红色显示)
```text
账面余额:-¥200.00
冻结金额¥0.00
信用额度¥1,000.00
可用¥800.00
可用¥800.00
```
`is_in_debt=true` 时,余额展示红色,并加"欠款"标签
- `balance < 0` 时账面余额显示欠款样式
- 可用金额以接口值为准,不在前端自行重复计算。
- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。
- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。
### 页面:修改信用额度
平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。
入口:代理详情 > 钱包 > "修改信用额度"按钮
弹框:输入新额度 → `PUT /admin/shops/{id}/credit-limit`
---
如果当前有欠款(`is_in_debt=true`),禁用该按钮,展示"存在欠款,无法修改信用额度"。
## 九、审计与可观测性
### 页面:余额显示(全局)
每次信用配置变更记录:
代理管理列表的余额列:支持显示负数,负数红色标注
- 操作人、店铺、钱包 ID
- 变更前后开关、额度、余额、冻结金额和版本。
- `request_id`、IP、设备信息和时间。
### 平台员工余额(角色信用)
关键日志:
平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。
在批量订购确认页展示可用额度。
- 扣款因信用额度不足被拒绝。
- 钱包 version 冲突。
- 数据库信用边界约束失败。
- 信用额度调整失败和旧值/新值。
资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。
---
## 十、发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加信用字段,历史数据默认关闭。
2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。
3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。
4. 系统开放后再由有权限的管理员对指定代理逐个开启额度。
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。

View File

@@ -1,14 +1,22 @@
# 需求22套餐临期提醒
> 状态:待评审
---
## 业务规则
### 临期定义
当资产的**当前生效套餐**`PackageUsage.status=1` `expires_at` ≤ 15天后,该资产进入临期状态。
当资产的**当前生效套餐**`PackageUsage.status=1` `Asia/Shanghai` 自然日计算的剩余天数在 `015` 天时,该资产进入临期状态。
**例外**:资产若有**排队中status=0的套餐**,不属于临期,不触发提醒
资产有可接续的排队主套餐时不进入临期提醒因为服务不会在当前套餐到期后中断需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期
### 查询与通知职责
- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。
### 颜色规则Version 2
@@ -27,34 +35,59 @@
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
| **C端公众号** | 套餐到期提醒模块≤15天展示 | 实时展示 |
```mermaid
flowchart TD
Schedule[每日定时任务] --> Query[批量查询实时临期候选]
Query --> QueueCheck{存在排队套餐?}
QueueCheck -->|是| Skip[不进入临期提醒]
QueueCheck -->|否| Days[计算剩余自然日]
Days --> Node{命中 15/7/3 天节点?}
Node -->|否| End[本次不发送]
Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录]
Upsert --> Notification[站内消息]
Upsert -. Phase 2 .-> WeCom[企业微信推送]
```
---
## 数据库变更
无新表。临期数据实时计算,不存储(避免数据陈旧)
临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录
企业客户的**每日临期推送记录**需防重,用一个 key 记录已推送记录
每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知
```sql
-- 临期推送记录(防重)
CREATE TABLE tb_expiry_push_record (
id BIGSERIAL PRIMARY KEY,
asset_type VARCHAR(20) NOT NULL, -- iot_card | device
asset_id BIGINT NOT NULL,
push_node INT NOT NULL, -- 推送节点3/7/15天
push_date DATE NOT NULL, -- 推送日期
pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_expiry_push UNIQUE (asset_type, asset_id, push_node, push_date)
id BIGSERIAL PRIMARY KEY,
package_usage_id BIGINT NOT NULL,
asset_type VARCHAR(20) NOT NULL, -- iot_card | device
asset_id BIGINT NOT NULL,
recipient_id BIGINT NOT NULL,
channel VARCHAR(20) NOT NULL, -- notification | wecom
push_node INT NOT NULL, -- 推送节点3/7/15天
event_id VARCHAR(64) NOT NULL,
pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_expiry_push_idempotency
ON tb_expiry_push_record(
package_usage_id, recipient_id, channel, push_node
);
CREATE UNIQUE INDEX idx_expiry_push_event
ON tb_expiry_push_record(event_id);
```
同一资产可能对应多个业务员,唯一约束必须包含接收人和渠道;否则第一位业务员写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。站内消息和 Phase 2 企业微信都使用该表防重。
---
## 后端实现
### 1. 现有接口新增临期字段
#### 资产列表接口(`GET /admin/iot-cards` / `GET /admin/devices`
#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`
响应新增字段:
```go
@@ -73,17 +106,22 @@ SELECT
ic.*,
CASE
WHEN pu.expires_at IS NULL THEN NULL
WHEN (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date
< (NOW() AT TIME ZONE 'Asia/Shanghai')::date THEN NULL
WHEN EXISTS (
SELECT 1 FROM tb_package_usage q
WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL
) THEN NULL -- 有排队套餐,不临期
ELSE GREATEST(0, EXTRACT(DAY FROM (pu.expires_at - NOW()))::INT)
ELSE (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date
- (NOW() AT TIME ZONE 'Asia/Shanghai')::date
END AS days_until_expiry
FROM tb_iot_card ic
LEFT JOIN tb_package_usage pu
ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL
```
`is_expiring` 由同一 SQL 表达式派生:`days_until_expiry IS NOT NULL AND days_until_expiry BETWEEN 0 AND 15`。设备查询复用同一规则,不能另写一套日期计算。
#### 资产列表筛选条件新增
```go
@@ -107,7 +145,7 @@ IsExpiring bool `json:"is_expiring" description:"是否临期≤15天且
### 2. 新增临期列表接口(独立页面)
```
GET /admin/expiring-assets
GET /api/admin/expiring-assets
```
查询参数:
@@ -140,49 +178,58 @@ type ExpiringAssetItem struct {
}
```
### 3. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧
### 3. 每日定时任务(仅通知
```go
// internal/task/expiry_reminder_handler.go
// HandleExpiryReminder 每日03:00执行,生成临期数据 + 写站内消息
// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
nodes := []int{15, 7, 3}
for _, node := range nodes {
assets, err := h.packageUsageStore.GetExpiringAssets(ctx, node)
if err != nil { return err }
assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15)
if err != nil { return err }
for _, asset := range assets {
// 防重检查(同一资产同一节点今天已推送过)
today := time.Now().Format("2006-01-02")
pushed, _ := h.expiryPushStore.AlreadyPushed(ctx, asset.AssetType, asset.AssetID, node, today)
if pushed { continue }
for _, asset := range assets {
node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3})
if node == 0 {
continue
}
today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化
for _, recipientID := range asset.SalesmanIDs {
eventID := fmt.Sprintf(
"expiry:%d:%d:%d:%d:%s",
asset.PackageUsageID, node, recipientID, asset.AssetID, today,
)
// 获取对应业务员ID企业客户场景
// 此处先写站内消息Phase 2 改为企微推送
h.notifyPublisher.Publish(ctx, notification.SendPayload{
RecipientIDs: asset.SalesmanIDs,
Type: constants.NotifyTypePackageExpiring,
Title: fmt.Sprintf("套餐临期提醒(%d天", node),
Body: fmt.Sprintf("资产 %s 的套餐将在 %d 天后到期", asset.Identifier, node),
RefType: "asset",
RefID: asset.AssetID,
})
// 记录推送防重
h.expiryPushStore.Record(ctx, asset.AssetType, asset.AssetID, node, today)
// 在事务内先写 expiry_push_record再通过 Outbox 发布站内消息。
if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{
EventID: eventID,
RecipientIDs: []uint{recipientID},
RecipientType: constants.NotifyRecipientAdmin,
Type: constants.NotifyTypePackageExpiring,
Title: fmt.Sprintf("套餐临期提醒(%d天节点", node),
Body: fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry),
RefType: asset.AssetType,
RefID: asset.AssetID,
}); err != nil {
return err
}
}
}
return nil
}
```
定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `015` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。
---
## 导出功能(临期列表)
```
GET /admin/expiring-assets/export
使用统一导出任务:
```text
POST /api/admin/export-tasks
scene=expiring_asset
```
导出字段:
@@ -205,7 +252,7 @@ GET /admin/expiring-assets/export
### 公众号首页
现有接口(`GET /app/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse``internal/model/dto/client_asset_dto.go`)新增字段:
现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse``internal/model/dto/client_asset_dto.go`)新增字段:
```go
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数≤15天时有值有排队套餐时为null"`
@@ -248,7 +295,7 @@ IsExpiring bool `json:"is_expiring" description:"是否临期"`
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
操作:导出按钮 → GET /admin/expiring-assets/export
操作:导出按钮 → `POST /api/admin/export-tasks``scene=expiring_asset`
```
### 代理端首页