Compare commits
4 Commits
2e130b98f5
...
d022cc8788
| Author | SHA1 | Date | |
|---|---|---|---|
| d022cc8788 | |||
| bcf3e31db6 | |||
| c4f430ccb3 | |||
| 1a9db9328e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@@ -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
343
CLAUDE.md
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# 7月迭代技术方案总览
|
||||
|
||||
> 讨论日期:2026-07-11
|
||||
> 分支:Iteration/7-11
|
||||
> 系统:junhong_cmp_fiber(已上线,迭代阶段)
|
||||
|
||||
---
|
||||
|
||||
## 阶段划分
|
||||
|
||||
| 阶段 | 范围 | 说明 |
|
||||
|------|------|------|
|
||||
| **Phase 1(本迭代)** | 需求 1-22(除企微对接) | Gateway 接口已有,全部可做 |
|
||||
| **Phase 2(下次迭代)** | 需求 18(APR-009)/22(EXP-003)的企微对接部分 | 仅依赖企业微信审批/推送 API |
|
||||
|
||||
---
|
||||
|
||||
## 需求总览
|
||||
|
||||
### 简单改动(无新表,影响范围小)
|
||||
|
||||
| # | 需求 | 文档 | 实现要点 |
|
||||
|---|------|------|---------|
|
||||
| 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) | 退款/代理充值/换号管理加提交人审批人 |
|
||||
|
||||
### 中等复杂(有新字段或配置机制)
|
||||
|
||||
| # | 需求 | 文档 | 实现要点 |
|
||||
|---|------|------|---------|
|
||||
| 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 判断 + 续费入口 |
|
||||
|
||||
### 复杂新模块(DDD 结构)
|
||||
|
||||
| # | 需求 | 文档 | 实现要点 |
|
||||
|---|------|------|---------|
|
||||
| 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) | 定时任务 + 站内消息 |
|
||||
|
||||
---
|
||||
|
||||
## 基础设施文档
|
||||
|
||||
| 文档 | 说明 | 被哪些需求依赖 |
|
||||
|------|------|--------------|
|
||||
| [DDD规范](./DDD规范.md) | 领域模型设计规范、目录结构、代码示例 | 复杂新模块全部 |
|
||||
| [系统配置](./基础设施/系统配置.md) | tb_system_config + 管理接口 | 需求 2、9 |
|
||||
| [站内消息](./基础设施/站内消息.md) | tb_notification + Asynq 异步分发 | 需求 18、20、21、22 |
|
||||
| [审批流](./基础设施/审批流.md) | 通用多级审批引擎 | 需求 18、20、21 |
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
第一周:基础设施
|
||||
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
|
||||
|
||||
第四周:复杂新模块
|
||||
7. 需求 17(信用额度)
|
||||
8. 需求 16(分销码)
|
||||
9. 需求 18/20/21(审批流对接)
|
||||
10. 需求 19(批量订购)
|
||||
11. 需求 22(临期提醒)
|
||||
```
|
||||
57
docs/7月迭代/7月迭代前后端任务工时表.md
Normal file
57
docs/7月迭代/7月迭代前后端任务工时表.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 7月迭代前后端任务工时表
|
||||
|
||||
> 估算口径:实现工作全权交由 AI 编码代理执行,前后端可并行推进。
|
||||
> 工时单位:人时,1 人日按 8 小时计算。
|
||||
> 包含:代码实现、迁移、接口调整、页面交互、联调修复、手工接口验证和发布准备。
|
||||
> 不包含:等待企微/支付配置、产品临时改需求、外部接口申请审核和生产历史脏数据人工处理时间。
|
||||
> 前提:提供后端仓库、前端仓库、可用数据库、Gateway、企微和支付联调配置;需求范围以标准评审稿为准。
|
||||
>
|
||||
> 禅道逐条录入时,使用[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)中的拆分工时;本表用于核对总量,逐条录入稿用于填写每条研发需求的预计工时。
|
||||
|
||||
|需求|前端任务|后端任务|前端任务工时|后端任务工时|相关风险可能导致的工时延长|
|
||||
|---|---|---|---:|---:|---|
|
||||
|公共基础|补齐公共状态、错误展示和异步任务交互组件|增量迁移、Outbox、DDD 目录和公共幂等能力|1~2小时|4~5小时|现有迁移冲突、Outbox 基础与文档不一致会增加 2~4 小时|
|
||||
|需求01:复机实名规则|调整复机失败提示,不再按行业卡写死文案|按 `realname_link_type` 判断实名要求,删除行业卡统一放行逻辑|0.5~1小时|1小时|运营商历史配置错误会增加数据修复时间|
|
||||
|需求02:H5 流程配置|按有效实名策略渲染先实名/先购买流程,补批量配置交互|统一运营商实名能力和资产顺序策略,补批量更新接口|2~3小时|3~4小时|前端现有 H5 状态机分散、冲突数据较多会增加 2~4 小时|
|
||||
|需求03:联系电话搜索|店铺列表增加联系电话搜索框|店铺 Query 增加 11 位联系电话精确过滤|0.5~1小时|0.5~1小时|现有列表参数命名不统一会增加约 1 小时|
|
||||
|需求04:退款中禁止换货|直接展示后端拦截原因|创建换货前批量校验活跃退款状态|0.5~1小时|1~1.5小时|历史退款状态语义不一致会增加 1~2 小时|
|
||||
|需求05:套餐分配生效条件|增加默认/购买生效/实名生效三选一及恢复默认|增加代理覆盖值和使用记录快照,统一生效条件计算|1.5~2.5小时|2.5~3.5小时|旧使用记录缺快照、套餐周期数据异常会增加 2~4 小时|
|
||||
|需求06/11:预计最终到期时间|资产层只展示预计最终到期时间、推算状态和统一高亮|Query 按当前及排队主套餐时长快照推算,供详情、临期和导出复用|1.5~2.5小时|3~4小时|旧套餐缺购买时长快照、等待实名激活无法确定起点会增加兼容处理时间|
|
||||
|需求07:实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|1~2小时|2~3小时|设备多当前卡、绑定历史异常会增加 2~3 小时|
|
||||
|需求08:设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq,新增两个独立批量命令和失败明细|2~3小时|3~4小时|前端无现成上传任务组件或生产文件格式不统一会增加 2~4 小时|
|
||||
|需求09:C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.5~2.5小时|2~3小时|多端支付入口未复用同一接口会增加逐端排查时间|
|
||||
|需求10:Gateway 限速|卡/设备详情增加设置和取消入口|统一 `speed_kbps` 接口,设备解析当前卡后调用 Gateway|1~2小时|2~3小时|Gateway 取消参数不明确或联调不稳定会增加 2~4 小时|
|
||||
|需求11:当前套餐到期高亮|并入需求06/22,不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时|
|
||||
|需求12:换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|1~2小时|1~2小时|历史数据不回填导致验收口径混淆会增加沟通时间|
|
||||
|需求13:列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|1~2小时|2~3小时|旧记录缺提交人或企微实例会增加兼容展示时间|
|
||||
|需求14:导出与字段权限|角色页配置导出字段,各列表接入统一导出入口|扩展 DataSource 场景、角色字段权限和字段快照|3~4小时|4~6小时|导出字段口径变更、现有数据源 N+1 会增加 3~5 小时|
|
||||
|需求15:下架套餐续费|当前套餐旁增加续费按钮,复用购买流程|统一可售策略,校验资产所有人及历史使用资格|1.5~2.5小时|2~3小时|C 端多个购买入口绕过统一策略会增加排查时间|
|
||||
|需求16:代理分销码与佣金提现|不实施|不实施|0小时|0小时|需求重新加入时必须单独评估,不计入本表|
|
||||
|需求17:代理主钱包信用额度|角色页配置新建店铺默认额度,店铺资金页单独调额并明确不追溯|增加角色默认模板、钱包实际额度、约束、扣款不变量和资金审计|2.5~3.5小时|4~5小时|现有负余额、CHECK 约束或创建店铺事务分散会增加 2~5 小时|
|
||||
|需求18:多人审批映射|不建设本地待办,只展示企微审批摘要|退款/充值场景映射到企微模板,复用企微状态同步|1~1.5小时|1~1.5小时|企微模板审批人配置不完整会阻塞联调|
|
||||
|需求19:批量订购套餐|批量上传、统一支付方式、任务进度和失败明细|任务/明细表、逐行幂等下单、钱包或线下支付处理|3~4小时|4~6小时|订单服务复用困难、钱包并发冲突会增加 3~6 小时|
|
||||
|需求20:退款审批|创建和详情展示企微状态、意见附件及处理结果|退款接入企微、代理钱包回溯、人工退款终态和撤销异常|2.5~3.5小时|4~5小时|存量退款买家类型混乱、通过后撤销场景会增加 3~5 小时|
|
||||
|需求21:平台员工线下充值|创建后展示企微审批状态,删除本地确认/驳回按钮|企微通过后幂等增加代理主钱包,旧接口下线|2~3小时|3~4小时|旧充值状态和钱包流水不一致会增加 2~4 小时|
|
||||
|需求22:套餐临期提醒|资产列表、详情、临期页、代理首页和 C 端展示;仅临期页将3天内置顶|复用预计最终到期 Query、15/7/3 天站内通知防重和导出接入|3~4.5小时|4~6小时|时区、排队套餐快照和多接收人数据异常会增加 2~4 小时|
|
||||
|禅道#98/#86:换货归属与资产标识|换货确认展示继承店铺,资产详情展示前代/后代标签和跳转|新资产继承旧资产店铺、保留旧资产归属、补换货链 Query 和索引|1~2小时|2~3小时|换货迁移涉及的租户标签或资产分配记录不完整会增加 2~4 小时|
|
||||
|禅道#96/#97:业务员与余额提醒|店铺业务员选择/筛选、资金不足100元状态和通知跳转|增加店铺业务员字段,钱包跨越固定100元阈值时向主账号和业务员发站内通知|1~2小时|2~3小时|店铺主账号异常、业务员停用或钱包写入口未统一会增加 2~4 小时|
|
||||
|禅道#43:系列套餐批量授权|套餐表格多选、已授权置灰、三类价格展示|复用现有批量写接口,新增套餐候选 Query 和重复授权幂等|1~2小时|0.5~1小时|前端旧页面结构不支持表格多选或成本价权限不完整会增加 1~3 小时|
|
||||
|新增01:数据同步触发与轮询优化|展示轮询活跃状态和审计跳转|卡状态领域收口、活跃调频、0/3/5 任务、回调防腐层|2~3小时|7~9小时|旧同步入口数量超出预期、Gateway 超频和 ICCID 重复会增加 4~8 小时|
|
||||
|新增02:企业微信审批接入|企微配置、模板映射、扫码绑定、审批运行和业务详情|Token、模板版本、上传提交、回调解密、轮询补偿和异常恢复|5~7小时|8~10小时|企微可信域名、模板 ID 变化、回调网络和真实账号权限会增加 4~8 小时|
|
||||
|新增03:站内通知|顶部铃铛、通知抽屉、通知中心和受控跳转|通知表、模板注册、接收人解析、未读/已读 API|3~4小时|3~4小时|前端多端布局差异、接收人关系不完整会增加 2~3 小时|
|
||||
|新增04:全局多视角审计|审计中心七个视角、详情抽屉和敏感字段展示|Audit Event、Integration Log、旧写入口切换、历史投影和脱敏|6~8小时|8~11小时|旧审计调用点遗漏、查询性能和历史字段差异会增加 5~10 小时|
|
||||
|新增05:代理钱包扫码充值|支付方式选择、二维码、倒计时和支付状态轮询|微信 Native、支付宝 PreCreate、支付单分发和钱包入账恢复|3~4小时|5~7小时|支付渠道配置、真实回调、微信 v2/富友差异会增加 3~6 小时|
|
||||
|全链路联调与发布|联调全部页面状态、修复交互、准备发布版本|数据核对、存量回填、旧入口清理、停机发布和恢复检查|3~4小时|4~5小时|生产数据与预期差异、外部回调不可达会增加 4~8 小时|
|
||||
|**合计**|**前端约 59~89 小时**|**后端约 93~128 小时**|**约 8~12 人日**|**约 12~16 人日**|**1 后端 + 1 前端并行时,正常目标 12~15 个工作日;历史数据或换货迁移超预期时可能到 16 个工作日**|
|
||||
|
||||
## 并行交付口径
|
||||
|
||||
| 项目 | 估算 |
|
||||
|------|------|
|
||||
| 前端投入 | 59~89 小时,约 8~12 人日 |
|
||||
| 后端投入 | 93~128 小时,约 12~16 人日 |
|
||||
| 合计投入 | 152~217 小时,约 19~28 人日 |
|
||||
| 1 后端 + 1 前端并行周期 | 正常 12~15 个工作日,风险上限 16 个工作日 |
|
||||
| 建议对外排期基线 | 14 个工作日 |
|
||||
|
||||
达到第 16 个工作日的主要条件:前端旧授权页面无法复用、换货归属需要补齐多张租户标签表、生产历史数据需要大规模人工修复、企微或支付联调配置不可用。
|
||||
1458
docs/7月迭代/7月迭代技术方案-标准评审稿.md
Normal file
1458
docs/7月迭代/7月迭代技术方案-标准评审稿.md
Normal file
File diff suppressed because it is too large
Load Diff
242
docs/7月迭代/7月迭代禅道研发需求拆分表.md
Normal file
242
docs/7月迭代/7月迭代禅道研发需求拆分表.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 7月迭代禅道研发需求拆分表
|
||||
|
||||
> 来源:禅道用户需求导出与7月迭代标准评审稿。
|
||||
> 范围:只包含激活需求;草稿 #41/#51 和已关闭重复需求 #54 不创建研发需求。
|
||||
>
|
||||
> 本文件用于总览、排期和关联关系,不用于逐条复制。实际录入禅道请使用:[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。录入稿中每条 FE/BE/INT 都有可独立复制的标题和完整描述。
|
||||
|
||||
## 一、拆分规则
|
||||
|
||||
每条用户需求下创建两条研发需求:
|
||||
|
||||
```text
|
||||
[FE][UR#编号] 前端交付名称
|
||||
[BE][UR#编号] 后端交付名称
|
||||
```
|
||||
|
||||
- FE、BE 研发需求分别指派给前端和后端,可并行进入开发。
|
||||
- 研发需求必须描述可独立交付的页面或接口能力,不能只写“配合前端”“配合后端”。
|
||||
- 联调属于研发需求完成后的交付活动,不为每条用户需求机械创建第三条研发需求。
|
||||
- 跨页面、跨模块、异步任务或第三方系统场景,统一创建链路级联调任务。
|
||||
- 简单搜索、字段展示等需求在 FE/BE 研发需求中完成自验,不单独创建联调任务。
|
||||
|
||||
推荐状态依赖:
|
||||
|
||||
```text
|
||||
FE/BE 研发需求开发完成
|
||||
-> 接口契约冻结
|
||||
-> 进入对应联调任务
|
||||
-> 问题回到原 FE/BE 研发需求修复
|
||||
-> 联调任务验收通过
|
||||
```
|
||||
|
||||
## 二、用户需求到研发需求映射
|
||||
|
||||
| 用户需求 | 前端研发需求 | 后端研发需求 | 联调归属 |
|
||||
|---|---|---|---|
|
||||
| #98 换货管理新资产归属 | `[FE][UR#98] 换货新资产归属继承提示`:选择新资产后展示将继承的店铺 | `[BE][UR#98] 换货新资产继承旧资产店铺`:事务内迁移归属、租户标签和分配记录 | INT-02 换货链路 |
|
||||
| #97 代理钱包阈值提醒 | `[FE][UR#97] 代理现金余额不足100元展示与通知跳转` | `[BE][UR#97] 主钱包固定100元余额预警`:跨越阈值、重新布防、站内通知 | INT-05 钱包支付 |
|
||||
| #96 员工作为发展人进行标识 | `[FE][UR#96] 店铺业务员选择、展示和筛选` | `[BE][UR#96] 店铺业务员关联与查询`:只绑定启用平台账号 | INT-05 钱包支付 |
|
||||
| #94 系统状态同步优化以及回调处理 | `[FE][UR#94] 资产同步状态与审计入口展示` | `[BE][UR#94] 资产状态同步DDD收口`:轮询、0/3/5事件、回调防腐层 | INT-01 实名与同步 |
|
||||
| #86 资产详情中换货标识 | `[FE][UR#86] 资产详情前代/后代换货标识与跳转` | `[BE][UR#86] 资产换货链Query`:返回 previous/next asset并校验权限 | INT-02 换货链路 |
|
||||
| #73 行业卡操作停复机 | `[FE][UR#73] 复机实名校验结果与错误提示适配` | `[BE][UR#73] 按运营商实名能力控制复机` | INT-01 实名与同步 |
|
||||
| #62 H5设置先充值后实名 | `[FE][UR#62] H5实名与购买顺序流程`:先实名/先购买/无需实名 | `[BE][UR#62] 资产实名顺序策略与批量配置接口` | INT-01 实名与同步 |
|
||||
| #60 店铺联系电话检索 | `[FE][UR#60] 店铺联系电话搜索控件` | `[BE][UR#60] 店铺联系电话精确查询` | 不单建,FE/BE自验 |
|
||||
| #57 退款中禁止换货 | `[FE][UR#57] 换货退款拦截错误展示` | `[BE][UR#57] 换货前活跃退款校验` | INT-02 换货链路 |
|
||||
| #55 套餐生效条件 | `[FE][UR#55] 套餐分配生效条件选择与恢复默认` | `[BE][UR#55] 套餐分配生效条件覆盖与购买快照` | INT-03 套餐生命周期 |
|
||||
| #53 资产实名状态筛选 | `[FE][UR#53] 卡和设备实名状态筛选` | `[BE][UR#53] 卡/设备实名状态查询与设备快照维护` | INT-01 实名与同步 |
|
||||
| #49 设备批量分配代理和套餐系列 | `[FE][UR#49] 设备批量分配代理/套餐系列双入口` | `[BE][UR#49] 两类设备批量分配任务与失败明细` | INT-04 批量与导出 |
|
||||
| #48 不同资产使用不同支付方式 | `[FE][UR#48] C端按资产展示允许支付方式` | `[BE][UR#48] 支付方式配置与订单二次校验` | INT-05 钱包支付 |
|
||||
| #47 限速规则 | `[FE][UR#47] 卡/设备手动设置与取消限速` | `[BE][UR#47] Gateway按cardNo限速统一接口` | INT-07 Gateway限速 |
|
||||
| #46 资产信息详情字段新增 | `[FE][UR#46] 预计最终到期时间与临期高亮` | `[BE][UR#46] 当前及排队套餐最终到期Query` | INT-03 套餐生命周期 |
|
||||
| #45 换货管理 | `[FE][UR#45] 换货新旧资产展示与独立搜索` | `[BE][UR#45] 换货标识快照修正与新旧资产查询` | INT-02 换货链路 |
|
||||
| #44 列表字段新增 | `[FE][UR#44] 退款/充值/换货提交人与审批摘要展示` | `[BE][UR#44] 提交人快照与企微审批摘要批量查询` | INT-06 企微审批 |
|
||||
| #43 代理系列授权 | `[FE][UR#43] 系列套餐批量选择与已授权置灰` | `[BE][UR#43] 系列套餐候选Query与重复授权幂等` | INT-03 套餐生命周期 |
|
||||
| #42 导出功能 | `[FE][UR#42] 导出字段选择、权限展示和任务进度` | `[BE][UR#42] 导出场景、角色字段权限与30天到期筛选` | INT-04 批量与导出 |
|
||||
| #40 套餐设计 | `[FE][UR#40] C端下架套餐续费入口` | `[BE][UR#40] 下架套餐历史用户续费资格校验` | INT-03 套餐生命周期 |
|
||||
| #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 |
|
||||
| #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 |
|
||||
| #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 |
|
||||
| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态、人工退款和代理钱包回溯` | INT-06 企微审批 |
|
||||
| #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 |
|
||||
| #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 |
|
||||
|
||||
## 三、前后端并行约定
|
||||
|
||||
研发需求创建前先冻结本表中的接口契约。前端按 Mock 数据开发页面,后端按同一契约实现接口。
|
||||
|
||||
- 响应统一为 `{code,msg,data,timestamp}`,下表只描述 `data`。
|
||||
- 分页统一返回 `{items,total,page,size}`。
|
||||
- 金额入参和返回均使用分;前端显示元。
|
||||
- 生命周期判断使用 `status`,展示使用 `status_name`。
|
||||
- 字段允许增加但不能改名或改变类型;确需调整时,必须同步修改 FE、BE 两条研发需求。
|
||||
- 前端先完成页面、交互、Mock、加载/空/错误状态;后端完成后只替换数据源并进入联调。
|
||||
|
||||
每组需求按以下顺序启动:
|
||||
|
||||
```text
|
||||
1. FE/BE共同确认路径、方法、入参、返回和枚举
|
||||
2. 后端先补DTO/OpenAPI契约或提供固定JSON示例
|
||||
3. 前端按JSON示例完成页面和Mock请求
|
||||
4. 后端实现真实查询、写入、权限、幂等和审计
|
||||
5. 前端切换真实接口,进入对应INT联调任务
|
||||
```
|
||||
|
||||
契约确认时必须标记字段是必返、可空还是仅特定状态返回,避免前端通过猜测补默认值。
|
||||
|
||||
## 四、研发需求压缩总览
|
||||
|
||||
> 下表仅用于快速核对,不建议复制到禅道。前端页面结构、交互状态、接口入参和返回字段以[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)为准。
|
||||
|
||||
| 用户需求 | 前端研发需求说明 | 后端研发需求与接口契约 |
|
||||
|---|---|---|
|
||||
| #98 换货新资产归属 | **标题:**换货新资产归属继承提示。<br>**页面:**换货创建、发货确认。选择新资产后展示“完成后归属到某店铺”,提交中禁止重复操作,失败展示后端原因。 | **标题:**换货新资产继承旧资产店铺。<br>**接口:**复用 `POST /api/admin/exchanges`、`POST /api/admin/exchanges/{id}/ship`、`POST /api/admin/exchanges/{id}/complete`。<br>**入参:**旧/新资产标识、换货类型、是否迁移资料。<br>**返回:**换货单及 `inherited_shop_id/inherited_shop_name`。<br>**规则:**平台库存新资产继承旧店铺;其他店铺资产拒绝。 |
|
||||
| #97 钱包100元预警 | **标题:**代理现金余额不足100元展示。<br>**页面:**资金概况显示红色预警;顶部通知和通知中心支持跳转店铺资金页,不提供阈值配置。 | **标题:**代理主钱包固定100元余额预警。<br>**接口:**`GET /api/admin/shops/fund-summary` 增加 `cash_available/low_balance_warning`;通知复用 `/api/admin/notifications`。<br>**规则:**`balance-frozen_balance<=10000`,不含信用额度;只在跨越阈值时通知,回升后重新布防。 |
|
||||
| #96 店铺业务员 | **标题:**店铺业务员选择、展示与筛选。<br>**页面:**店铺新建/编辑增加平台业务员下拉,列表和详情展示业务员,筛选栏支持按业务员查询。 | **标题:**店铺业务员关联与查询。<br>**接口:**`POST /api/admin/shops`、`PUT /api/admin/shops/{id}` 入参增加 `business_owner_account_id`;`GET /api/admin/shops` 增加同名筛选。<br>**返回:**`business_owner_account_id/business_owner_name`。只允许启用的平台账号。 |
|
||||
| #94 状态同步优化 | **标题:**资产同步状态与审计入口。<br>**页面:**资产详情展示活跃级别、最后活跃、下次轮询;保留原刷新按钮,增加“查看同步轨迹”跳转。 | **标题:**资产状态同步DDD收口。<br>**接口:**不新增刷新入口,复用 `POST /api/admin/assets/{identifier}/refresh`;`GET /api/admin/assets/resolve/{identifier}` 返回 `polling`;轨迹使用 `GET /api/admin/audit/integrations`。<br>**规则:**轮询、0/3/5事件和回调统一应用状态。 |
|
||||
| #86 资产换货标识 | **标题:**资产详情换货前代/后代展示。<br>**页面:**显示“换货新资产”“已换出旧资产”标签,可查看前代和后代;无权限时不可点击。 | **标题:**资产换货链Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}` 增加 `exchange_trace.previous_asset/next_asset`。<br>**返回:**资产类型、ID、标识、换货单号和 `can_view`。 |
|
||||
| #73 行业卡复机 | **标题:**复机实名结果和错误提示。<br>**页面:**沿用资产详情复机按钮;未满足实名条件时展示后端中文原因。 | **标题:**按运营商实名能力控制复机。<br>**接口:**复用 `POST /api/admin/assets/{identifier}/start`。<br>**返回:**最新资产状态。<br>**规则:**只看运营商 `realname_link_type`,不按行业卡类型统一放行。 |
|
||||
| #62 H5实名顺序 | **标题:**H5先实名/先购买流程及后台批量配置。<br>**页面:**C端按接口策略跳转;后台卡和设备列表提供批量修改入口。 | **标题:**资产实名顺序策略。<br>**接口:**`PATCH /api/admin/assets/{identifier}/realname-mode`;`POST /api/admin/iot-cards/batch-update-realname-policy`;`POST /api/admin/devices/batch-update-realname-policy`。<br>**入参:**资产标识列表、`policy=none/before_order/after_order`。<br>**返回:**生效策略及成功数量。 |
|
||||
| #60 联系电话搜索 | **标题:**店铺联系电话搜索。<br>**页面:**店铺列表新增11位联系电话输入框,支持清空和重新查询。 | **标题:**店铺联系电话精确查询。<br>**接口:**`GET /api/admin/shops?contact_phone=`。<br>**入参:**11位手机号。<br>**返回:**原店铺分页结构。 |
|
||||
| #57 退款中禁止换货 | **标题:**换货退款拦截提示。<br>**页面:**创建换货失败时原地展示“该资产存在退款申请”,不自行判断退款状态。 | **标题:**换货前活跃退款校验。<br>**接口:**复用 `POST /api/admin/exchanges`。<br>**规则:**审批中、已退回或退款处理未完成时返回业务错误;已拒绝、撤销或处理完成后放行。 |
|
||||
| #55 套餐生效条件 | **标题:**套餐分配生效条件选择。<br>**页面:**授权/分配弹框提供跟随默认、购买生效、实名生效三选一;已分配记录支持修改和恢复默认。 | **标题:**套餐分配生效条件覆盖与快照。<br>**接口:**`POST /api/admin/shop-package-allocations`;`PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。<br>**入参:**`expiry_base_override`,`null` 表示恢复默认。<br>**返回:**默认值、覆盖值和最终生效值。 |
|
||||
| #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。<br>**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。<br>**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1`;`GET /api/admin/devices?real_name_status=0\|1`。<br>**返回:**`real_name_status/real_name_status_name`。 |
|
||||
| #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。<br>**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。<br>**接口:**`POST /api/admin/devices/batch-assign-shop`、`POST /api/admin/devices/batch-assign-series`、`GET /api/admin/devices/batch-allocation/{task_id}`。<br>**入参:**文件和目标ID。<br>**返回:**任务状态及失败明细。 |
|
||||
| #48 支付方式限制 | **标题:**C端按资产展示支付方式。<br>**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。<br>**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods`;`POST /api/c/v1/orders/create`、`POST /api/c/v1/orders/{id}/pay` 再次校验。<br>**后台:**`PUT /api/admin/system/config/{config_key}`。 |
|
||||
| #47 Gateway限速 | **标题:**卡和设备手动限速。<br>**页面:**详情页提供设置、取消入口;设备显示最终使用的当前卡ICCID。 | **标题:**Gateway按cardNo统一限速。<br>**接口:**`POST /api/admin/assets/{identifier}/speed-limit`。<br>**入参:**`speed_kbps`,0表示取消。<br>**返回:**资产标识、最终 `card_no`、目标值和执行结果。 |
|
||||
| #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。<br>**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}`。<br>**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 |
|
||||
| #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。<br>**页面:**列表分别搜索旧资产、新资产,展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。<br>**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。<br>**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 |
|
||||
| #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。<br>**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。<br>**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。<br>**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 |
|
||||
| #43 系列批量授权 | **标题:**系列套餐批量选择与已授权置灰。<br>**页面:**首次和后续授权共用多选表格;已授权套餐置灰,展示公司成本、授权成本和建议售价。 | **标题:**系列套餐候选和批量授权。<br>**接口:**`GET /api/admin/shop-series-grants/{id}/package-options`;`PUT /api/admin/shop-series-grants/{id}/packages`。<br>**返回:**三类价格、`is_authorized`;重复授权幂等。 |
|
||||
| #42 导出功能 | **标题:**导出字段选择、权限和任务进度。<br>**页面:**导出弹框加载可选字段,提交后展示进度、失败和下载。 | **标题:**统一导出场景和字段权限。<br>**接口:**`GET /api/admin/export-fields?scene=`、`POST /api/admin/export-tasks`、`GET /api/admin/export-tasks/{id}`。<br>**入参:**`scene/format/query/fields`。<br>**返回:**任务进度和下载地址。 |
|
||||
| #40 下架套餐续费 | **标题:**C端当前套餐续费入口。<br>**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。<br>**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason`;`POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 |
|
||||
| #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。<br>**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。<br>**接口:**`PUT /api/admin/roles/{id}/default-credit`、`PUT /api/admin/shops/{id}/credit-limit`、`GET /api/admin/shops/fund-summary`。<br>**入参:**开关、额度、钱包版本。<br>**返回:**额度、可用金额、欠款和版本。 |
|
||||
| #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。<br>**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。<br>**接口:**`GET /api/admin/wecom/status`、`POST /api/admin/wecom/account-binding/sessions`、`GET /api/admin/wecom/approvals`、`POST /api/admin/wecom/approvals/{id}/sync`。<br>**业务详情:**统一返回 `approval` 对象。 |
|
||||
| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**选择代理和整批支付方式,上传Excel及线下凭证,展示部分成功。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/bulk-purchases`、`GET /api/admin/bulk-purchases/{task_id}`、`GET /api/admin/bulk-purchases/{task_id}/items`。<br>**入参:**代理、支付方式、文件、凭证。<br>**返回:**任务和逐行结果。 |
|
||||
| #35 退款审核 | **标题:**退款企微审批和退款处理状态。<br>**页面:**创建时上传备注附件;详情展示审批、人工退款说明和业务处理结果。 | **标题:**退款企微终态处理。<br>**接口:**`POST /api/admin/refunds`、`GET /api/admin/refunds/{id}`、`POST /api/admin/refunds/{id}/resubmit`。<br>**返回:**退款数据、`approval` 和 `processing_status`。代理钱包通过后幂等回溯。 |
|
||||
| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码和支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods`、`POST /api/admin/agent-recharges`、`GET /api/admin/agent-recharges/{id}/payment-status`、`GET /api/admin/agent-recharges/{id}`。<br>**返回:**二维码、过期时间、支付/审批/入账状态。 |
|
||||
| #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。<br>**页面:**临期页3天内置顶;普通资产列表只高亮;代理首页显示数量;C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。<br>**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。<br>**返回:**最终到期、剩余天数、颜色节点;15/7/3天通知防重。 |
|
||||
|
||||
## 五、前端Mock公共样例
|
||||
|
||||
资产详情扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"estimated_final_expires_at": "2026-08-01T23:59:59+08:00",
|
||||
"days_until_final_expiry": 15,
|
||||
"expiry_estimate_status": "exact",
|
||||
"is_expiring": true,
|
||||
"allowed_payment_methods": ["alipay", "wallet"],
|
||||
"polling": {
|
||||
"enabled": true,
|
||||
"activity_level": "active",
|
||||
"last_activity_at": "2026-07-17T10:00:00+08:00",
|
||||
"next_poll_at": "2026-07-17T10:03:00+08:00"
|
||||
},
|
||||
"exchange_trace": {
|
||||
"previous_asset": null,
|
||||
"next_asset": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
统一异步任务:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": 1001,
|
||||
"status": 2,
|
||||
"status_name": "处理中",
|
||||
"total_count": 100,
|
||||
"success_count": 60,
|
||||
"failed_count": 3,
|
||||
"failed_items": []
|
||||
}
|
||||
```
|
||||
|
||||
代理资金概况:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 8000,
|
||||
"frozen_balance": 0,
|
||||
"cash_available": 8000,
|
||||
"credit_enabled": true,
|
||||
"credit_limit": 100000,
|
||||
"available_balance": 108000,
|
||||
"low_balance_warning": true,
|
||||
"version": 3
|
||||
}
|
||||
```
|
||||
|
||||
企微审批摘要:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "wecom",
|
||||
"instance_id": 123,
|
||||
"sp_no": "202607170001",
|
||||
"status": 1,
|
||||
"status_name": "审批中",
|
||||
"current_approver_summary": "财务审批",
|
||||
"approvers": [],
|
||||
"attachments": [],
|
||||
"business_process_result": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
## 六、联调任务拆分
|
||||
|
||||
联调项建议创建为项目执行任务,而不是研发需求。每个任务可关联多条 FE/BE 研发需求和用户需求。
|
||||
|
||||
如果团队流程强制要求联调也必须使用“研发需求”类型,则先新增一条技术用户需求“7月迭代跨模块联调与发布验收”,再将下列 INT-01~08 建成它的子研发需求。不要把同一条联调需求重复挂到每个业务用户需求下。
|
||||
|
||||
| 编号 | 联调任务名称 | 关联用户需求 | 参与工时 | 进入条件 | 主要验收链路 |
|
||||
|---|---|---|---|---|---|
|
||||
| INT-01 | 资产实名、复机与状态同步联调 | #94、#73、#62、#53 | FE 1~1.5h / BE 1~1.5h,已含 | 实名策略接口、同步任务和H5页面完成 | 不同运营商实名能力、先实名/先购买、0/3/5同步、回调后状态和筛选一致 |
|
||||
| INT-02 | 换货完整链路联调 | #98、#86、#57、#45 | FE 0.5~1h / BE 0.5~1h,已含 | 换货接口、详情和列表页面完成 | 退款拦截、新资产继承店铺、完成换货、列表搜索、前代/后代跳转 |
|
||||
| INT-03 | 套餐授权、购买、续费、到期与临期联调 | #55、#46、#43、#40、#33 | FE 1~1.5h / BE 1~1.5h,已含 | 套餐快照、授权候选和各端到期字段完成 | 批量授权、购买生效条件、下架续费、排队套餐最终到期、临期高亮和通知 |
|
||||
| INT-04 | Excel批量任务与导出联调 | #49、#36、#42 | FE 1~1.5h / BE 1~1.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 |
|
||||
| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48、#38、#34、#36、#96、#97 | FE 1~1.5h / BE 1~1.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 |
|
||||
| INT-06 | 企业微信审批、退款和线下充值联调 | #37、#35、#34、#44 | FE 1.5~2h / BE 1.5~2h,已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 |
|
||||
| INT-07 | Gateway卡限速联调 | #47 | FE 0.5~1h / BE 0.5~1h,已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 |
|
||||
| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求 | FE 3~4h / BE 4~5h,额外 | INT-01~07完成 | 权限、通知、审计、历史数据、旧入口关闭、Worker恢复和发布检查 |
|
||||
|
||||
## 七、联调任务责任方式
|
||||
|
||||
- 涉及企微、支付、Gateway、运营商回调和异步 Worker 的联调任务由后端主责,前端参与。
|
||||
- 主要是页面与接口契约的批量、导出、套餐和换货联调可由前端主责,后端参与。
|
||||
- 禅道只有一个指派人时,指派给主责人;另一人写入抄送和参与人,不拆成两条重复联调任务。
|
||||
- 联调发现的代码问题回到对应 FE/BE 研发需求处理;联调任务只记录场景、阻塞和最终结论。
|
||||
|
||||
## 八、研发需求描述模板
|
||||
|
||||
前端研发需求至少填写:页面入口、交互状态、调用接口、权限、异常状态、完成标准。
|
||||
|
||||
后端研发需求至少填写:业务规则、数据变更、API、权限、幂等/并发、事件与审计、完成标准。
|
||||
|
||||
联调任务至少填写:关联研发需求、环境和账号、测试数据、完整操作步骤、预期结果、阻塞项和最终结论。
|
||||
|
||||
## 九、额外禅道项
|
||||
|
||||
当前用户需求 CSV 没有覆盖公共开发基础、公共站内通知和全局审计。建议补建三条技术用户需求:
|
||||
|
||||
```text
|
||||
用户需求:七月迭代公共开发基础
|
||||
用户需求:公共站内通知
|
||||
用户需求:全局多视角审计与外部集成追踪
|
||||
```
|
||||
|
||||
再分别拆分:
|
||||
|
||||
```text
|
||||
[FE] 七月迭代公共状态与异步任务交互
|
||||
[BE] 七月迭代公共迁移幂等与异步任务基础
|
||||
[FE] 顶部通知铃铛与站内通知中心
|
||||
[BE] 站内通知基础设施与受控跳转
|
||||
[FE] 全局多视角审计中心
|
||||
[BE] Audit Event与Integration Log统一审计
|
||||
```
|
||||
|
||||
具体描述和工时直接使用[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。
|
||||
1889
docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md
Normal file
1889
docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,357 +0,0 @@
|
||||
# 项目 DDD 设计规范
|
||||
|
||||
> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。
|
||||
> 基准文档:`docs/改造方向.md`(绞杀者模式)
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么用 DDD / 什么不是 DDD
|
||||
|
||||
### 现状问题
|
||||
|
||||
- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起
|
||||
- Model 只是 GORM 映射,没有业务行为(贫血模型)
|
||||
- 新增业务联动必须修改原有 Service,牵一发动全身
|
||||
|
||||
### 目标
|
||||
|
||||
不是重写,是**渐进替换**:
|
||||
|
||||
1. **新功能按新结构写**,不往旧 Service 加代码
|
||||
2. **旧功能迁移**:每次迁移一个用例,跑通后再下一个
|
||||
3. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
internal/
|
||||
├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM)
|
||||
│ ├── wallet/
|
||||
│ │ ├── wallet.go ← 聚合根(含业务方法)
|
||||
│ │ ├── events.go ← 领域事件定义
|
||||
│ │ ├── repository.go ← 仓储接口(interface)
|
||||
│ │ └── vo.go ← 值对象(Money, CreditLimit)
|
||||
│ ├── approval/ ← 新:审批流领域
|
||||
│ ├── notification/ ← 新:通知领域
|
||||
│ ├── distribution/ ← 新:分销领域
|
||||
│ └── package/ ← 套餐领域(迁移中)
|
||||
│
|
||||
├── application/ ← 应用层(用例,只做编排,不含业务判断)
|
||||
│ ├── wallet/
|
||||
│ │ ├── debit_wallet.go ← 一个文件 = 一个用例
|
||||
│ │ ├── credit_wallet.go
|
||||
│ │ └── grant_credit.go ← 新:授信
|
||||
│ ├── approval/
|
||||
│ │ ├── submit_approval.go
|
||||
│ │ ├── advance_step.go
|
||||
│ │ └── reject_approval.go
|
||||
│ └── notification/
|
||||
│ └── send_notification.go
|
||||
│
|
||||
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface)
|
||||
│ └── persistence/ ← 原来的 store/postgres/(保持不动)
|
||||
│
|
||||
├── handler/ ← 原有 handler(保持不动)
|
||||
├── service/ ← 原有 service(保持不动,新模块不在这里)
|
||||
└── store/ ← 原有 store(保持不动)
|
||||
```
|
||||
|
||||
**过渡期规则**:
|
||||
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
|
||||
- 新模块:`internal/domain/xxx` + `internal/application/xxx`
|
||||
- Handler 层统一调 Application 层(新)或 Service 层(旧)
|
||||
|
||||
---
|
||||
|
||||
## 三、领域模块划分
|
||||
|
||||
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|
||||
|------|--------|------------|------|
|
||||
| **Asset(资产)** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 |
|
||||
| **Order(订单)** | `Order` | 支付、退款、佣金触发 | 迁移中 |
|
||||
| **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
|
||||
| **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
|
||||
| **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 |
|
||||
| **Approval(审批)** | `ApprovalFlow` | 多级审批、推进、驳回 | **全新 DDD** |
|
||||
| **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** |
|
||||
| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | **全新 DDD** |
|
||||
|
||||
---
|
||||
|
||||
## 四、聚合根设计原则(富模型)
|
||||
|
||||
### 4.1 核心原则
|
||||
|
||||
业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。
|
||||
|
||||
```go
|
||||
// ❌ 贫血模型(禁止)——所有判断在 Service 里
|
||||
func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error {
|
||||
wallet, _ := s.store.Get(ctx, walletID)
|
||||
if wallet.Balance-wallet.FrozenBalance < amount {
|
||||
return errors.New(errors.CodeInsufficientBalance)
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
s.store.Update(ctx, wallet)
|
||||
}
|
||||
|
||||
// ✅ 富模型(推荐)——判断逻辑在聚合根里
|
||||
func (w *AgentWallet) Debit(amount Money) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度
|
||||
if available < amount.Cents() {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
w.Balance -= amount.Cents()
|
||||
w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Application 层只做编排
|
||||
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
|
||||
wallet, err := uc.repo.GetByID(ctx, cmd.WalletID)
|
||||
if err != nil { return err }
|
||||
if err := wallet.Debit(cmd.Amount); err != nil { return err }
|
||||
return uc.repo.Save(ctx, wallet)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 聚合根必须包含
|
||||
|
||||
```go
|
||||
type AggregateRoot struct {
|
||||
// 内嵌 GORM model(过渡期)
|
||||
gorm.Model
|
||||
|
||||
// 业务字段...
|
||||
|
||||
// 未发布领域事件(内存中,Save 后由框架分发)
|
||||
domainEvents []DomainEvent `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// 获取并清空领域事件(由 Repository.Save 调用)
|
||||
func (a *AggregateRoot) PopEvents() []DomainEvent {
|
||||
events := a.domainEvents
|
||||
a.domainEvents = nil
|
||||
return events
|
||||
}
|
||||
|
||||
func (a *AggregateRoot) recordEvent(e DomainEvent) {
|
||||
a.domainEvents = append(a.domainEvents, e)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 与现有 GORM 的共存
|
||||
|
||||
过渡期,聚合根 **就是** 现有的 GORM Model(加业务方法),不需要单独建领域对象:
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/wallet.go
|
||||
// AgentWallet 钱包聚合根
|
||||
// 直接复用并扩展现有 model.AgentWallet
|
||||
type AgentWallet struct {
|
||||
model.AgentWallet // 内嵌原有 GORM 模型
|
||||
domainEvents []DomainEvent `gorm:"-"`
|
||||
}
|
||||
|
||||
// Debit 从钱包扣款(含信用额度)
|
||||
func (w *AgentWallet) Debit(amount Money) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit
|
||||
if available < amount.Cents() {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
w.Balance -= amount.Cents()
|
||||
w.recordEvent(WalletDebitedEvent{...})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、值对象设计
|
||||
|
||||
值对象:没有 ID,靠值来判断相等,不可变。
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/vo.go
|
||||
|
||||
// Money 金额值对象(分为单位,防止浮点数精度问题)
|
||||
type Money struct {
|
||||
cents int64
|
||||
}
|
||||
|
||||
func NewMoney(cents int64) (Money, error) {
|
||||
if cents < 0 {
|
||||
return Money{}, ErrNegativeMoney
|
||||
}
|
||||
return Money{cents: cents}, nil
|
||||
}
|
||||
|
||||
func (m Money) Cents() int64 { return m.cents }
|
||||
func (m Money) Yuan() float64 { return float64(m.cents) / 100 }
|
||||
func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} }
|
||||
|
||||
// CreditLimit 信用额度值对象
|
||||
type CreditLimit struct {
|
||||
maxCents int64 // 最大授信额度(分)
|
||||
allowNegative bool // 是否允许负余额
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、领域事件(用 Asynq 实现)
|
||||
|
||||
领域事件不用消息队列,直接复用现有 Asynq 体系。
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/events.go
|
||||
|
||||
// DomainEvent 领域事件接口
|
||||
type DomainEvent interface {
|
||||
EventType() string
|
||||
OccurredAt() time.Time
|
||||
}
|
||||
|
||||
// WalletDebitedEvent 钱包扣款事件
|
||||
type WalletDebitedEvent struct {
|
||||
WalletID uint
|
||||
ShopID uint
|
||||
Amount Money
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
RefType string
|
||||
RefID uint
|
||||
occurredAt time.Time
|
||||
}
|
||||
|
||||
func (e WalletDebitedEvent) EventType() string { return "wallet.debited" }
|
||||
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
|
||||
```
|
||||
|
||||
**Repository.Save 发布事件**:
|
||||
|
||||
```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()))
|
||||
// 事件发布失败不回滚业务(异步,可补偿)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、仓储接口
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/repository.go
|
||||
|
||||
// WalletRepository 钱包仓储接口
|
||||
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 逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 八、应用服务(用例)
|
||||
|
||||
一个文件 = 一个用例。用例只做编排,不含业务判断。
|
||||
|
||||
```go
|
||||
// internal/application/wallet/debit_wallet.go
|
||||
|
||||
// DebitCommand 扣款命令
|
||||
type DebitCommand struct {
|
||||
WalletID uint
|
||||
Amount domainWallet.Money
|
||||
RefType string
|
||||
RefID uint
|
||||
OperatorID uint
|
||||
}
|
||||
|
||||
// DebitWalletUseCase 扣款用例
|
||||
type DebitWalletUseCase struct {
|
||||
walletRepo domainWallet.WalletRepository
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wallet.Debit(cmd.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.walletRepo.SaveInTx(ctx, tx, wallet)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、Handler 层调用方式
|
||||
|
||||
新模块:Handler → Application UseCase(不经过 Service 层)
|
||||
|
||||
```go
|
||||
// internal/handler/admin/wallet_handler.go
|
||||
func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
|
||||
var req dto.GrantCreditRequest
|
||||
// ... 参数解析
|
||||
|
||||
cmd := walletApp.GrantCreditCommand{
|
||||
ShopID: req.ShopID,
|
||||
MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents),
|
||||
OperatorID: middleware.GetUserID(c),
|
||||
}
|
||||
if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return response.OK(c, nil)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、禁止事项
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|------|------|
|
||||
| 在 Application 层做业务判断 | 业务规则必须在领域对象里 |
|
||||
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 |
|
||||
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
|
||||
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
|
||||
| 领域事件发布失败时回滚业务 | 事件是异步补偿,不是事务一部分 |
|
||||
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、本次迭代 DDD 落地计划
|
||||
|
||||
| 新模块 | 领域路径 | 用例路径 |
|
||||
|--------|---------|---------|
|
||||
| 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` |
|
||||
| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/` |
|
||||
| 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` |
|
||||
| 分销码(需求16) | `internal/domain/distribution/` | `internal/application/distribution/` |
|
||||
| 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
|
||||
62
docs/7月迭代/README.md
Normal file
62
docs/7月迭代/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 7月迭代文档索引
|
||||
|
||||
本目录只保留一份当前有效的评审结论和可追溯的独立来源稿。
|
||||
|
||||
## 当前有效方案
|
||||
|
||||
- [7月迭代技术方案(标准评审稿)](./7月迭代技术方案-标准评审稿.md)
|
||||
- [7月迭代前后端任务工时表](./7月迭代前后端任务工时表.md)
|
||||
- [7月迭代禅道研发需求拆分表](./7月迭代禅道研发需求拆分表.md)
|
||||
- [7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)
|
||||
|
||||
评审、开发和验收均以标准评审稿为准。独立稿用于解释方案来源;与标准稿冲突时,标准稿优先。
|
||||
|
||||
## 原需求独立稿
|
||||
|
||||
| 需求 | 来源文件 |
|
||||
|------|----------|
|
||||
| 01 | [复机实名规则](./独立方案/原需求/需求01-复机实名规则.md) |
|
||||
| 02 | [H5 流程配置](./独立方案/原需求/需求02-H5流程配置.md) |
|
||||
| 03、07、11、12、13 | [简单改动合集](./独立方案/原需求/需求03-07-11-12-13-简单改动.md) |
|
||||
| 04、06 | [退款拦截与最后到期时间](./独立方案/原需求/需求04-06-退款拦截与最后到期时间.md) |
|
||||
| 05 | [套餐分配生效条件](./独立方案/原需求/需求05-套餐分配生效条件.md) |
|
||||
| 08 | [设备批量分配 Excel](./独立方案/原需求/需求08-设备批量分配Excel.md) |
|
||||
| 09 | [C 端支付限制配置化](./独立方案/原需求/需求09-C端支付限制配置化.md) |
|
||||
| 10 | [Gateway 限速规则](./独立方案/原需求/需求10-限速规则.md) |
|
||||
| 14 | [导出功能](./独立方案/原需求/需求14-导出功能.md) |
|
||||
| 15、16、18、19、20、21 | [复杂需求来源稿](./独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md) |
|
||||
| 17 | [信用额度](./独立方案/原需求/需求17-信用额度.md) |
|
||||
| 22 | [套餐临期提醒](./独立方案/原需求/需求22-套餐临期提醒.md) |
|
||||
|
||||
需求16已经移出本期,仅在来源稿中保留讨论记录。
|
||||
|
||||
## 新增需求独立稿
|
||||
|
||||
| 编号 | 来源文件 |
|
||||
|------|----------|
|
||||
| 新增01 | [数据同步触发与轮询优化](./独立方案/新增需求/01-数据同步触发与轮询优化.md) |
|
||||
| 新增02 | [企业微信审批接入](./独立方案/新增需求/02-企业微信审批接入.md) |
|
||||
| 新增03 | [站内通知详细方案](./独立方案/新增需求/03-站内通知详细方案.md) |
|
||||
| 新增04 | [全局多视角审计方案](./独立方案/新增需求/04-全局多视角审计方案.md) |
|
||||
| 新增05 | [代理钱包扫码充值](./独立方案/新增需求/05-代理钱包扫码充值.md) |
|
||||
| 禅道 #98/#86 | [换货归属与资产换货标识](./独立方案/新增需求/06-换货归属与资产换货标识.md) |
|
||||
| 禅道 #96/#97 | [店铺业务员与钱包余额预警](./独立方案/新增需求/07-店铺业务员与钱包余额预警.md) |
|
||||
| 禅道 #43 | [代理系列套餐批量授权](./独立方案/新增需求/08-代理系列套餐批量授权.md) |
|
||||
|
||||
## 基础规范与历史方案
|
||||
|
||||
- [DDD 规范](./独立方案/基础规范/DDD规范.md)
|
||||
- [系统配置独立方案](./独立方案/基础规范/系统配置.md)
|
||||
- [本地通用审批流(已废弃)](./独立方案/历史方案/本地通用审批流-已废弃方案.md)
|
||||
- [站内消息初版(已被详细方案替代)](./独立方案/历史方案/站内消息-初版.md)
|
||||
- [前端共性方案历史稿](./独立方案/历史方案/前端共性方案-历史稿.md)
|
||||
|
||||
原始讨论材料:
|
||||
|
||||
- [原始业务需求](./来源材料/业务需求.md)
|
||||
- [新增需求讨论与示例代码](./来源材料/新需求.md)
|
||||
- [禅道用户需求导出](./物联网卡管系统-需求.csv)
|
||||
|
||||
这些文件只用于追溯,不是技术实施结论。
|
||||
|
||||
已删除的 `00-总览.md` 和 `7月迭代完整技术方案-评审稿.md` 都是重复汇编,不包含独立来源信息。
|
||||
@@ -1,414 +0,0 @@
|
||||
# 基础设施:通用审批流引擎
|
||||
|
||||
> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核)
|
||||
> 设计:DDD 结构,`internal/domain/approval/`
|
||||
|
||||
---
|
||||
|
||||
## 一、业务规则
|
||||
|
||||
- 审批顺序:**提交人部门领导 → 财务**(固定两级)
|
||||
- 每级只能有一个审批人(通过角色确定,不是指定个人)
|
||||
- 上一级审批通过后,下一级审批人收到站内消息
|
||||
- **驳回**:永久拒绝,流程终止,通知申请人含驳回原因
|
||||
- **退回**:退回给提交人修改,流程终止,提交人可修改后重新提交(新建审批流)
|
||||
- **通过**:最后一级通过后,通知申请人,触发业务回调
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 审批流实例表
|
||||
CREATE TABLE tb_approval_flow (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flow_no VARCHAR(30) NOT NULL UNIQUE,
|
||||
biz_type VARCHAR(50) NOT NULL, -- 业务类型:recharge | refund
|
||||
biz_id BIGINT NOT NULL,
|
||||
biz_no VARCHAR(50) NOT NULL DEFAULT '', -- 关联业务单号(快照)
|
||||
submitter_id BIGINT NOT NULL,
|
||||
submitter_name VARCHAR(50) NOT NULL DEFAULT '',
|
||||
current_step INT NOT NULL DEFAULT 1, -- 当前步骤(1=部门领导, 2=财务)
|
||||
total_steps INT NOT NULL DEFAULT 2,
|
||||
status INT NOT NULL DEFAULT 1, -- 1=审批中 2=已通过 3=已驳回 4=已退回
|
||||
reject_reason TEXT, -- 驳回/退回原因
|
||||
completed_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 审批步骤记录表
|
||||
CREATE TABLE tb_approval_step_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flow_id BIGINT NOT NULL,
|
||||
step INT NOT NULL,
|
||||
step_name VARCHAR(50) NOT NULL,
|
||||
approver_id BIGINT,
|
||||
approver_name VARCHAR(50),
|
||||
result INT NOT NULL DEFAULT 0, -- 0=待审批 1=通过 2=驳回 3=退回
|
||||
remark TEXT,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_flow_biz ON tb_approval_flow (biz_type, biz_id);
|
||||
CREATE INDEX idx_approval_step_flow ON tb_approval_step_record (flow_id, step);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
|
||||
```go
|
||||
// internal/model/approval.go
|
||||
|
||||
const (
|
||||
ApprovalBizTypeRecharge = "recharge"
|
||||
ApprovalBizTypeRefund = "refund"
|
||||
|
||||
ApprovalStatusPending = 1 // 审批中
|
||||
ApprovalStatusApproved = 2 // 已通过
|
||||
ApprovalStatusRejected = 3 // 已驳回(永久拒绝,流程终止)
|
||||
ApprovalStatusReturned = 4 // 已退回(退回给提交人修改,可重新提交)
|
||||
|
||||
ApprovalStepResultPending = 0 // 待审批
|
||||
ApprovalStepResultApproved = 1 // 通过
|
||||
ApprovalStepResultRejected = 2 // 驳回
|
||||
ApprovalStepResultReturned = 3 // 退回
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、领域层(DDD)
|
||||
|
||||
```go
|
||||
// internal/domain/approval/approval_flow.go
|
||||
|
||||
type ApprovalFlowAggregate struct {
|
||||
model.ApprovalFlow
|
||||
Steps []*model.ApprovalStepRecord
|
||||
domainEvents []DomainEvent `gorm:"-"`
|
||||
}
|
||||
|
||||
// CanAdvance 检查当前用户是否可以审批当前步骤
|
||||
func (f *ApprovalFlowAggregate) CanAdvance(approverRoles []string) bool {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return false
|
||||
}
|
||||
if f.CurrentStep == 1 {
|
||||
return contains(approverRoles, "department_leader")
|
||||
}
|
||||
if f.CurrentStep == 2 {
|
||||
return contains(approverRoles, "finance")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Approve 通过当前步骤
|
||||
func (f *ApprovalFlowAggregate) Approve(approverID uint, approverName string, remark string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultApproved
|
||||
step.Remark = &remark
|
||||
step.ApprovedAt = &now
|
||||
|
||||
if f.CurrentStep >= f.TotalSteps {
|
||||
f.Status = model.ApprovalStatusApproved
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalCompletedEvent{FlowID: f.ID, BizType: f.BizType, BizID: f.BizID})
|
||||
} else {
|
||||
f.CurrentStep++
|
||||
f.recordEvent(ApprovalStepAdvancedEvent{
|
||||
FlowID: f.ID,
|
||||
NextStep: f.CurrentStep,
|
||||
BizType: f.BizType,
|
||||
SubmitterID: f.SubmitterID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 驳回(永久拒绝,流程终止)
|
||||
func (f *ApprovalFlowAggregate) Reject(approverID uint, approverName string, reason string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultRejected
|
||||
step.Remark = &reason
|
||||
step.ApprovedAt = &now
|
||||
|
||||
f.Status = model.ApprovalStatusRejected
|
||||
f.RejectReason = &reason
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalRejectedEvent{
|
||||
FlowID: f.ID,
|
||||
BizType: f.BizType,
|
||||
BizID: f.BizID,
|
||||
SubmitterID: f.SubmitterID,
|
||||
Reason: reason,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return 退回给提交人修改(流程终止,提交人可重新提交)
|
||||
func (f *ApprovalFlowAggregate) Return(approverID uint, approverName string, reason string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultReturned
|
||||
step.Remark = &reason
|
||||
step.ApprovedAt = &now
|
||||
|
||||
f.Status = model.ApprovalStatusReturned
|
||||
f.RejectReason = &reason
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalReturnedEvent{
|
||||
FlowID: f.ID,
|
||||
BizType: f.BizType,
|
||||
BizID: f.BizID,
|
||||
SubmitterID: f.SubmitterID,
|
||||
Reason: reason,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、领域事件
|
||||
|
||||
```go
|
||||
// internal/domain/approval/events.go
|
||||
|
||||
// ApprovalStepAdvancedEvent 步骤推进(通知下一级审批人)
|
||||
type ApprovalStepAdvancedEvent struct {
|
||||
FlowID uint
|
||||
NextStep int
|
||||
BizType string
|
||||
SubmitterID uint
|
||||
}
|
||||
|
||||
// ApprovalCompletedEvent 审批完成(触发业务回调 + 通知申请人)
|
||||
type ApprovalCompletedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
}
|
||||
|
||||
// ApprovalRejectedEvent 驳回(通知申请人,流程永久终止)
|
||||
type ApprovalRejectedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
SubmitterID uint
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ApprovalReturnedEvent 退回给提交人修改(通知申请人,可重新提交)
|
||||
type ApprovalReturnedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
SubmitterID uint
|
||||
Reason string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、接入协议(业务层如何接入审批流)
|
||||
|
||||
任何业务接入审批流需做以下四件事:
|
||||
|
||||
### 6.1 业务表新增字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_xxx ADD COLUMN approval_flow_id BIGINT DEFAULT NULL;
|
||||
```
|
||||
|
||||
### 6.2 提交时创建审批流
|
||||
|
||||
业务提交接口(如 `POST /admin/refund-requests`):
|
||||
|
||||
```go
|
||||
// 1. 创建业务单(status=待审批)
|
||||
bizRecord, _ := s.bizStore.Create(ctx, req)
|
||||
|
||||
// 2. 创建审批流
|
||||
flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{
|
||||
BizType: model.ApprovalBizTypeRefund,
|
||||
BizID: bizRecord.ID,
|
||||
BizNo: bizRecord.RefundNo,
|
||||
SubmitterID: middleware.GetUserID(ctx),
|
||||
SubmitterName: middleware.GetUsername(ctx),
|
||||
})
|
||||
|
||||
// 3. 把 flow_id 写回业务单
|
||||
s.bizStore.UpdateApprovalFlowID(ctx, bizRecord.ID, flow.ID)
|
||||
```
|
||||
|
||||
### 6.3 监听审批事件,更新业务状态
|
||||
|
||||
每个接入方在 `internal/task/` 注册对应的 Asynq 事件处理器:
|
||||
|
||||
```go
|
||||
// internal/task/refund_approval_handler.go
|
||||
|
||||
// OnApprovalCompleted 审批通过 → 退款单状态变"已通过",触发退款
|
||||
func (h *RefundApprovalHandler) OnApprovalCompleted(ctx context.Context, event ApprovalCompletedEvent) error {
|
||||
// 按 biz_type 过滤,只处理退款类型
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.ApproveRefund(ctx, event.BizID)
|
||||
}
|
||||
|
||||
// OnApprovalRejected 审批驳回 → 退款单状态变"已拒绝"
|
||||
func (h *RefundApprovalHandler) OnApprovalRejected(ctx context.Context, event ApprovalRejectedEvent) error {
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.RejectRefund(ctx, event.BizID, event.Reason)
|
||||
}
|
||||
|
||||
// OnApprovalReturned 审批退回 → 退款单状态变"已退回",提交人可修改重提
|
||||
func (h *RefundApprovalHandler) OnApprovalReturned(ctx context.Context, event ApprovalReturnedEvent) error {
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.ReturnRefund(ctx, event.BizID, event.Reason)
|
||||
}
|
||||
```
|
||||
|
||||
充值单同理,注册 `RechargeApprovalHandler`。
|
||||
|
||||
### 6.4 退回后重新提交
|
||||
|
||||
业务层新增重新提交接口(如 `POST /admin/refund-requests/{id}/resubmit`):
|
||||
|
||||
```go
|
||||
// 1. 校验业务单 status=已退回
|
||||
if bizRecord.Status != model.RefundStatusReturned {
|
||||
return errors.New(errors.CodeForbidden, "当前状态不允许重新提交")
|
||||
}
|
||||
|
||||
// 2. 新建审批流(biz_type+biz_id 相同,旧的流程作为历史保留)
|
||||
flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{...})
|
||||
|
||||
// 3. 更新业务单:approval_flow_id = 新 flow.ID,status 回到"待审批"
|
||||
s.bizStore.Resubmit(ctx, bizRecord.ID, flow.ID)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、应用层(用例)
|
||||
|
||||
```go
|
||||
// internal/application/approval/submit_approval.go
|
||||
|
||||
type SubmitApprovalCommand struct {
|
||||
BizType string
|
||||
BizID uint
|
||||
BizNo string
|
||||
SubmitterID uint
|
||||
SubmitterName string
|
||||
}
|
||||
|
||||
type SubmitApprovalUseCase struct {
|
||||
approvalRepo approval.ApprovalRepository
|
||||
notifyPublisher *notification.NotificationPublisher
|
||||
userStore UserStore
|
||||
}
|
||||
|
||||
func (uc *SubmitApprovalUseCase) Execute(ctx context.Context, cmd SubmitApprovalCommand) (*model.ApprovalFlow, error) {
|
||||
flow := &domain_approval.ApprovalFlowAggregate{...}
|
||||
// 初始化两个步骤记录(均为待审批)
|
||||
if err := uc.approvalRepo.Create(ctx, flow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 通知部门领导
|
||||
leaderIDs := uc.userStore.GetUsersByRole(ctx, "department_leader")
|
||||
uc.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
RecipientIDs: leaderIDs,
|
||||
Type: constants.NotifyTypeApprovalPending,
|
||||
Title: "您有一条待审批记录",
|
||||
Body: fmt.Sprintf("「%s」提交了%s申请", cmd.SubmitterName, bizTypeLabel(cmd.BizType)),
|
||||
RefType: "approval",
|
||||
RefID: flow.ID,
|
||||
})
|
||||
return &flow.ApprovalFlow, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、API 设计
|
||||
|
||||
### 8.1 待我审批列表
|
||||
|
||||
```
|
||||
GET /admin/approvals?status=1&biz_type=recharge&page=1&page_size=20
|
||||
```
|
||||
|
||||
### 8.2 审批通过
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/approve
|
||||
{ "remark": "审批通过" }
|
||||
```
|
||||
|
||||
### 8.3 审批驳回(永久拒绝)
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/reject
|
||||
{ "reason": "金额不符,请重新提交" }
|
||||
```
|
||||
|
||||
### 8.4 退回给提交人修改
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/return
|
||||
{ "reason": "请补充支付凭证后重新提交" }
|
||||
```
|
||||
|
||||
退回后,提交人修改业务单,再通过业务接口重新提交(如 `POST /admin/refund-requests/{id}/resubmit`)。
|
||||
|
||||
### 8.5 审批详情
|
||||
|
||||
```
|
||||
GET /admin/approvals/{flow_id}
|
||||
```
|
||||
|
||||
响应包含:flow 基本信息 + steps 列表(每步审批人、结果【通过/驳回/退回】、时间、备注)
|
||||
|
||||
---
|
||||
|
||||
## 九、前端对接
|
||||
|
||||
### 审批详情页操作区
|
||||
|
||||
当前登录用户是当前步骤审批人时,显示三个按钮:
|
||||
|
||||
```
|
||||
[审批通过] [退回修改] [驳回]
|
||||
备注/原因输入框
|
||||
```
|
||||
|
||||
- **审批通过** → `POST /admin/approvals/{id}/approve`
|
||||
- **退回修改** → `POST /admin/approvals/{id}/return`(需填退回原因)
|
||||
- **驳回** → `POST /admin/approvals/{id}/reject`(需填驳回原因)
|
||||
|
||||
### 权限控制
|
||||
|
||||
- `department_leader` 角色:步骤1为"待审批"时显示操作按钮
|
||||
- `finance` 角色:步骤2为"待审批"时显示操作按钮
|
||||
- 其他角色:只读
|
||||
@@ -1,3 +1,5 @@
|
||||
> 本文只保留原始业务需求措辞,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 为准。
|
||||
|
||||
其中有一些需要对接第三方系统的,除了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. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。
|
||||
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 端公众号首页提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
@@ -358,7 +388,9 @@ excel表导入字段修改为以下:
|
||||
代理 15,7,3天
|
||||
C端公众号 小于等于15天
|
||||
|
||||
颜色规则
|
||||
颜色规则
|
||||
临期天数小于等于15天时,显示粉红色;
|
||||
临期天数小于等于7天时,显示紫色;
|
||||
临期天数小于等于3天时,显示黄色;
|
||||
|
||||
> 最终评审口径:资产临期按当前及排队主套餐的预计最终到期时间计算;3天内改为红色,只在临期独立列表置顶。七月迭代不发送企业微信临期消息,只发送站内通知。
|
||||
729
docs/7月迭代/来源材料/新需求.md
Normal file
729
docs/7月迭代/来源材料/新需求.md
Normal file
@@ -0,0 +1,729 @@
|
||||
> 本文保留新增需求讨论和示例代码,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 及对应新增需求独立稿为准。
|
||||
|
||||
处理回调的代码
|
||||
```golang
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ranNumLib "math/rand"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// XML请求体结构
|
||||
type ContractRoot struct {
|
||||
XMLName xml.Name `xml:"ContractRoot"`
|
||||
Type string `xml:"TYPE"`
|
||||
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
|
||||
StatusInfo string `xml:"STATUSINFO"`
|
||||
AccNbr string `xml:"ACCNBR"`
|
||||
ICCID string `xml:"ICCID"`
|
||||
SendDt string `xml:"SENDDT"`
|
||||
AcceptType string `xml:"ACCEPTTYPE"`
|
||||
AcceptMsg string `xml:"ACCEPTMSG"`
|
||||
StatusDt string `xml:"STATUSDT"`
|
||||
ResultMsg string `xml:"RESULTMSG"`
|
||||
}
|
||||
|
||||
// 第三方推送数据结构
|
||||
type PushData struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
RequestID string `json:"requestId"`
|
||||
RealStatus bool `json:"realStatus"`
|
||||
ICCID string `json:"iccid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseCallback(jsonStr []byte) (string, string, error) {
|
||||
// 定义匿名结构体用于解析外层JSON
|
||||
var cb struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jsonStr, &cb)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err)
|
||||
}
|
||||
|
||||
// 定义匿名结构体用于解析内层数据
|
||||
var inner struct {
|
||||
DateChanged string `json:"dateChanged"`
|
||||
ICCID string `json:"iccid"`
|
||||
}
|
||||
err = json.Unmarshal([]byte(cb.Data), &inner)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err)
|
||||
}
|
||||
|
||||
return inner.ICCID, inner.DateChanged, nil
|
||||
}
|
||||
|
||||
// 5GCMP实名后推送到第三方平台
|
||||
func pushToThirdParty(iccid string) (string, error) {
|
||||
// 构建推送数据
|
||||
pushData := PushData{
|
||||
Msg: "查询成功",
|
||||
Code: 200,
|
||||
}
|
||||
pushData.Data.RequestID = uuid.New().String()
|
||||
pushData.Data.RealStatus = true
|
||||
pushData.Data.ICCID = iccid
|
||||
|
||||
// 序列化为JSON
|
||||
jsonData, err := json.Marshal(pushData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化推送数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 发送HTTP POST请求
|
||||
pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3"
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// 获取日志文件路径
|
||||
func getLogFilePath() string {
|
||||
// 创建logs目录
|
||||
logsDir := "logs"
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
log.Printf("创建日志目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 按日期生成文件名
|
||||
dateStr := time.Now().Format("2006-01-02")
|
||||
fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr)
|
||||
return filepath.Join(logsDir, fileName)
|
||||
}
|
||||
|
||||
func GetQcRandNum() (ranNum string) {
|
||||
randomFloat := ranNumLib.Float64()
|
||||
if randomFloat < 0.5 {
|
||||
randomFloat = 1 - randomFloat
|
||||
}
|
||||
ranNum = fmt.Sprintf("%.16f", randomFloat)
|
||||
return
|
||||
}
|
||||
|
||||
// 写入日志
|
||||
func writeLog(content string) {
|
||||
logFile := getLogFilePath()
|
||||
|
||||
// 打开或创建日志文件
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("打开日志文件失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 写入日志内容
|
||||
if _, err := file.WriteString(content); err != nil {
|
||||
log.Printf("写入日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 向管理平台发送删除实名请求
|
||||
func DelRealName(iccid string) (res string) {
|
||||
account := Account{UserName: "18627991016", Password: "y123456"}
|
||||
sessionid := account.Login()
|
||||
realnameId := getRealnameIdByIccid(iccid, sessionid)
|
||||
if realnameId == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId)
|
||||
url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum()
|
||||
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("创建请求失败: %v", err)
|
||||
return ""
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("HTTP请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("删除实名响应: %s", respBody)
|
||||
res = string(respBody)
|
||||
return
|
||||
}
|
||||
|
||||
func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) {
|
||||
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum()
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("创建请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("HTTP请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var JSONData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
PageNo int `json:"pageNo"`
|
||||
PageCount int `json:"pageCount"`
|
||||
PageSize int `json:"pageSize"`
|
||||
PageStartOffset int `json:"pageStartOffset"`
|
||||
Total int `json:"total"`
|
||||
Rows []struct {
|
||||
MybatisRecordCount int `json:"mybatisRecordCount"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
JSONUpdateFlag string `json:"jsonUpdateFlag"`
|
||||
ID string `json:"id"`
|
||||
IccidMark string `json:"iccidMark"`
|
||||
Phone string `json:"phone"`
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
Status int `json:"status"`
|
||||
CreateName string `json:"createName"`
|
||||
CreateDate string `json:"createDate"`
|
||||
StatusStr string `json:"statusStr"`
|
||||
} `json:"rows"`
|
||||
Framework string `json:"framework"`
|
||||
Data string `json:"data"`
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"`
|
||||
Layui bool `json:"layui"`
|
||||
} `json:"data"`
|
||||
CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"`
|
||||
AppResultKey string `json:"app_result_key"`
|
||||
SystemResultKey string `json:"system_result_key"`
|
||||
}
|
||||
json.Unmarshal(respBody, &JSONData)
|
||||
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
|
||||
realnameId = JSONData.Data.Rows[0].ID
|
||||
}
|
||||
|
||||
log.Printf("响应体: %s", respBody)
|
||||
return
|
||||
}
|
||||
|
||||
func getIccidByMsisdn(msisdn, sessionid string) (iccid string) {
|
||||
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum()
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("status=1&phone=%s", msisdn)
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("创建请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("HTTP请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
var JSONData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Rows []struct {
|
||||
IccidMark string `json:"iccidMark"`
|
||||
} `json:"rows"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
AppResultKey string `json:"app_result_key"`
|
||||
SystemResultKey string `json:"system_result_key"`
|
||||
}
|
||||
json.Unmarshal(respBody, &JSONData)
|
||||
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
|
||||
iccid = JSONData.Data.Rows[0].IccidMark
|
||||
}
|
||||
log.Printf("响应体: %s", respBody)
|
||||
return
|
||||
}
|
||||
|
||||
func ModifyDate(iccid, dateChanged string) {
|
||||
var jsonData = map[string]interface{}{
|
||||
"iccid": iccid,
|
||||
"dateChanged": dateChanged,
|
||||
}
|
||||
jsonDataBs, _ := json.Marshal(jsonData)
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs))
|
||||
if err != nil {
|
||||
writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
|
||||
// 设置Content-Type为x-www-form-urlencoded
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应内容
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody))
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody)))
|
||||
|
||||
}
|
||||
|
||||
// 处理回调请求
|
||||
func handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
timestamp := startTime.Format("2006-01-02 15:04:05")
|
||||
|
||||
// 构建日志内容
|
||||
var logBuilder strings.Builder
|
||||
logBuilder.WriteString("\n========================================\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
|
||||
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
|
||||
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
|
||||
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
|
||||
|
||||
// 记录请求头
|
||||
logBuilder.WriteString("--- 请求头 ---\n")
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求体
|
||||
logBuilder.WriteString("--- 请求体 ---\n")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
|
||||
} else {
|
||||
if len(body) > 0 {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
|
||||
} else {
|
||||
logBuilder.WriteString("(空请求体)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 处理XML请求体和第三方推送
|
||||
var pushResponse string
|
||||
|
||||
log.Printf("body:%s\r\n", string(body))
|
||||
|
||||
if len(body) > 0 {
|
||||
// 尝试解析XML
|
||||
var contractRoot ContractRoot
|
||||
if err := xml.Unmarshal(body, &contractRoot); err == nil {
|
||||
logBuilder.WriteString("--- XML解析结果 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type))
|
||||
logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID))
|
||||
logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo))
|
||||
|
||||
// 如果TYPE=1,表示实名认证成功,需要推送到第三方
|
||||
if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type))
|
||||
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID))
|
||||
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
|
||||
|
||||
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
ModifyDate(contractRoot.ICCID, timestampStr)
|
||||
|
||||
// 执行推送
|
||||
if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
|
||||
pushResponse = fmt.Sprintf("推送失败: %v", err)
|
||||
} else {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
|
||||
pushResponse = resp
|
||||
}
|
||||
} else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
|
||||
logBuilder.WriteString("--- 第三方推送删除实名 ---\n")
|
||||
res := DelRealName(contractRoot.ICCID)
|
||||
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
|
||||
} else {
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg))
|
||||
}
|
||||
} else {
|
||||
logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录处理时间
|
||||
processTime := time.Since(startTime)
|
||||
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
|
||||
logBuilder.WriteString("========================================\n")
|
||||
|
||||
// 写入日志文件
|
||||
writeLog(logBuilder.String())
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Print(logBuilder.String())
|
||||
|
||||
// 返回成功响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// 构建响应数据
|
||||
responseData := map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
// 如果有推送响应,添加到响应中
|
||||
if pushResponse != "" {
|
||||
responseData["pushResponse"] = pushResponse
|
||||
}
|
||||
|
||||
respJSON, _ := json.Marshal(responseData)
|
||||
w.Write(respJSON)
|
||||
}
|
||||
|
||||
// 获取管理平台登录凭证
|
||||
func (ac Account) Login() (sessionid string) {
|
||||
var password string
|
||||
password = ac.Password
|
||||
var requestBody bytes.Buffer
|
||||
multipartWriter := multipart.NewWriter(&requestBody)
|
||||
multipartWriter.WriteField("username", ac.UserName)
|
||||
multipartWriter.WriteField("password", password)
|
||||
multipartWriter.Close()
|
||||
req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||
//client1 := http.DefaultClient
|
||||
client1 := &http.Client{
|
||||
CheckRedirect: func(req1 *http.Request, via []*http.Request) error {
|
||||
|
||||
strs := strings.Split(req1.URL.Path, ";")
|
||||
log.Printf("%s\r\n", req1.URL.Path)
|
||||
if len(strs) == 2 {
|
||||
strs1 := strings.Split(strs[1], "=")
|
||||
if len(strs1) == 2 {
|
||||
sessionid = strs1[1]
|
||||
}
|
||||
}
|
||||
// fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client1.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to send request:", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 处理响应
|
||||
_, err = ioutil.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Failed to read response:", err)
|
||||
return
|
||||
}
|
||||
//fmt.Println("Response:", string(respBody))
|
||||
return
|
||||
}
|
||||
|
||||
// 移动实名回调
|
||||
func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
timestamp := startTime.Format("2006-01-02 15:04:05")
|
||||
|
||||
// 构建日志内容
|
||||
var logBuilder strings.Builder
|
||||
logBuilder.WriteString("\n========================================\n")
|
||||
logBuilder.WriteString("【移动实名回调】\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
|
||||
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
|
||||
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
|
||||
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
|
||||
|
||||
// 记录请求头
|
||||
logBuilder.WriteString("--- 请求头 ---\n")
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求体
|
||||
logBuilder.WriteString("--- 请求体 ---\n")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
|
||||
} else {
|
||||
if len(body) > 0 {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
|
||||
var JSONData struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
RegStatus string `json:"regStatus"`
|
||||
BusiSeq string `json:"busiSeq"`
|
||||
Msisdn string `json:"msisdn"`
|
||||
Iccid string `json:"iccid"`
|
||||
} `json:"result"`
|
||||
}
|
||||
json.Unmarshal(body, &JSONData)
|
||||
if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" {
|
||||
iccid := JSONData.Result[0].Iccid
|
||||
if iccid == "" {
|
||||
msisdn := JSONData.Result[0].Msisdn //接入号
|
||||
account := Account{UserName: "18627991016", Password: "y123456"}
|
||||
sessionid := account.Login()
|
||||
iccid = getIccidByMsisdn(msisdn, sessionid)
|
||||
}
|
||||
if iccid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
//推送修改过期时间
|
||||
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
ModifyDate(iccid, timestampStr)
|
||||
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
|
||||
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
|
||||
|
||||
// 执行推送实名状态
|
||||
if resp, err := pushToThirdParty(iccid); err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
|
||||
} else {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
|
||||
}
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
|
||||
} else {
|
||||
logBuilder.WriteString("(实名认证失败)\n")
|
||||
}
|
||||
|
||||
} else {
|
||||
logBuilder.WriteString("(空请求体)\n")
|
||||
}
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// 记录处理时间
|
||||
processTime := time.Since(startTime)
|
||||
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
|
||||
logBuilder.WriteString("========================================\n")
|
||||
|
||||
// 写入日志文件
|
||||
writeLog(logBuilder.String())
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Print(logBuilder.String())
|
||||
|
||||
// 返回200 OK响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// 构建响应数据
|
||||
responseData := map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
respJSON, _ := json.Marshal(responseData)
|
||||
w.Write(respJSON)
|
||||
}
|
||||
|
||||
// 联通解除实名回调
|
||||
func UniRealnameRemove(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
timestamp := startTime.Format("2006-01-02 15:04:05")
|
||||
|
||||
// 构建日志内容
|
||||
var logBuilder strings.Builder
|
||||
logBuilder.WriteString("\n========================================\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
|
||||
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
|
||||
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
|
||||
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
|
||||
|
||||
// 记录请求头
|
||||
logBuilder.WriteString("--- 请求头 ---\n")
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求体
|
||||
logBuilder.WriteString("--- 请求体 ---\n")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
|
||||
} else {
|
||||
if len(body) > 0 {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
|
||||
} else {
|
||||
logBuilder.WriteString("(空请求体)\n")
|
||||
}
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// 解析回调数据
|
||||
iccid, dateChanged, err := parseCallback(body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
|
||||
} else {
|
||||
if len(iccid) == 20 {
|
||||
//取前面19位
|
||||
iccid = iccid[:19]
|
||||
//删除实名
|
||||
res := DelRealName(iccid)
|
||||
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
|
||||
}
|
||||
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
writeLog(logBuilder.String())
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Print(logBuilder.String())
|
||||
|
||||
// 构建响应数据
|
||||
responseData := map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
respJSON, _ := json.Marshal(responseData)
|
||||
w.Write(respJSON)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 注册路由
|
||||
// res := DelRealName("8986112422108176397")
|
||||
// log.Printf("删除实名响应: %s", res)
|
||||
|
||||
http.HandleFunc("/5gcmp/callback/realname", handleCallback)
|
||||
http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove)
|
||||
http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback)
|
||||
|
||||
// 启动服务器
|
||||
port := ":16159"
|
||||
fmt.Printf("5GCMP回调服务器启动成功\n")
|
||||
fmt.Printf("监听端口: %s\n", port)
|
||||
fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port)
|
||||
fmt.Printf("联通回调地址: %s/unicom/callback/realname/remove\n", port)
|
||||
fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port)
|
||||
fmt.Printf("日志目录: logs/\n")
|
||||
fmt.Printf("按 Ctrl+C 停止服务器\n\n")
|
||||
|
||||
if err := http.ListenAndServe(port, nil); err != nil {
|
||||
log.Fatalf("启动服务器失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
UserName string
|
||||
Password string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
优化轮询以及添加事件/触发式 数据同步
|
||||
|
||||
目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重
|
||||
所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调
|
||||
|
||||
需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了
|
||||
329
docs/7月迭代/物联网卡管系统-需求.csv
Normal file
329
docs/7月迭代/物联网卡管系统-需求.csv
Normal file
@@ -0,0 +1,329 @@
|
||||
"编号","所属产品","所属模块","所属计划","来源","来源备注","用户需求名称","描述","验收标准","需求层级","父需求","关键词","优先级","预计工时","当前状态","所处阶段","类别","T","B","C","由谁创建","创建日期","指派给","指派日期","抄送给","已评审人","评审时间","由谁关闭","关闭日期","关闭原因","最后修改","最后修改日期","反馈者","重复需求","附件"
|
||||
"98 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换过管理新资产归属","默认换货把旧资产的所属权一起划过去
|
||||
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 14:08:57","黄燚麒","2026-07-16 11:51:00","","
|
||||
黄燚麒(#huang)","2026-07-16 11:50:00","","","","黄燚麒","2026-07-16 11:51:00","","","",""
|
||||
"97 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理钱包阈值提醒","代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。
|
||||
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:23:35","黄燚麒","2026-07-16 15:40:38","","
|
||||
黄燚麒(#huang)","2026-07-16 15:40:00","","","","黄燚麒","2026-07-16 15:40:38","","","",""
|
||||
"96 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","员工作为发展人进行标识","新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。
|
||||
","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:22:00","黄燚麒","2026-07-15 11:23:15","","
|
||||
黄燚麒(#huang)","2026-07-15 11:23:00","","","","李昕娉","2026-07-15 11:23:42","","","",""
|
||||
"94 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","系统状态同步优化以及回调处理","因资产迁移需要涉及回调和状态同步,故进行相关优化
|
||||
","","1 ","0 ","","1(#1)","40.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-14 10:53:29","黄燚麒","2026-07-14 11:00:14","","
|
||||
黄燚麒(#huang)","2026-07-14 10:59:00","","","","李昕娉","2026-07-14 11:01:12","","","",""
|
||||
"86 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情中换货标识","在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src="{128.png}" alt="index.php?m=file&f=read&t=png&fileID=128" /><img src="{129.png}" alt="index.php?m=file&f=read&t=png&fileID=129" />
|
||||
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-13 11:50:17","黄燚麒","2026-07-13 11:54:36","","
|
||||
黄燚麒(#huang)","2026-07-13 11:54:00","","","","黄燚麒","2026-07-13 11:54:36","","","",""
|
||||
"73 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","行业卡操作停复机","行业卡允许未实名复机
|
||||
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-11 10:02:33","黄燚麒","2026-07-11 14:22:26","","
|
||||
黄燚麒(#huang)","2026-07-11 14:22:00","","","","黄燚麒","2026-07-11 14:22:26","","","",""
|
||||
"62 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","新卡管H5需要设置先充值后实名","用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值?
|
||||
","","1 ","0 ","","3(#3)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 17:04:27","李昕娉","2026-07-09 18:13:07","","
|
||||
黄燚麒(#huang)","2026-07-11 14:23:00","","","","黄燚麒","2026-07-11 14:23:36","","","",""
|
||||
"60 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","店铺管理新增检索条件","店铺列表搜索栏新增一项:联系电话,便于搜索
|
||||
","","1 ","0 ","","3(#3)","0.10 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 16:00:56","黄燚麒","2026-07-09 17:37:57","","
|
||||
黄燚麒(#huang)","2026-07-09 17:37:00","","","","黄燚麒","2026-07-09 17:37:57","","","",""
|
||||
"57 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","操作拦截","若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
|
||||
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 15:07:17","黄燚麒","2026-07-09 17:32:56","","
|
||||
黄燚麒(#huang)","2026-07-09 17:32:00","","","","李昕娉","2026-07-09 17:33:21","","","",""
|
||||
"55 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐生效条件","套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本
|
||||
","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 11:57:51","黄燚麒","2026-07-09 16:54:53","","
|
||||
黄燚麒(#huang)","2026-07-09 16:54:00","","","","黄燚麒","2026-07-09 16:54:53","","","",""
|
||||
"54 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情页面新增套餐到期时间显示","增加所有待生效套餐加上生效套餐加起来的最后到期时间
|
||||
","","1 ","0 ","","2(#2)","","已关闭(#closed)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-09 11:56:50","closed","2026-07-09 17:26:38","","
|
||||
黄燚麒(#huang)","2026-07-09 17:26:00","黄燚麒","2026-07-09 17:26:38","重复(#duplicate)","黄燚麒","2026-07-09 17:26:38","","#46 资产信息详情字段新增","",""
|
||||
"53 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产管理查询字段新增","lot卡管理和设备管理新增已实名/未实名的筛选查询条件
|
||||
","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 09:39:53","黄燚麒","2026-07-09 16:53:29","","
|
||||
黄燚麒(#huang)","2026-07-09 16:53:00","","","","黄燚麒","2026-07-09 16:53:29","","","",""
|
||||
"51 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同品类资产的换货","因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。
|
||||
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:54:32","李昕娉","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","黄燚麒","2026-07-09 16:53:05","","","",""
|
||||
"49 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","设备批量分配代理和套餐系列","因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号
|
||||
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:43:58","黄燚麒","2026-07-08 17:43:58","","
|
||||
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:46:08","","","",""
|
||||
"48 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","根据不同的资产使用不同的支付方式","C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:56:52","黄燚麒","2026-07-09 16:45:21","","
|
||||
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:45:21","","","",""
|
||||
"47 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","限速规则","根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则","","1 ","0 ","","3(#3)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:54:39","黄燚麒","2026-07-09 17:30:42","","
|
||||
黄燚麒(#huang)","2026-07-09 17:27:00","","","","黄燚麒","2026-07-09 17:30:42","","","",""
|
||||
"46 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产信息详情字段新增","资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:52:36","黄燚麒","2026-07-09 17:25:53","","
|
||||
黄燚麒(#huang)","2026-07-09 17:25:00","","","","黄燚麒","2026-07-09 17:25:53","","","",""
|
||||
"45 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换货管理","| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 |
|
||||
| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 |
|
||||
| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 |
|
||||
| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |","","1 ","0 ","","1(#1)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:46","","
|
||||
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:46","","","",""
|
||||
"44 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","列表字段新增","| 编号 | 模块 | 新增字段 |
|
||||
| ------- | ------------ | -------------- |
|
||||
| COL-001 | 退款管理列表 | 提交人、审批人 |
|
||||
| COL-002 | 代理充值列表 | 提交人、审批人 |
|
||||
| COL-003 | 换号管理列表 | 提交人 |","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:20","","
|
||||
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:20","","","",""
|
||||
"43 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理系列授权","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 |
|
||||
| AUT-002 | 授权套餐时展示建议售价。 |
|
||||
| AUT-003 | 授权套餐时展示公司成本价。 |
|
||||
| AUT-004 | 已分配套餐和未分配套餐需要明显区分。 |
|
||||
| AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 |","","1 ","0 ","","2(#2)","5.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 17:25:42","","
|
||||
黄燚麒(#huang)","2026-07-09 17:12:00","","","","黄燚麒","2026-07-09 17:25:42","","","",""
|
||||
"42 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","导出功能","#### 6.8.1 lot 卡导出
|
||||
|
||||
##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
|
||||
|
||||
| 编号 | 需求 |
|
||||
| -------- | ---------------------------- |
|
||||
| EXPD-001 | lot 卡导出字段新增套餐名称。 |
|
||||
| EXPD-002 | lot 卡导出字段新增使用流量。 |
|
||||
| EXPD-003 | lot 卡导出字段新增剩余流量。 |
|
||||
|
||||
#### 6.8.2 代理资金概况-预充值钱包流水导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------------- | ------------------------------------------------------------ |
|
||||
| 店铺名称 | 代理店铺名称 |
|
||||
| 交易类型 | 充值、扣款、退款等 |
|
||||
| 交易金额 | 以元为单位 |
|
||||
| 状态 | 交易状态 |
|
||||
| 资产类型 | 卡/设备等 |
|
||||
| 资产标识 | ICCID/设备号等 |
|
||||
| 交易时间 | 流水生成时间 |
|
||||
| 交易前金额 | 交易前余额 |
|
||||
| 交易后金额 | 交易后余额 |
|
||||
| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 |
|
||||
| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) |
|
||||
| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 |
|
||||
| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 |
|
||||
| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 |
|
||||
|
||||
#### 6.8.3 套餐列表导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| -------------- |
|
||||
| 套餐编码 |
|
||||
| 套餐名称 |
|
||||
| 套餐系列名称 |
|
||||
| 套餐类型 |
|
||||
| 套餐时长(月) |
|
||||
| 套餐时长说明 |
|
||||
| 套餐周期类型 |
|
||||
| 套餐天数 |
|
||||
| 真流量额度(MB) |
|
||||
| 虚流量额度(MB) |
|
||||
| 是否启用虚流量 |
|
||||
| 虚流量比例 |
|
||||
| 流量重置周期 |
|
||||
| 到期时间基准 |
|
||||
| 成本价(元) |
|
||||
| 建议售价(元) |
|
||||
| 价格配置状态 |
|
||||
| 状态 |
|
||||
| 上架状态 |
|
||||
| 是否赠送套餐 |
|
||||
| 创建人ID |
|
||||
| 更新人ID |
|
||||
| 创建时间 |
|
||||
| 更新时间 |
|
||||
| 删除时间 |
|
||||
|
||||
#### 6.8.4 退款管理退款列表导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| ---------------- |
|
||||
| 退款单号 |
|
||||
| 代理店铺名称 |
|
||||
| 关联的支付订单号 |
|
||||
| 资产类型 |
|
||||
| 资产标识 |
|
||||
| 套餐名称 |
|
||||
| 原订单金额 |
|
||||
| 实收金额 |
|
||||
| 可退金额 |
|
||||
| 申请退款金额 |
|
||||
| 实际退款金额 |
|
||||
| 退款到账方式 |
|
||||
| 状态 |
|
||||
| 退款原因 |
|
||||
| 备注 |
|
||||
| 审批备注 |
|
||||
| 退款申请时间 |
|
||||
| 退款完成时间 |
|
||||
| 提交人 |
|
||||
| 部门领导审批人 |
|
||||
| 财务审批人 |
|
||||
| 退款凭证 |
|
||||
|
||||
#### 6.8.5 换货管理导出
|
||||
|
||||
##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| ------------ |
|
||||
| 换货单号 |
|
||||
| 换货类型 |
|
||||
| 换货原因 |
|
||||
| 问题描述 |
|
||||
| 旧资产类型 |
|
||||
| 旧资产标识符 |
|
||||
| 新资产标识符 |
|
||||
| 收货人姓名 |
|
||||
| 收货人电话 |
|
||||
| 收货地址 |
|
||||
| 快递公司 |
|
||||
| 快递单号 |
|
||||
| 状态 |
|
||||
| 创建人 |
|
||||
| 创建时间 |
|
||||
|
||||
#### 6.8.6 代理充值导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| -------------- |
|
||||
| 充值单号 |
|
||||
| 店铺名称 |
|
||||
| 充值类型 |
|
||||
| 充值金额 |
|
||||
| 实付金额 |
|
||||
| 充值前余额 |
|
||||
| 充值后余额 |
|
||||
| 状态 |
|
||||
| 支付方式 |
|
||||
| 支付通道 |
|
||||
| 运营备注 |
|
||||
| 驳回原因 |
|
||||
| 创建时间 |
|
||||
| 支付时间 |
|
||||
| 完成时间 |
|
||||
| 提交人 |
|
||||
| 部门领导审批人 |
|
||||
| 财务审批人 |
|
||||
| 支付凭证 |
|
||||
| 备注 |","","1 ","0 ","","1(#1)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:42:07","","
|
||||
黄燚麒(#huang)","2026-07-09 16:40:00","","","","黄燚麒","2026-07-09 16:42:07","","","",""
|
||||
"41 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理查询限制(类似酷蛙这种客户)","下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。
|
||||
| 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 |
|
||||
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","李昕娉","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","黄燚麒","2026-07-11 14:25:30","","","",""
|
||||
"40 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐设计","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------ |
|
||||
| PKG-001 | 套餐需标准化管理。 |
|
||||
| PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 |
|
||||
| PKG-003 | 下架套餐续费仅支持客户自己购买。 |
|
||||
| PKG-004 | 下架套餐不可被新购买。 |","","1 ","0 ","","2(#2)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 17:11:34","","
|
||||
黄燚麒(#huang)","2026-07-09 17:10:00","","","","黄燚麒","2026-07-09 17:11:34","","","",""
|
||||
"38 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同渠道额度处理","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 |
|
||||
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
|
||||
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
|
||||
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |","","1 ","0 ","","2(#2)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:58:16","","
|
||||
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:58:16","","","",""
|
||||
"37 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","审核流转","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| APR-001 | 代理可在系统提交充值申请。 |
|
||||
| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 |
|
||||
| APR-003 | 充值和退款均支持多级审核。 |
|
||||
| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 |
|
||||
| APR-005 | 待审核订单需有消息提示。 |
|
||||
| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 |
|
||||
| APR-007 | 审核通过后通知申请人。 |
|
||||
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
|
||||
| APR-009 | 审核流程需对接企业微信审批流程。 |","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:57:35","","
|
||||
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:57:35","","","",""
|
||||
"36 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","批量订购套餐","1. 内部员工进入批量订购页面。
|
||||
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
|
||||
3. Excel表头:跳转至6.3会显示。
|
||||
4. 系统校验导入文件和资产状态。
|
||||
5. 校验通过的资产从代理余额扣款并完成订购。
|
||||
6. 校验失败的明细在页面展示失败原因。
|
||||
7. 系统记录操作员、导入明细、导入数量和导入时间。
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| BPO-001 | 批量订购由内部员工操作。 |
|
||||
| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 |
|
||||
| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 |
|
||||
| BPO-004 | 批量订购无需审核。 |
|
||||
| BPO-005 | 资产标识支持 ICCID/设备号。 |
|
||||
| BPO-006 | 支持 Excel 模板导入。 |
|
||||
| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 |
|
||||
| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 |
|
||||
|
||||
excel表导入字段:
|
||||
| 字段 |
|
||||
| ----------------------------- |
|
||||
| 资产类型 |
|
||||
| 资产标识 |
|
||||
| 套餐系列名称 |
|
||||
| 套餐名称 |
|
||||
| 代理名称 |
|
||||
| 支付方式:代理商账户/员工账户 |","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:31:53","","
|
||||
黄燚麒(#huang)","2026-07-09 16:31:00","","","","黄燚麒","2026-07-09 16:31:53","","","",""
|
||||
"35 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款审核","(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 员工提交退款申请。
|
||||
2. 退款单进入多级审核流程。
|
||||
3. 按部门领导、财务顺序审批。
|
||||
4. 当前环节审批完成后,下一环节审批人收到消息提示。
|
||||
5. 审批通过或驳回后通知申请人。","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:56:31","","
|
||||
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:56:31","","","",""
|
||||
"34 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","充值审核流程","代理自己充值:
|
||||
1. 代理在系统提交充值申请。
|
||||
2. 系统展示收款二维码。
|
||||
3. 代理扫码支付。
|
||||
|
||||
员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 充值单进入多级审核流程。
|
||||
2. 提交人部门领导先审批。
|
||||
3. 财务在上一审批人通过后收到待办提醒并审批。
|
||||
4. 审批通过后通知申请人。
|
||||
5. 审批驳回后通知申请人,并展示驳回原因。","","1 ","0 ","","2(#2)","32.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:55:42","","
|
||||
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:55:42","","","",""
|
||||
"33 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐临期提醒","1. 系统每日计算卡/设备套餐剩余有效期。
|
||||
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
|
||||
3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
|
||||
4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
|
||||
5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。
|
||||
6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。
|
||||
#### 6.1.1 企业客户临期提醒
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-001 | 后台每日生成企业客户临期列表。 |
|
||||
| EXP-002 | 临期节点包括 15 天、7 天、3 天。 |
|
||||
| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 |
|
||||
| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 |
|
||||
|
||||
#### 6.1.2 代理端临期提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 |
|
||||
| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 |
|
||||
| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 |
|
||||
| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 |
|
||||
|
||||
#### 6.1.3 C 端公众号首页提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 |
|
||||
| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 |
|
||||
| EXP-011 | 按钮文案为“立即续费”。 |
|
||||
| EXP-012 | 剩余天数需要按日期每天自动更新。 |
|
||||
| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 |
|
||||
推荐展示文案:
|
||||
您的套餐即将到期
|
||||
|
||||
卡号/设备号:xxx
|
||||
剩余有效期:xx 天
|
||||
|
||||
为避免到期后影响正常使用,请您提前完成续费。","","1 ","0 ","","1(#1)","","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:30:06","","
|
||||
黄燚麒(#huang)","2026-07-09 16:29:00","","","","黄燚麒","2026-07-09 16:30:06","","","",""
|
||||
|
271
docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md
Normal file
271
docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 7月迭代前端技术方案
|
||||
|
||||
> 历史状态:共性前端来源稿;本地审批交互已废弃,最终以前端标准章节和各独立方案为准。
|
||||
> 当前方案:[标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
|
||||
> 适用端:后台管理端、代理端、C 端 H5/公众号
|
||||
> 最后更新:2026-07-14
|
||||
> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本方案解决七月迭代中跨需求的前端共性问题:
|
||||
|
||||
- 审批任务、退款和充值使用同一套动态审批展示。
|
||||
- Excel 导入、批量订购和导出使用统一的异步任务交互。
|
||||
- 站内消息统一未读数、列表、已读和业务跳转。
|
||||
- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。
|
||||
- 金额、状态、时间、权限和错误展示使用统一口径。
|
||||
|
||||
本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。
|
||||
|
||||
---
|
||||
|
||||
## 二、系统上下文
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端]
|
||||
Customer[C端客户] --> ClientWeb[C端 H5/公众号]
|
||||
AdminWeb -->|/api/admin/*| API[Junhong API]
|
||||
ClientWeb -->|/api/c/v1/*| API
|
||||
API --> DB[(PostgreSQL)]
|
||||
API --> Redis[(Redis)]
|
||||
API --> Queue[Asynq]
|
||||
Queue --> Worker[Worker]
|
||||
Worker --> APIData[业务数据/对象存储/Gateway]
|
||||
AdminWeb -->|轮询未读数、任务状态| API
|
||||
```
|
||||
|
||||
前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。
|
||||
|
||||
---
|
||||
|
||||
## 三、模块边界
|
||||
|
||||
建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构:
|
||||
|
||||
| 模块 | 负责内容 | 不负责内容 |
|
||||
|------|----------|------------|
|
||||
| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 |
|
||||
| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 |
|
||||
| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 |
|
||||
| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 |
|
||||
| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 |
|
||||
| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON |
|
||||
|
||||
全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。
|
||||
|
||||
---
|
||||
|
||||
## 四、接口与类型约定
|
||||
|
||||
### 4.1 路径前缀
|
||||
|
||||
- 后台管理:`/api/admin/*`
|
||||
- C 端:`/api/c/v1/*`
|
||||
- 回调接口不由前端调用。
|
||||
|
||||
专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。
|
||||
|
||||
### 4.2 枚举和显示
|
||||
|
||||
- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。
|
||||
- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。
|
||||
- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。
|
||||
- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。
|
||||
|
||||
### 4.3 请求幂等
|
||||
|
||||
审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。
|
||||
|
||||
按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。
|
||||
|
||||
---
|
||||
|
||||
## 五、统一异步任务交互
|
||||
|
||||
适用:设备批量分配、批量订购、导出任务。
|
||||
|
||||
不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Editing: 填写参数/选择文件
|
||||
Editing --> Submitting: 提交
|
||||
Submitting --> Processing: 创建任务成功
|
||||
Submitting --> Editing: 参数或上传失败
|
||||
Processing --> Processing: 轮询进度
|
||||
Processing --> Completed: 全部或部分完成
|
||||
Processing --> Failed: 任务失败
|
||||
Processing --> Cancelled: 用户取消且后端确认
|
||||
Completed --> [*]
|
||||
Failed --> Editing: 修正后重新提交
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
### 5.1 轮询规则
|
||||
|
||||
- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。
|
||||
- 页面不可见时暂停轮询,恢复可见时立即刷新。
|
||||
- 达到终态、离开页面或组件销毁时停止轮询。
|
||||
- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。
|
||||
- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。
|
||||
|
||||
### 5.2 结果展示
|
||||
|
||||
- 必须同时展示总数、成功数、失败数和任务状态。
|
||||
- 部分成功不能只弹一个成功 Toast;失败明细要留在页面,并支持下载或复制,具体能力按专项方案。
|
||||
- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。
|
||||
|
||||
### 5.3 Excel 模板
|
||||
|
||||
- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。
|
||||
- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。
|
||||
- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。
|
||||
- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。
|
||||
|
||||
---
|
||||
|
||||
## 六、统一审批交互
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading
|
||||
Loading --> ReadOnly: 当前用户无待处理资格
|
||||
Loading --> Actionable: 当前用户是待处理审批人
|
||||
Actionable --> EditingAction: 打开通过/驳回/退回弹框
|
||||
EditingAction --> Uploading: 上传附件
|
||||
Uploading --> EditingAction: 上传成功或失败后返回
|
||||
EditingAction --> Submitting: 意见和附件校验通过
|
||||
Submitting --> EditingAction: 请求失败且任务仍可操作
|
||||
Submitting --> ReadOnly: 操作成功或状态已被他人改变
|
||||
ReadOnly --> [*]
|
||||
```
|
||||
|
||||
### 6.1 页面建议
|
||||
|
||||
| 页面 | 建议路由 | 主要能力 |
|
||||
|------|----------|----------|
|
||||
| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 |
|
||||
| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 |
|
||||
| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 |
|
||||
| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 |
|
||||
|
||||
第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。
|
||||
|
||||
### 6.2 动态详情
|
||||
|
||||
审批详情按接口返回的 `tasks[]` 和 `assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。
|
||||
|
||||
详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。
|
||||
|
||||
操作按钮显示条件同时满足:
|
||||
|
||||
- 流程状态为审批中。
|
||||
- 当前任务状态为待审批。
|
||||
- 当前账号对应的审批人状态为待处理。
|
||||
- 接口返回相应操作权限。
|
||||
|
||||
操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。
|
||||
|
||||
审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存,请求结束立即清空。节点没有动作字段时不显示额外表单,禁止根据“财务审核”等节点名称自行添加输入项。
|
||||
|
||||
每个通过、驳回、退回弹框统一包含“审批意见”和“附件”:
|
||||
|
||||
- 通过意见可选;驳回、退回意见必填,最多 1000 字。
|
||||
- 意见使用普通多行文本框,不提供富文本编辑器。
|
||||
- 附件最多 5 个、单个默认不超过 20MB,允许图片、PDF、Word、Excel;打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。
|
||||
- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。
|
||||
- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。
|
||||
- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL,不直接拼对象存储地址。
|
||||
|
||||
### 6.3 业务处理中的展示
|
||||
|
||||
审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示:
|
||||
|
||||
- 审批状态。
|
||||
- 业务处理状态。
|
||||
- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。
|
||||
|
||||
不能仅根据业务单原状态显示“待审批”。
|
||||
|
||||
### 6.4 存量历史记录
|
||||
|
||||
业务详情接口增加 `approval_source`:
|
||||
|
||||
- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。
|
||||
- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`。
|
||||
- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。
|
||||
|
||||
如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。
|
||||
|
||||
---
|
||||
|
||||
## 七、页面与需求映射
|
||||
|
||||
| 需求 | 端 | 页面/入口 | 关键交互 |
|
||||
|------|----|-----------|----------|
|
||||
| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 |
|
||||
| 02 | 后台 + C端 | 卡/设备列表实名策略;C端流程页 | 单条/批量设置,C端按接口策略跳转 |
|
||||
| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 |
|
||||
| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 |
|
||||
| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 |
|
||||
| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式,C端隐藏并由后端兜底拦截 |
|
||||
| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 |
|
||||
| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 |
|
||||
| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 |
|
||||
| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 |
|
||||
| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 |
|
||||
| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 |
|
||||
| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 |
|
||||
| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 |
|
||||
|
||||
---
|
||||
|
||||
## 八、站内消息
|
||||
|
||||
- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。
|
||||
- 页面不可见时暂停,恢复时立即刷新。
|
||||
- 铃铛数字超过 99 显示 `99+`。
|
||||
- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。
|
||||
- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。
|
||||
- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。
|
||||
|
||||
---
|
||||
|
||||
## 九、权限与敏感数据
|
||||
|
||||
- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。
|
||||
- 审批人资格由任务接口返回,前端不根据角色名称推导。
|
||||
- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。
|
||||
- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。
|
||||
- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。
|
||||
|
||||
---
|
||||
|
||||
## 十、停机发布
|
||||
|
||||
1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。
|
||||
2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。
|
||||
3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。
|
||||
4. 前端只调用任务级审批 API,不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。
|
||||
5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。
|
||||
6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。
|
||||
|
||||
前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。
|
||||
|
||||
---
|
||||
|
||||
## 十一、待前端仓库确认
|
||||
|
||||
以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认:
|
||||
|
||||
- 后台、代理端和 C 端分别使用的框架版本与目录结构。
|
||||
- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。
|
||||
- 是否已有通用任务轮询组件和流程时间线组件。
|
||||
- 实际菜单路由和按钮权限编码。
|
||||
- 表格是否支持服务端返回的动态导出字段配置。
|
||||
|
||||
确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。
|
||||
1174
docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md
Normal file
1174
docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,36 @@
|
||||
# 基础设施:站内消息(Notification)
|
||||
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> 历史状态:初版方案,已被站内通知详细方案替代。
|
||||
> 替代方案:[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
- **业务代码不直接写通知**:只发 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 +40,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, -- 用户ID(admin 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 +54,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 +97,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 +118,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 +144,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 +201,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 +213,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 +233,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 +244,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 +284,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,29 +338,32 @@ type ReadAllRequest struct {
|
||||
### 顶部导航栏铃铛
|
||||
|
||||
```
|
||||
组件挂载 → 轮询 GET /admin/notifications/unread-count(30秒一次)
|
||||
组件挂载 → 轮询 GET /api/admin/notifications/unread-count(30秒一次)
|
||||
→ 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)
|
||||
|
||||
筛选:通知类型(下拉)、已读状态
|
||||
操作:全部已读按钮
|
||||
列表字段:类型、标题、时间、已读状态
|
||||
筛选:通知类型(下拉)、已读状态
|
||||
操作:全部已读按钮
|
||||
列表字段:类型、标题、时间、已读状态
|
||||
点击行:跳转关联业务详情
|
||||
|
||||
前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。
|
||||
|
||||
---
|
||||
|
||||
## 九、Phase 2 扩展预留
|
||||
@@ -1,5 +1,7 @@
|
||||
# 需求01:行业卡后台手动复机允许未实名
|
||||
|
||||
> 状态:原需求来源稿;实名最终规则已由数据同步方案和标准评审稿修正。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
@@ -1,5 +1,7 @@
|
||||
# 需求02:H5 流程顺序配置化
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
@@ -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` |
|
||||
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
|
||||
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |
|
||||
@@ -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`):
|
||||
@@ -135,41 +140,26 @@ if req.RealNameStatus != nil {
|
||||
|
||||
### 前端
|
||||
|
||||
IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。
|
||||
IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。
|
||||
设备管理同上,列表展示 `real_name_status_name` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 需求11:资产详情-套餐到期时间字段 + 15天高亮
|
||||
## 需求11:资产详情套餐到期时间(与需求06合并)
|
||||
|
||||
### 后端
|
||||
需求11和需求06属于同一业务需求:资产层只展示当前及排队主套餐连续使用后的预计最终到期时间。详细计算、DTO 和前端规则统一见[需求04/06独立稿](./需求04-06-退款拦截与最后到期时间.md)。
|
||||
|
||||
**无需改动。**
|
||||
现有 `current_package_expires_at` 只表示当前套餐结束时间,不能代表资产服务最终结束时间,因此不得继续作为资产详情汇总到期时间或临期判断依据。
|
||||
|
||||
后台资产详情页实际调用的是:
|
||||
统一使用:
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```text
|
||||
estimated_final_expires_at
|
||||
days_until_final_expiry
|
||||
expiry_estimate_status
|
||||
```
|
||||
|
||||
该接口的 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)已包含:
|
||||
|
||||
```go
|
||||
CurrentPackage string `json:"current_package"` // 当前套餐名称
|
||||
CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间
|
||||
CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at"` // 到期时间(无套餐为 null)
|
||||
```
|
||||
|
||||
到期时间字段已有,前端直接读 `current_package_expires_at` 即可,不需要新增后端字段。
|
||||
|
||||
### 前端
|
||||
|
||||
资产详情页"套餐信息"板块展示到期时间,并在剩余 ≤15 天时高亮:
|
||||
|
||||
- 读取 `resolve` 接口返回的 `current_package_expires_at`
|
||||
- 若为 `null`:展示"暂无套餐"
|
||||
- 剩余天数由前端计算:`Math.ceil((expiresAt - now) / 86400000)`
|
||||
- 剩余 ≤15 天:**红色/高亮**展示(建议红色文字 + 标签)
|
||||
前端不自行计算天数;高亮颜色和临期状态直接使用需求22统一返回字段。
|
||||
|
||||
---
|
||||
|
||||
@@ -255,7 +245,9 @@ if req.OldAssetKeyword != "" {
|
||||
|
||||
### 核心原则
|
||||
|
||||
提交人、审批人均在**写入时快照**到单据本身,读取时直接返回,不做额外查询。
|
||||
- 提交人账号名在业务单创建时快照到业务表。
|
||||
- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。
|
||||
- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。
|
||||
|
||||
---
|
||||
|
||||
@@ -287,35 +279,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` 时显示“历史审批”标识且不提供操作按钮。
|
||||
206
docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md
Normal file
206
docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 需求04:退款中资产禁止换货
|
||||
# 需求06/11:资产预计最终到期时间
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 需求04:退款中禁止换货
|
||||
|
||||
### 业务规则
|
||||
|
||||
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
|
||||
|
||||
拦截范围:
|
||||
|
||||
- `status=1` 待审批。
|
||||
- `status=4` 已退回,等待提交人修改。
|
||||
- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。
|
||||
|
||||
不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。
|
||||
|
||||
### 数据模型
|
||||
|
||||
退款模型:`RefundRequest`(表 `tb_refund_request`)
|
||||
|
||||
资产字段为两个独立字段(无 asset_type/asset_id):
|
||||
- `iot_card_id *uint`:IoT卡ID(卡类资产)
|
||||
- `device_id *uint`:设备ID(设备类资产)
|
||||
|
||||
状态常量(`internal/model/refund.go`):
|
||||
```go
|
||||
RefundStatusPending = 1 // 待审批
|
||||
RefundStatusApproved = 2 // 已通过
|
||||
RefundStatusRejected = 3 // 已拒绝
|
||||
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
|
||||
```
|
||||
|
||||
### 实现位置
|
||||
|
||||
换货单创建入口:`internal/service/exchange/service.go` 创建前校验。
|
||||
|
||||
### 后端
|
||||
|
||||
**Store 新增方法**(`internal/store/postgres/refund_store.go`):
|
||||
|
||||
```go
|
||||
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
|
||||
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 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
|
||||
}
|
||||
|
||||
// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
|
||||
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 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
|
||||
}
|
||||
```
|
||||
|
||||
**Service 校验**(`internal/service/exchange/service.go` 创建换货单前调用):
|
||||
|
||||
```go
|
||||
// validateNoActiveRefund 校验资产是否有未结束的退款申请
|
||||
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
|
||||
var hasActive bool
|
||||
var err error
|
||||
if asset.CardID != nil {
|
||||
hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
|
||||
} else if asset.DeviceID != nil {
|
||||
hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasActive {
|
||||
return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
无需改动。换货申请时后端返回错误,前端展示错误信息即可。
|
||||
|
||||
---
|
||||
|
||||
## 需求06/11:资产详情-预计最终到期时间
|
||||
|
||||
### 业务规则
|
||||
|
||||
需求06与需求11是同一个业务需求的两种描述,不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间**。
|
||||
|
||||
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
|
||||
|
||||
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
|
||||
|
||||
### 接口
|
||||
|
||||
后台资产详情页实际调用的是:
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```
|
||||
|
||||
响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`)
|
||||
|
||||
该 DTO 目前只有当前套餐到期时间,需新增统一的最终到期响应,并由前端替代原资产汇总展示。
|
||||
|
||||
### 后端
|
||||
|
||||
**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`):
|
||||
|
||||
```go
|
||||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"`
|
||||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余自然日"`
|
||||
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"`
|
||||
```
|
||||
|
||||
**Store 新增方法**(`internal/store/postgres/package_usage_store.go`):
|
||||
|
||||
```go
|
||||
// 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 master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
|
||||
).
|
||||
Order("priority ASC, created_at ASC, id ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
|
||||
// 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 master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
|
||||
).
|
||||
Order("priority ASC, created_at ASC, id ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
```
|
||||
|
||||
新建 `PackageUsage` 必须快照 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`,与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。
|
||||
|
||||
**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加):
|
||||
|
||||
```go
|
||||
// 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.EstimatedFinalExpiresAt = lastExpiry
|
||||
```
|
||||
|
||||
`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。
|
||||
|
||||
### 前端
|
||||
|
||||
资产详情“套餐信息”板块只保留一个资产汇总展示:
|
||||
|
||||
```
|
||||
预计套餐到期时间:2027-01-01
|
||||
```
|
||||
|
||||
读取 `estimated_final_expires_at`。`exact` 时展示日期;`waiting_activation` 时展示“待激活后起算”;`none` 时展示“—”。当前套餐自身的 `expires_at` 仅在套餐明细列表展示,不再作为第二个资产汇总字段。
|
||||
|
||||
高亮和临期提醒统一使用 `days_until_final_expiry`,前端不得再根据 `current_package_expires_at` 自行计算另一套剩余天数。
|
||||
@@ -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`。
|
||||
206
docs/7月迭代/独立方案/原需求/需求08-设备批量分配Excel.md
Normal file
206
docs/7月迭代/独立方案/原需求/需求08-设备批量分配Excel.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 需求08:设备批量分配代理或套餐系列(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: 处理结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同):
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_device_batch_allocation_task (
|
||||
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,
|
||||
task_no VARCHAR(30) NOT NULL,
|
||||
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,
|
||||
fail_count INT NOT NULL DEFAULT 0,
|
||||
status INT NOT NULL DEFAULT 1,
|
||||
failed_items JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
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`),失败条数有限,无需单独明细表。
|
||||
|
||||
---
|
||||
|
||||
## Model(`internal/model/device_batch_allocation_task.go`)
|
||||
|
||||
```go
|
||||
// DeviceBatchAllocationTask 设备批量分配任务模型
|
||||
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"`
|
||||
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"`
|
||||
FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
|
||||
StartedAt *time.Time `gorm:"column:started_at" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"`
|
||||
}
|
||||
|
||||
func (DeviceBatchAllocationTask) TableName() string {
|
||||
return "tb_device_batch_allocation_task"
|
||||
}
|
||||
```
|
||||
|
||||
> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。
|
||||
|
||||
---
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 前端静态 Excel 模板
|
||||
|
||||
模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。
|
||||
|
||||
- 文件建议命名:`设备批量分配模板-v1.xlsx`
|
||||
- 表头:`设备号`(一列)
|
||||
- 前端“下载模板”按钮直接下载静态文件。
|
||||
- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。
|
||||
|
||||
模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。
|
||||
|
||||
### 2. 上传并提交
|
||||
|
||||
```
|
||||
POST /api/admin/devices/batch-assign-shop
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
字段:
|
||||
shop_id: 123 (必填,分配给哪个代理)
|
||||
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. 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 /api/admin/devices/batch-allocation/{task_id}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_no": "DBA20240101001",
|
||||
"operation_type": "assign_shop",
|
||||
"target_id": 123,
|
||||
"status": 2,
|
||||
"status_name": "已完成",
|
||||
"total_count": 100,
|
||||
"success_count": 95,
|
||||
"fail_count": 5,
|
||||
"failed_items": [
|
||||
{ "line": 3, "virtual_no": "12345", "reason": "设备号不存在" },
|
||||
{ "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" }
|
||||
],
|
||||
"started_at": "2024-01-01T10:00:00Z",
|
||||
"completed_at": "2024-01-01T10:00:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 入口
|
||||
|
||||
设备管理页 > 批量操作:
|
||||
|
||||
- "批量分配代理"
|
||||
- "批量分配套餐系列"
|
||||
|
||||
### 操作流程
|
||||
|
||||
1. 点击其中一个批量操作入口。
|
||||
2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。
|
||||
3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop` 或 `POST /api/admin/devices/batch-assign-series`。
|
||||
4. 提示"任务提交成功,正在处理..."
|
||||
5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
|
||||
6. 展示结果:成功X条,失败X条,失败明细在页面展示
|
||||
|
||||
### Excel 规范
|
||||
|
||||
- 表头:`设备号`
|
||||
- 每行一个设备号(支持虚拟号或IMEI)
|
||||
@@ -1,6 +1,7 @@
|
||||
# 需求09:C端支付方式限制配置化
|
||||
|
||||
> 依赖:[系统配置](./基础设施/系统配置.md)
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
> 依赖:[系统配置](../基础规范/系统配置.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`。
|
||||
|
||||
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。
|
||||
136
docs/7月迭代/独立方案/原需求/需求10-限速规则.md
Normal file
136
docs/7月迭代/独立方案/原需求/需求10-限速规则.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 需求10:Gateway 手动卡限速
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
> 已确认边界:本期只提供手动设置/取消限速;Gateway 最终对象始终是卡,统一使用 `cardNo`。
|
||||
|
||||
---
|
||||
|
||||
## 一、范围
|
||||
|
||||
本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。
|
||||
|
||||
- 单卡资产:使用 `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
|
||||
// GatewaySpeedLimitPort 设置或取消卡限速。
|
||||
// speedKbps=0 表示取消,具体上游参数由适配器转换。
|
||||
type GatewaySpeedLimitPort interface {
|
||||
SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error
|
||||
}
|
||||
|
||||
// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。
|
||||
type SpeedLimitCardResolver interface {
|
||||
ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试:
|
||||
|
||||
```sql
|
||||
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` 是“设为目标状态”的幂等操作。
|
||||
|
||||
---
|
||||
|
||||
## 四、接口与前端
|
||||
|
||||
```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 的实时状态。
|
||||
|
||||
---
|
||||
|
||||
## 五、审计与人工验收
|
||||
|
||||
每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。
|
||||
|
||||
人工验收覆盖:
|
||||
|
||||
1. 单卡按 ICCID 设置限速。
|
||||
2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。
|
||||
3. 设备无当前卡时拒绝调用 Gateway。
|
||||
4. `speed_kbps=0` 经同一 Gateway 方法取消限速。
|
||||
5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。
|
||||
6. Gateway 失败记录错误并允许相同目标值重试。
|
||||
|
||||
上线前置条件:Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。
|
||||
412
docs/7月迭代/独立方案/原需求/需求14-导出功能.md
Normal file
412
docs/7月迭代/独立方案/原需求/需求14-导出功能.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# 需求14:导出功能
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
> 复用现有 ExportTask 体系(`tb_export_task` + Asynq),不为每个业务模块新增一套导出路由。
|
||||
|
||||
---
|
||||
|
||||
## 导出模块总览
|
||||
|
||||
| 编号 | 模块 | 新增/修改 |
|
||||
|------|------|---------|
|
||||
| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 |
|
||||
| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** |
|
||||
| 6.8.3 | 套餐列表导出 | **全新** |
|
||||
| 6.8.4 | 退款管理退款列表导出 | **全新** |
|
||||
| 6.8.5 | 换货管理导出 | **全新** |
|
||||
| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) |
|
||||
| 需求22 | 临期资产列表导出 | **全新**,`scene=expiring_asset` |
|
||||
|
||||
---
|
||||
|
||||
## 现有导出体系说明
|
||||
|
||||
系统已有异步导出框架(`internal/exporter/`):
|
||||
- `tb_export_task` 表记录导出任务
|
||||
- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`)
|
||||
- `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景
|
||||
- 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. 扩展统一 `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/` 必须同步维护。
|
||||
|
||||
---
|
||||
|
||||
## EXPD-001~003:IoT卡导出字段新增
|
||||
|
||||
**修改文件**:`internal/exporter/iot_card_scene.go`
|
||||
|
||||
在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段:
|
||||
|
||||
```go
|
||||
// Headers() 新增
|
||||
"套餐名称", "使用流量(MB)", "剩余流量(MB)"
|
||||
|
||||
// baseQuery() 或 Fetch() 新增 JOIN
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name
|
||||
FROM tb_package_usage pu
|
||||
JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL
|
||||
WHERE pu.iot_card_id = c.id AND pu.status = 1
|
||||
AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
) AS pkg ON TRUE
|
||||
|
||||
// iotCardExportRow 新增
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb"`
|
||||
```
|
||||
|
||||
剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算)
|
||||
|
||||
---
|
||||
|
||||
## 6.8.2:代理资金概况-预充值钱包流水导出
|
||||
|
||||
**新增场景**:`scene=agent_wallet_transaction`
|
||||
|
||||
支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。
|
||||
|
||||
导出字段映射:
|
||||
|
||||
| 字段 | 数据来源 |
|
||||
|------|---------|
|
||||
| 店铺名称 | JOIN `tb_shop` |
|
||||
| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) |
|
||||
| 交易金额 | `amount / 100` 转元 |
|
||||
| 状态 | `status` 中文化 |
|
||||
| 资产类型 | `asset_type` 中文化 |
|
||||
| 资产标识 | `asset_identifier` |
|
||||
| 交易时间 | `created_at` |
|
||||
| 交易前金额 | `balance_before / 100` |
|
||||
| 交易后金额 | `balance_after / 100` |
|
||||
| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 |
|
||||
| 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) |
|
||||
| 交易 ID | `id` |
|
||||
| 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) |
|
||||
| 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 |
|
||||
|
||||
---
|
||||
|
||||
## 6.8.3:套餐列表导出
|
||||
|
||||
**新增场景**:`scene=package`
|
||||
|
||||
支持现有套餐列表筛选条件。
|
||||
|
||||
导出字段(按实际列举的 25 个稳定字段 Key):
|
||||
|
||||
```go
|
||||
type PackageExportRow struct {
|
||||
PackageCode string `xlsx:"套餐编码"`
|
||||
PackageName string `xlsx:"套餐名称"`
|
||||
SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series
|
||||
PackageType string `xlsx:"套餐类型"` // formal/addon 中文化
|
||||
DurationMonths int `xlsx:"套餐时长(月)"`
|
||||
DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明
|
||||
CalendarType string `xlsx:"套餐周期类型"`
|
||||
DurationDays int `xlsx:"套餐天数"`
|
||||
RealDataMB int64 `xlsx:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `xlsx:"虚流量额度(MB)"`
|
||||
EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否
|
||||
VirtualRatio float64 `xlsx:"虚流量比例"`
|
||||
DataResetCycle string `xlsx:"流量重置周期"`
|
||||
ExpiryBase string `xlsx:"到期时间基准"`
|
||||
CostPrice string `xlsx:"成本价(元)"` // 分→元
|
||||
SuggestedRetailPrice string `xlsx:"建议售价(元)"`
|
||||
PriceConfigStatus string `xlsx:"价格配置状态"`
|
||||
Status string `xlsx:"状态"`
|
||||
ShelfStatus string `xlsx:"上架状态"`
|
||||
IsGift string `xlsx:"是否赠送套餐"`
|
||||
CreatorID uint `xlsx:"创建人ID"`
|
||||
UpdaterID uint `xlsx:"更新人ID"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
UpdatedAt string `xlsx:"更新时间"`
|
||||
DeletedAt string `xlsx:"删除时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.4:退款管理退款列表导出
|
||||
|
||||
**新增场景**:`scene=refund`
|
||||
|
||||
导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点):
|
||||
|
||||
```go
|
||||
type RefundExportRow struct {
|
||||
RefundNo string `xlsx:"退款单号"`
|
||||
ShopName string `xlsx:"代理店铺名称"`
|
||||
PaymentOrderNo string `xlsx:"关联的支付订单号"`
|
||||
AssetType string `xlsx:"资产类型"`
|
||||
AssetIdentifier string `xlsx:"资产标识"`
|
||||
PackageName string `xlsx:"套餐名称"`
|
||||
OriginalAmount string `xlsx:"原订单金额"`
|
||||
ActualAmount string `xlsx:"实收金额"`
|
||||
RefundableAmount string `xlsx:"可退金额"`
|
||||
AppliedAmount string `xlsx:"申请退款金额"`
|
||||
ActualRefundAmount string `xlsx:"实际退款金额"`
|
||||
Status string `xlsx:"状态"`
|
||||
RefundReason string `xlsx:"退款原因"`
|
||||
Remark 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:"提交人"`
|
||||
VoucherURLs string `xlsx:"退款凭证"`
|
||||
}
|
||||
```
|
||||
|
||||
`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。
|
||||
|
||||
---
|
||||
|
||||
## 6.8.5:换货管理导出
|
||||
|
||||
**新增场景**:`scene=exchange`
|
||||
|
||||
```go
|
||||
type ExchangeExportRow struct {
|
||||
ExchangeNo string `xlsx:"换货单号"`
|
||||
ExchangeType string `xlsx:"换货类型"`
|
||||
ExchangeReason string `xlsx:"换货原因"`
|
||||
ProblemDesc string `xlsx:"问题描述"`
|
||||
OldAssetType string `xlsx:"旧资产类型"`
|
||||
OldAssetIdentifier string `xlsx:"旧资产标识符"`
|
||||
NewAssetIdentifier string `xlsx:"新资产标识符"`
|
||||
ReceiverName string `xlsx:"收货人姓名"`
|
||||
ReceiverPhone string `xlsx:"收货人电话"`
|
||||
ReceiverAddress string `xlsx:"收货地址"`
|
||||
ExpressCompany string `xlsx:"快递公司"`
|
||||
TrackingNo string `xlsx:"快递单号"`
|
||||
Status string `xlsx:"状态"`
|
||||
CreatorName string `xlsx:"创建人"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.6:代理充值导出
|
||||
|
||||
**新增场景**:`scene=agent_recharge`
|
||||
|
||||
去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:
|
||||
|
||||
```go
|
||||
type AgentRechargeExportRow struct {
|
||||
RechargeNo string `xlsx:"充值单号"`
|
||||
ShopName string `xlsx:"店铺名称"`
|
||||
RechargeType string `xlsx:"充值类型"`
|
||||
RechargeAmount string `xlsx:"充值金额"`
|
||||
ActualAmount string `xlsx:"实付金额"`
|
||||
BalanceBefore string `xlsx:"充值前余额"`
|
||||
BalanceAfter string `xlsx:"充值后余额"`
|
||||
Status string `xlsx:"状态"`
|
||||
PaymentMethod string `xlsx:"支付方式"`
|
||||
// 去掉支付通道
|
||||
OperationRemark string `xlsx:"运营备注"`
|
||||
RejectReason string `xlsx:"驳回原因"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
PaidAt string `xlsx:"支付时间"`
|
||||
CompletedAt string `xlsx:"完成时间"`
|
||||
SubmitterName string `xlsx:"提交人"`
|
||||
ApprovalSource string `xlsx:"审批来源"`
|
||||
ApprovalStatus string `xlsx:"审批状态"`
|
||||
CurrentNodeName string `xlsx:"当前审批节点"`
|
||||
ApprovalRecords string `xlsx:"审批记录"`
|
||||
VoucherURLs string `xlsx:"支付凭证"`
|
||||
Remark string `xlsx:"备注"`
|
||||
}
|
||||
```
|
||||
|
||||
充值导出的审批记录格式和批量查询规则与退款导出一致。
|
||||
|
||||
---
|
||||
|
||||
## 前端对接(通用模式)
|
||||
|
||||
各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。
|
||||
|
||||
调用流程:
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 导出字段权限
|
||||
|
||||
需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。
|
||||
|
||||
评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。
|
||||
|
||||
该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。
|
||||
|
||||
### 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. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。
|
||||
|
||||
禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。
|
||||
587
docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md
Normal file
587
docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# 需求15/16/18/19/20/21 技术方案
|
||||
|
||||
> 状态:原需求来源稿;其中本地审批流、审批页面和操作密码方案已废弃,最终以企微审批方案和标准评审稿为准。
|
||||
> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。
|
||||
|
||||
---
|
||||
|
||||
## 需求15:套餐下架后允许续费
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 下架套餐(`shelf_status=2`)不可被**新购**
|
||||
- 下架套餐**可以续费**(已在使用该套餐的客户)
|
||||
- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户)
|
||||
|
||||
```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,
|
||||
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 { // 2=下架
|
||||
if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) {
|
||||
return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格:
|
||||
|
||||
```go
|
||||
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端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。
|
||||
|
||||
后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。
|
||||
|
||||
---
|
||||
|
||||
## 需求16:代理分销码与佣金提现(已移出7月迭代)
|
||||
|
||||
> 状态:已移出本期
|
||||
> 决策日期:2026-07-14
|
||||
> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布
|
||||
|
||||
本次范围调整包含整个需求 16:
|
||||
|
||||
- 代理/员工分销码和推广二维码。
|
||||
- H5 代理申请、进度查询和退回重提。
|
||||
- 代理申请接入通用审批及审批后自动开店。
|
||||
- 店铺发展人关系和代理申请来源字段。
|
||||
- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。
|
||||
|
||||
因此 7 月迭代不创建 `tb_distribution_code`、`tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API,不发布 `agent_application_approval`,也不建设对应前端页面。
|
||||
|
||||
通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力,但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。
|
||||
|
||||
---
|
||||
|
||||
## 需求18:多人审批(APR-001~009)
|
||||
|
||||
### 依赖
|
||||
|
||||
历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md) 和 [站内消息初版](../历史方案/站内消息-初版.md)。
|
||||
|
||||
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;需求16已移出本期 |
|
||||
| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 |
|
||||
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
|
||||
| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 |
|
||||
| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 |
|
||||
| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 |
|
||||
| APR-009 | 对接企微 | **Phase 2** |
|
||||
|
||||
### 默认流程与可配置边界
|
||||
|
||||
- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。
|
||||
- 每个节点可配置 `role` 或 `user` 审批人来源,以及 `any` 或 `all` 完成方式。
|
||||
- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。
|
||||
- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。
|
||||
- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。
|
||||
- 已发起实例始终使用发起时保存的流程定义快照。
|
||||
|
||||
### 业务表与审批流的关联
|
||||
|
||||
充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。
|
||||
|
||||
本段是历史设计。旧接入协议见 [本地通用审批流 - 业务接入协议](../历史方案/本地通用审批流-已废弃方案.md#十一业务接入协议)。
|
||||
|
||||
退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。
|
||||
|
||||
### 前端技术方案
|
||||
|
||||
- 历史前端交互见 [前端共性方案历史稿](../历史方案/前端共性方案-历史稿.md#六统一审批交互);当前已改为企微审批只读页面。
|
||||
- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。
|
||||
- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。
|
||||
- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。
|
||||
- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。
|
||||
- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。
|
||||
|
||||
---
|
||||
|
||||
## 需求19:批量订购套餐(BPO-001~008)
|
||||
|
||||
### 支付方式粒度
|
||||
|
||||
评审结论:支付方式按**整批统一**设计:
|
||||
|
||||
- 页面选择 `offline` 或 `agent_wallet`,Excel 不再重复填写支付方式。
|
||||
- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
|
||||
- Excel 不包含支付方式列,后端拒绝同一批次混合支付。
|
||||
|
||||
### 业务流程
|
||||
|
||||
```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。
|
||||
|
||||
### 数据库变更
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_bulk_purchase_task (
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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 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);
|
||||
```
|
||||
|
||||
状态建议:
|
||||
|
||||
- 任务:`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 模板**:
|
||||
|
||||
模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为:
|
||||
|
||||
```text
|
||||
资产类型 | 资产标识 | 套餐编码 | 套餐名称
|
||||
```
|
||||
|
||||
`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
|
||||
|
||||
**上传并提交**:
|
||||
|
||||
```text
|
||||
POST /api/admin/bulk-purchases
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
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}
|
||||
```
|
||||
|
||||
**分页查询任务明细**:
|
||||
|
||||
```text
|
||||
GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
|
||||
```
|
||||
|
||||
### 前端技术方案
|
||||
|
||||
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。
|
||||
- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。
|
||||
- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。
|
||||
- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。
|
||||
- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。
|
||||
- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。
|
||||
|
||||
---
|
||||
|
||||
## 需求20:退款审批
|
||||
|
||||
### 依赖
|
||||
|
||||
本段历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md)。
|
||||
|
||||
### 退款单现有状态(不变)
|
||||
|
||||
```
|
||||
1=待审批 2=已通过 3=已拒绝 4=已退回
|
||||
```
|
||||
|
||||
退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态:
|
||||
|
||||
```text
|
||||
processing_status:0=待处理 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,且不超过申请退款金额和订单实收金额,并写入退款单和审批操作日志。会签需要指定金额决策人时,流程定义增加其专属最终节点,禁止第一位会签人预先锁定金额。
|
||||
|
||||
### 流程
|
||||
|
||||
```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 财务人员
|
||||
|
||||
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=1,status=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)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。
|
||||
|
||||
### 退回后重新提交
|
||||
|
||||
```
|
||||
POST /api/admin/refunds/{id}/resubmit
|
||||
→ 校验 status=4
|
||||
→ 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason
|
||||
→ 在同一事务新建 ProcessInstance
|
||||
→ 更新 approval_instance_id,status 回到 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:充值审核流程
|
||||
|
||||
### 充值单现有状态
|
||||
|
||||
```
|
||||
tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
|
||||
```
|
||||
|
||||
代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`:
|
||||
|
||||
```sql
|
||||
-- 现有: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_instance_id` 查询审批状态)
|
||||
- `2=已支付`:在线支付已确认,或线下充值审批通过后正在执行钱包入账
|
||||
- `3=已完成`:充值到账
|
||||
- `4=已关闭`:取消/超时
|
||||
- `5=已退款`:退款
|
||||
- `6=已驳回`:审批流程拒绝
|
||||
- `7=已退回`:审批人退回给提交人修改
|
||||
|
||||
`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。
|
||||
|
||||
充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。
|
||||
|
||||
### 流程
|
||||
|
||||
**代理自行充值(不走审批)**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[代理提交充值申请] --> B[系统生成收款码]
|
||||
B --> C[代理扫码支付]
|
||||
C --> D[支付回调幂等入账]
|
||||
```
|
||||
|
||||
**员工线下代充值(走审批)**:
|
||||
|
||||
现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。
|
||||
|
||||
```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 更新为 2,processing_status=1
|
||||
Worker->>DB: 幂等增加钱包余额并写流水
|
||||
Worker->>DB: status 从 2 更新为 3,processing_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` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。
|
||||
|
||||
### 退回后重新提交
|
||||
|
||||
```
|
||||
POST /api/admin/agent-recharges/{id}/resubmit
|
||||
→ 校验 status=7
|
||||
→ 请求体可修改 amount、payment_voucher_key、remark
|
||||
→ 在同一事务新建 ProcessInstance
|
||||
→ 更新 approval_instance_id,status 回到 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` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。
|
||||
349
docs/7月迭代/独立方案/原需求/需求17-信用额度.md
Normal file
349
docs/7月迭代/独立方案/原需求/需求17-信用额度.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# 需求17:信用额度
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query
|
||||
> 关联需求:BPO-009~012、需求19批量订购
|
||||
|
||||
---
|
||||
|
||||
## 一、评审结论
|
||||
|
||||
代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。
|
||||
|
||||
平台员工信用额度不实施,原因如下:
|
||||
|
||||
- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。
|
||||
- 客户角色可以保存“新建店铺默认额度”模板,但角色本身不持有余额和债务;实际额度仍写入代理主钱包。
|
||||
- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。
|
||||
- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。
|
||||
- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。
|
||||
|
||||
因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。
|
||||
|
||||
---
|
||||
|
||||
## 二、已确认范围:代理主钱包授信
|
||||
|
||||
### 2.1 业务规则
|
||||
|
||||
- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。
|
||||
- 可用金额:`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.2 角色默认与店铺实际额度
|
||||
|
||||
- 客户角色可以配置 `default_credit_enabled` 和 `default_credit_limit`,作为以后新建店铺的默认值。
|
||||
- 新建店铺时读取请求中的 `default_role_id`,将该角色当时的默认配置复制到新建主钱包。
|
||||
- 修改角色默认值不更新任何已有店铺,也不批量扫描钱包。
|
||||
- 已有店铺通过独立资金接口直接修改实际额度;店铺后续角色变化不影响钱包。
|
||||
- 关闭开关时额度必须为 0。
|
||||
- 打开开关时额度必须大于 0。
|
||||
- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据库变更
|
||||
|
||||
角色字段只是新建店铺模板,不参与运行时扣款。创建店铺后,最终权威数据只读取代理主钱包;修改角色默认值不会产生角色与钱包双写同步。
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_agent_wallet
|
||||
ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE tb_role
|
||||
ADD COLUMN default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN default_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 '信用额度上限(分),仅主钱包有效';
|
||||
COMMENT ON COLUMN tb_role.default_credit_enabled IS '新建代理店铺是否默认启用信用额度,仅客户角色有效';
|
||||
COMMENT ON COLUMN tb_role.default_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
|
||||
);
|
||||
```
|
||||
|
||||
必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。
|
||||
|
||||
---
|
||||
|
||||
## 四、领域模型
|
||||
|
||||
```go
|
||||
// AgentWallet 代理主钱包聚合根
|
||||
type AgentWallet struct {
|
||||
ID uint
|
||||
WalletType string
|
||||
Balance int64
|
||||
FrozenBalance int64
|
||||
CreditEnabled bool
|
||||
CreditLimit int64
|
||||
Version int64
|
||||
}
|
||||
|
||||
// AvailableBalance 返回当前可用金额。
|
||||
func (w *AgentWallet) AvailableBalance() int64 {
|
||||
credit := int64(0)
|
||||
if w.CreditEnabled {
|
||||
credit = w.CreditLimit
|
||||
}
|
||||
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
|
||||
Handler
|
||||
→ UpdateRoleDefaultCreditUseCase
|
||||
→ 校验角色为客户角色
|
||||
→ 校验开关和额度组合
|
||||
→ 只更新 tb_role 默认模板
|
||||
→ 写角色配置审计
|
||||
→ 不查询、不更新已有店铺钱包
|
||||
```
|
||||
|
||||
### 5.3 钱包扣款
|
||||
|
||||
所有现有主钱包扣款语句必须从:
|
||||
|
||||
```text
|
||||
balance - frozen_balance >= amount
|
||||
```
|
||||
|
||||
统一改为:
|
||||
|
||||
```text
|
||||
balance - frozen_balance +
|
||||
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount
|
||||
```
|
||||
|
||||
扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。
|
||||
|
||||
### 5.4 受影响用例
|
||||
|
||||
- 后台订单和批量订购的代理钱包支付。
|
||||
- C端/代理端使用代理主钱包的订单支付。
|
||||
- 钱包冻结与解冻。
|
||||
- 退款回充和员工线下代充值。
|
||||
- 资金概况、钱包详情和导出 Query。
|
||||
|
||||
实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。
|
||||
|
||||
---
|
||||
|
||||
## 六、查询方案
|
||||
|
||||
资金概况、钱包详情、列表和导出使用 Query 直接读取:
|
||||
|
||||
```sql
|
||||
balance - frozen_balance +
|
||||
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance
|
||||
```
|
||||
|
||||
响应统一增加:
|
||||
|
||||
```go
|
||||
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 设计
|
||||
|
||||
### 7.1 角色默认额度
|
||||
|
||||
```text
|
||||
PUT /api/admin/roles/{id}/default-credit
|
||||
```
|
||||
|
||||
```go
|
||||
type UpdateRoleDefaultCreditRequest struct {
|
||||
EnableCredit bool `json:"enable_credit" description:"新建店铺是否默认开启信用额度"`
|
||||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新建店铺默认信用额度(分)"`
|
||||
}
|
||||
```
|
||||
|
||||
现有 `POST /api/admin/shops` 继续使用 `default_role_id`。Application 在创建店铺和主钱包的同一事务中读取角色默认值并复制到钱包;角色模板之后变化不影响该钱包。
|
||||
|
||||
### 7.2 修改信用额度
|
||||
|
||||
```text
|
||||
PUT /api/admin/shops/{id}/credit-limit
|
||||
```
|
||||
|
||||
```go
|
||||
type UpdateCreditLimitRequest struct {
|
||||
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
|
||||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`
|
||||
Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"`
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 查询展示
|
||||
|
||||
现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。
|
||||
|
||||
---
|
||||
|
||||
## 八、前端技术方案
|
||||
|
||||
### 8.1 角色管理
|
||||
|
||||
```text
|
||||
新建代理默认信用额度
|
||||
[开关] 默认允许使用信用额度
|
||||
默认授信上限 [金额输入,单位元]
|
||||
提示:修改后只影响以后新建的代理,不影响已有店铺
|
||||
```
|
||||
|
||||
- 开关关闭时清空输入并提交 `credit_limit=0`。
|
||||
- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
|
||||
- 仅客户角色展示该配置;平台角色不展示。
|
||||
|
||||
### 8.2 店铺资金概况和详情
|
||||
|
||||
```text
|
||||
账面余额:-¥200.00
|
||||
冻结金额:¥0.00
|
||||
信用额度:¥1,000.00
|
||||
可用金额:¥800.00
|
||||
```
|
||||
|
||||
- `balance < 0` 时账面余额显示欠款样式。
|
||||
- 可用金额以接口值为准,不在前端自行重复计算。
|
||||
- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。
|
||||
- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。
|
||||
- 修改店铺角色、账号角色或角色默认额度都不能覆盖已有钱包额度。
|
||||
- 编辑代理基础资料不自动修改信用额度;信用调整使用独立权限和独立接口。
|
||||
|
||||
平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。
|
||||
|
||||
---
|
||||
|
||||
## 九、审计与可观测性
|
||||
|
||||
每次信用配置变更记录:
|
||||
|
||||
- 操作人、店铺、钱包 ID。
|
||||
- 变更前后开关、额度、余额、冻结金额和版本。
|
||||
- `request_id`、IP、设备信息和时间。
|
||||
|
||||
关键日志:
|
||||
|
||||
- 扣款因信用额度不足被拒绝。
|
||||
- 钱包 version 冲突。
|
||||
- 数据库信用边界约束失败。
|
||||
- 信用额度调整失败和旧值/新值。
|
||||
|
||||
资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。
|
||||
|
||||
---
|
||||
|
||||
## 十、发布与回滚
|
||||
|
||||
本需求随七月迭代停机发布:
|
||||
|
||||
1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加钱包信用字段和角色默认模板字段,历史钱包默认关闭。
|
||||
2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。
|
||||
3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。
|
||||
4. 系统开放后配置客户角色的新建默认额度;已有店铺需要授信时仍由管理员在店铺资金页面逐个设置。
|
||||
|
||||
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。
|
||||
297
docs/7月迭代/独立方案/原需求/需求22-套餐临期提醒.md
Normal file
297
docs/7月迭代/独立方案/原需求/需求22-套餐临期提醒.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# 需求22:套餐临期提醒
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 临期定义
|
||||
|
||||
当资产的当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**按 `Asia/Shanghai` 自然日计算处于 `0~15` 天时,该资产进入临期状态。预计最终到期时间与需求06/11共用同一个 Query,不再维护“当前套餐临期”和“最终到期”两套口径。
|
||||
|
||||
没有生效套餐且队首套餐仍等待无法确定时间的实名激活时,返回不可预计状态,不进入临期。已过期资产不属于临期。
|
||||
|
||||
### 查询与通知职责
|
||||
|
||||
- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
|
||||
- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
|
||||
- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。
|
||||
|
||||
### 颜色规则(Version 2)
|
||||
|
||||
| 剩余天数 | 颜色 |
|
||||
|---------|------|
|
||||
| ≤ 15天 | 粉红色 |
|
||||
| ≤ 7天 | 紫色 |
|
||||
| ≤ 3天 | 红色 |
|
||||
|
||||
### 各端提醒规则
|
||||
|
||||
| 场景 | 提醒方式 | 触发节点 |
|
||||
|------|---------|---------|
|
||||
| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 |
|
||||
| **企业客户** | 对应店铺业务员接收站内通知;后台每日生成临期列表 | 15天/7天/3天节点 |
|
||||
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
|
||||
| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Schedule[每日定时任务] --> Query[计算预计最终到期时间]
|
||||
Query --> Predictable{可以推算?}
|
||||
Predictable -->|否| Skip[不进入临期提醒]
|
||||
Predictable -->|是| Days[计算最终剩余自然日]
|
||||
Days --> Node{命中 15/7/3 天节点?}
|
||||
Node -->|否| End[本次不发送]
|
||||
Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录]
|
||||
Upsert --> Notification[站内消息]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。
|
||||
|
||||
每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知:
|
||||
|
||||
```sql
|
||||
-- 临期推送记录(防重)
|
||||
CREATE TABLE tb_expiry_push_record (
|
||||
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
|
||||
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 天提醒。本期只使用站内通知渠道。
|
||||
|
||||
---
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 现有接口新增临期字段
|
||||
|
||||
#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`)
|
||||
|
||||
响应新增字段:
|
||||
```go
|
||||
type IotCardListItem struct {
|
||||
// ...原有字段...
|
||||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
|
||||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
|
||||
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||||
}
|
||||
```
|
||||
|
||||
列表 Query 批量加载当前和排队主套餐,复用需求06/11的周期、时长快照推算逻辑,禁止逐资产查询。`is_expiring` 由 `days_until_final_expiry BETWEEN 0 AND 15` 派生;设备和卡不得各写一套日期规则。
|
||||
|
||||
#### 资产列表筛选条件新增
|
||||
|
||||
```go
|
||||
type IotCardListRequest struct {
|
||||
// ...原有字段...
|
||||
ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 资产详情接口
|
||||
|
||||
后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。
|
||||
|
||||
新增字段:
|
||||
```go
|
||||
// AssetResolveResponse 追加
|
||||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
|
||||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
|
||||
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||||
```
|
||||
|
||||
### 2. 新增临期列表接口(独立页面)
|
||||
|
||||
```
|
||||
GET /api/admin/expiring-assets
|
||||
```
|
||||
|
||||
查询参数:
|
||||
```go
|
||||
type ExpiringAssetsRequest struct {
|
||||
AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"`
|
||||
AssetIdentifier string `query:"asset_identifier" description:"资产标识(ICCID/设备号)"`
|
||||
PackageName string `query:"package_name" description:"套餐名称"`
|
||||
ShopID *uint `query:"shop_id" description:"店铺ID"`
|
||||
ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"`
|
||||
ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"`
|
||||
MaxDaysUntilFinalExpiry *int `query:"max_days_until_final_expiry" description:"最大预计最终剩余天数(如15)"`
|
||||
Page int `query:"page" default:"1"`
|
||||
PageSize int `query:"page_size" default:"20"`
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```go
|
||||
type ExpiringAssetItem struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetIdentifier string `json:"asset_identifier"`
|
||||
AssetStatus int `json:"asset_status"`
|
||||
ShopName string `json:"shop_name"`
|
||||
PackageName string `json:"package_name"`
|
||||
DaysUntilFinalExpiry int `json:"days_until_final_expiry"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DataUsageMB int64 `json:"data_usage_mb"`
|
||||
RemainingDataMB int64 `json:"remaining_data_mb"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 每日定时任务(仅通知)
|
||||
|
||||
```go
|
||||
// internal/task/expiry_reminder_handler.go
|
||||
|
||||
// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。
|
||||
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
|
||||
assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15)
|
||||
if err != nil { return err }
|
||||
|
||||
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.RecipientAccountIDs {
|
||||
eventID := fmt.Sprintf(
|
||||
"expiry:%d:%d:%d:%d:%s",
|
||||
asset.PackageUsageID, node, recipientID, asset.AssetID, 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
|
||||
}
|
||||
```
|
||||
|
||||
定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `0~15` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。
|
||||
|
||||
---
|
||||
|
||||
## 导出功能(临期列表)
|
||||
|
||||
使用统一导出任务:
|
||||
|
||||
```text
|
||||
POST /api/admin/export-tasks
|
||||
scene=expiring_asset
|
||||
```
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 资产标识 | ICCID/设备号 |
|
||||
| 资产类型 | 卡/设备 |
|
||||
| 店铺名称 | |
|
||||
| 套餐名称 | |
|
||||
| 套餐到期时间 | |
|
||||
| 剩余天数 | |
|
||||
| 资产状态 | |
|
||||
| 已用流量(MB) | |
|
||||
| 剩余流量(MB) | |
|
||||
|
||||
---
|
||||
|
||||
## C端接口变更
|
||||
|
||||
### 公众号首页
|
||||
|
||||
现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段:
|
||||
|
||||
```go
|
||||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
|
||||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||||
```
|
||||
|
||||
前端逻辑:`is_expiring=true` 时展示续费提醒模块:
|
||||
|
||||
```
|
||||
您的套餐即将到期
|
||||
|
||||
卡号/设备号:XXXX
|
||||
剩余有效期:XX 天
|
||||
|
||||
为避免到期后影响正常使用,请您提前完成续费。
|
||||
|
||||
[立即续费]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接(后台管理)
|
||||
|
||||
### 资产列表(IoT卡管理 / 设备管理)
|
||||
|
||||
1. 列表新增"剩余天数"列
|
||||
2. 根据 `days_until_final_expiry` 高亮行:
|
||||
- ≤ 15天:行背景粉红色
|
||||
- ≤ 7天:行背景紫色
|
||||
- ≤ 3天:行背景红色
|
||||
3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天)
|
||||
- 选中后传 `expiring_within_days=15`
|
||||
|
||||
### 临期资产独立列表页
|
||||
|
||||
路由:`/expiring-assets`
|
||||
|
||||
```
|
||||
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
|
||||
|
||||
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
|
||||
|
||||
操作:导出按钮 → `POST /api/admin/export-tasks`,`scene=expiring_asset`
|
||||
```
|
||||
|
||||
临期独立列表将 `days_until_final_expiry <= 3` 的资产置顶,再按预计最终到期时间升序。普通卡列表和设备列表只按颜色高亮,不改变原有排序。
|
||||
|
||||
### 代理端首页
|
||||
|
||||
在首页数据接口新增字段(需要确认代理端首页接口):
|
||||
|
||||
```go
|
||||
type AgentDashboardResponse struct {
|
||||
// ...原有字段...
|
||||
ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"`
|
||||
ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"`
|
||||
}
|
||||
```
|
||||
|
||||
前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15`
|
||||
456
docs/7月迭代/独立方案/基础规范/DDD规范.md
Normal file
456
docs/7月迭代/独立方案/基础规范/DDD规范.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# 项目 DDD 设计规范
|
||||
|
||||
> 状态:项目渐进迁移规范;7月迭代标准稿已采用。
|
||||
> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。
|
||||
> 基准文档:`docs/改造方向.md`(绞杀者模式)
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么用 DDD / 什么不是 DDD
|
||||
|
||||
### 现状问题
|
||||
|
||||
- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起
|
||||
- Model 只是 GORM 映射,没有业务行为(贫血模型)
|
||||
- 新增业务联动必须修改原有 Service,牵一发动全身
|
||||
|
||||
### 目标
|
||||
|
||||
不是重写,是**渐进替换**:
|
||||
|
||||
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/Redis/GORM)
|
||||
│ ├── wallet/
|
||||
│ │ ├── wallet.go ← 聚合根(含业务方法)
|
||||
│ │ ├── events.go ← 领域事件定义
|
||||
│ │ ├── repository.go ← 仓储接口(interface)
|
||||
│ │ └── vo.go ← 值对象(Money, CreditLimit)
|
||||
│ ├── approval/ ← 新:审批流领域
|
||||
│ ├── notification/ ← 新:通知领域
|
||||
│ ├── distribution/ ← 后续预留:需求16独立立项后再创建
|
||||
│ └── package/ ← 套餐领域(迁移中)
|
||||
│
|
||||
├── application/ ← 应用层(用例,只做编排,不含业务判断)
|
||||
│ ├── wallet/
|
||||
│ │ ├── debit_wallet.go ← 一个文件 = 一个用例
|
||||
│ │ ├── credit_wallet.go
|
||||
│ │ └── grant_credit.go ← 新:授信
|
||||
│ ├── approval/
|
||||
│ │ ├── submit_approval.go
|
||||
│ │ ├── advance_step.go
|
||||
│ │ └── reject_approval.go
|
||||
│ └── notification/
|
||||
│ └── send_notification.go
|
||||
│
|
||||
├── query/ ← 读取侧(可直接使用 GORM,返回 DTO/Projection)
|
||||
│ ├── order/
|
||||
│ ├── refund/
|
||||
│ ├── approval/
|
||||
│ ├── dashboard/
|
||||
│ └── report/
|
||||
│
|
||||
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface)
|
||||
│ ├── persistence/ ← GORM Repository,可委托现有 Store
|
||||
│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费
|
||||
│ └── adapter/ ← 外部系统和旧模块防腐层
|
||||
│
|
||||
├── handler/ ← 原有 handler(保持不动)
|
||||
├── service/ ← 原有 service(保持不动,新模块不在这里)
|
||||
└── store/ ← 原有 store(保持不动)
|
||||
```
|
||||
|
||||
**过渡期规则**:
|
||||
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
|
||||
- 复杂写用例:`internal/domain/xxx` + `internal/application/xxx`
|
||||
- 简单写用例:`internal/application/xxx`
|
||||
- 读取用例:`internal/query/xxx`
|
||||
- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
|
||||
- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model,但不得让 Fiber、Redis 或 GORM 查询进入业务方法
|
||||
|
||||
---
|
||||
|
||||
## 四、领域模块划分
|
||||
|
||||
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|
||||
|------|--------|------------|------|
|
||||
| **Asset(资产)** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 |
|
||||
| **Order(订单)** | `Order` | 支付、退款、佣金触发 | 迁移中 |
|
||||
| **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
|
||||
| **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
|
||||
| **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 |
|
||||
| **Approval(审批)** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** |
|
||||
| **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** |
|
||||
| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 |
|
||||
|
||||
---
|
||||
|
||||
## 五、聚合根设计原则(富模型)
|
||||
|
||||
### 5.1 核心原则
|
||||
|
||||
业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。
|
||||
|
||||
```go
|
||||
// ❌ 贫血模型(禁止)——所有判断在 Service 里
|
||||
func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error {
|
||||
wallet, _ := s.store.Get(ctx, walletID)
|
||||
if wallet.Balance-wallet.FrozenBalance < amount {
|
||||
return errors.New(errors.CodeInsufficientBalance)
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
s.store.Update(ctx, wallet)
|
||||
}
|
||||
|
||||
// ✅ 富模型(推荐)——判断逻辑在聚合根里
|
||||
func (w *AgentWallet) Debit(amount Money) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度
|
||||
if available < amount.Cents() {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
w.Balance -= amount.Cents()
|
||||
w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Application 层只做编排
|
||||
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 聚合根必须包含
|
||||
|
||||
```go
|
||||
type AggregateRoot struct {
|
||||
// 业务字段...
|
||||
|
||||
// 未发布领域事件,由 Application 在事务内写入 Outbox
|
||||
domainEvents []DomainEvent
|
||||
}
|
||||
|
||||
// PopEvents 获取并清空领域事件
|
||||
func (a *AggregateRoot) PopEvents() []DomainEvent {
|
||||
events := a.domainEvents
|
||||
a.domainEvents = nil
|
||||
return events
|
||||
}
|
||||
|
||||
func (a *AggregateRoot) recordEvent(e DomainEvent) {
|
||||
a.domainEvents = append(a.domainEvents, e)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 与现有 GORM 的共存
|
||||
|
||||
迁移旧模块时,聚合根可以临时包装现有 GORM Model,减少一次性重构成本:
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/wallet.go
|
||||
// AgentWallet 钱包聚合根
|
||||
// 直接复用并扩展现有 model.AgentWallet
|
||||
type AgentWallet struct {
|
||||
model.AgentWallet // 迁移期复用持久化字段
|
||||
domainEvents []DomainEvent
|
||||
}
|
||||
|
||||
// Debit 从钱包扣款(含信用额度)
|
||||
func (w *AgentWallet) Debit(amount Money) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit
|
||||
if available < amount.Cents() {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
w.Balance -= amount.Cents()
|
||||
w.recordEvent(WalletDebitedEvent{...})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。
|
||||
|
||||
---
|
||||
|
||||
## 六、值对象设计
|
||||
|
||||
值对象:没有 ID,靠值来判断相等,不可变。
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/vo.go
|
||||
|
||||
// Money 金额值对象(分为单位,防止浮点数精度问题)
|
||||
type Money struct {
|
||||
cents int64
|
||||
}
|
||||
|
||||
func NewMoney(cents int64) (Money, error) {
|
||||
if cents < 0 {
|
||||
return Money{}, ErrNegativeMoney
|
||||
}
|
||||
return Money{cents: cents}, nil
|
||||
}
|
||||
|
||||
func (m Money) Cents() int64 { return m.cents }
|
||||
func (m Money) Yuan() float64 { return float64(m.cents) / 100 }
|
||||
func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} }
|
||||
|
||||
// CreditLimit 信用额度值对象
|
||||
type CreditLimit struct {
|
||||
maxCents int64 // 最大授信额度(分)
|
||||
allowNegative bool // 是否允许负余额
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、领域事件与可靠投递
|
||||
|
||||
领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox,再由 Relay 异步投递到 Asynq。
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/events.go
|
||||
|
||||
// DomainEvent 领域事件接口
|
||||
type DomainEvent interface {
|
||||
EventType() string
|
||||
OccurredAt() time.Time
|
||||
}
|
||||
|
||||
// WalletDebitedEvent 钱包扣款事件
|
||||
type WalletDebitedEvent struct {
|
||||
WalletID uint
|
||||
ShopID uint
|
||||
Amount Money
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
RefType string
|
||||
RefID uint
|
||||
occurredAt time.Time
|
||||
}
|
||||
|
||||
func (e WalletDebitedEvent) EventType() string { return "wallet.debited" }
|
||||
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
|
||||
```
|
||||
|
||||
**事务边界**:
|
||||
|
||||
```text
|
||||
数据库事务
|
||||
├── 保存聚合
|
||||
├── 保存操作日志
|
||||
└── 保存 Outbox 事件
|
||||
提交事务
|
||||
↓
|
||||
Outbox Relay → Asynq → 事件处理器
|
||||
```
|
||||
|
||||
**Application UseCase 保存事件**:
|
||||
|
||||
```go
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
// WalletRepository 钱包仓储接口
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
实现在 `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
|
||||
|
||||
// DebitCommand 扣款命令
|
||||
type DebitCommand struct {
|
||||
WalletID uint
|
||||
Amount domainWallet.Money
|
||||
RefType string
|
||||
RefID uint
|
||||
OperatorID uint
|
||||
}
|
||||
|
||||
// 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(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
|
||||
}
|
||||
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、Handler 层调用方式
|
||||
|
||||
- 复杂写、简单写:Handler → Application UseCase。
|
||||
- 读取:Handler → Query。
|
||||
- 尚未迁移的旧用例:Handler → Service。
|
||||
- Handler 不直接访问 GORM,也不承载业务规则或复杂 DTO 拼装。
|
||||
|
||||
```go
|
||||
// internal/handler/admin/wallet_handler.go
|
||||
func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
|
||||
var req dto.GrantCreditRequest
|
||||
// ... 参数解析
|
||||
|
||||
cmd := walletApp.GrantCreditCommand{
|
||||
ShopID: req.ShopID,
|
||||
MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents),
|
||||
OperatorID: middleware.GetUserID(c),
|
||||
}
|
||||
if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return response.OK(c, nil)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、禁止事项
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|------|------|
|
||||
| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 |
|
||||
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 |
|
||||
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
|
||||
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
|
||||
| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox |
|
||||
| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 |
|
||||
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
|
||||
| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application |
|
||||
| 使用 Query 快照替代写侧校验 | 查询结果可能已过期,Domain 必须重新验证不变量 |
|
||||
| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query |
|
||||
| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、本次迭代 DDD 落地计划
|
||||
|
||||
| 新模块 | 领域路径 | 用例路径 |
|
||||
|--------|---------|---------|
|
||||
| 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` |
|
||||
| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox |
|
||||
| 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` |
|
||||
| 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
|
||||
|
||||
需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。
|
||||
@@ -1,6 +1,7 @@
|
||||
# 基础设施:系统配置(tb_system_config)
|
||||
|
||||
> 被依赖:需求 02(H5流程配置)、需求 09(C端支付限制)
|
||||
> 状态:独立方案来源稿;最终口径以7月迭代标准评审稿为准。
|
||||
> 被依赖:需求 09(C端支付限制)
|
||||
|
||||
---
|
||||
|
||||
@@ -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流程顺序(需求2,per-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 文本框。
|
||||
625
docs/7月迭代/独立方案/新增需求/01-数据同步触发与轮询优化.md
Normal file
625
docs/7月迭代/独立方案/新增需求/01-数据同步触发与轮询优化.md
Normal file
@@ -0,0 +1,625 @@
|
||||
# 新增需求 01:数据同步触发与轮询优化
|
||||
|
||||
> 状态:已合并至标准评审稿,本文保留为实施明细。
|
||||
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
|
||||
> 范围:IoT 卡实名、流量、网络状态和设备实时数据同步。
|
||||
|
||||
## 一、已确认决策
|
||||
|
||||
1. 轮询、运营商回调、业务事件触发是三条相互隔离的自动同步通道。
|
||||
2. 轮询始终保留,负责回调失效、事件漏网和上游偶发失败时的最终兜底。
|
||||
3. 资产是否轮询只保留现有全局开关 `enable_polling`;不再设计“实名资格、流量资格、状态资格”等业务资格开关。
|
||||
4. 轮询优化只区分活跃卡和不活跃卡,通过不同轮询间隔降低无效请求,不因业务类型直接永久排除某类卡。
|
||||
5. 行业卡是否需要实名不能由 `card_category=industry` 推断,统一以运营商 `realname_link_type` 判断。
|
||||
6. 业务事件触发不是新增一个显式同步接口,而是埋点到关键查询、停复机、支付、实名和设备操作用例中。
|
||||
7. 每次事件默认形成三个独立尝试:立即、事件发生后 3 分钟、事件发生后 5 分钟;三次结束后由常规轮询继续兜底。
|
||||
8. 不设置统一五分钟冷却。重复请求控制拆成同场景触发合并、单卡请求互斥和运营商最小请求间隔;Gateway 超频只记录当前失败,不建立退避状态。
|
||||
9. 运营商实名回调百分百信任,不做来源真实性校验;但必须经过防腐层完成报文解析、标识转换和状态语义转换。
|
||||
10. 解除实名第一版不修改业务状态,只保留回调入口、写入统一集成审计并返回运营商要求的成功报文。
|
||||
11. 三条自动通道和现有手动刷新接口共享同一套“获取上游观测并应用到业务”的 DDD 用例,禁止各入口直接更新卡字段。
|
||||
12. 同步执行记录属于全局审计的 Integration Log,不在本方案新建 `tb_card_sync_execution` 或独立同步监控系统。
|
||||
13. 本次迁移触碰到的旧实名、流量和状态写逻辑必须删除或改为调用新用例,不保留长期双实现。
|
||||
|
||||
## 二、现状问题
|
||||
|
||||
当前代码存在以下明确问题:
|
||||
|
||||
- `internal/task/polling_realname_handler.go` 直接跳过所有行业卡,与“是否实名由运营商特性决定”冲突。
|
||||
- `PollingRealnameHandler`、`PollingCarddataHandler` 和 `PollingCardStatusHandler` 同时承担 Gateway 查询、字段更新和业务联动。
|
||||
- `internal/service/iot_card/service.go:RefreshCardDataFromGateway` 又实现一套实名、流量和网络状态同步。
|
||||
- `ManualUpdateRealnameStatus` 单独实现实名状态变更联动,无法保证与轮询、回调行为一致。
|
||||
- `ClientRealnameHandler`、部分设备 Handler 直接调用 Gateway,业务事件无法在统一用例边界埋点。
|
||||
- 现有获取实名链接后的自动检查借用了 `ManualTriggerService`,把系统事件伪装成平台用户手工操作。
|
||||
- 调度任务载荷只有 `card_id`,缺少触发场景、来源、序列和关联链路。
|
||||
- `last_sync_time` 被实名、流量和状态共同覆盖,无法说明最后同步了什么。
|
||||
- 现有显式刷新接口已经存在,再增加 `/card-sync/requests` 只会形成重复入口。
|
||||
|
||||
因此,本次不是增加更多同步 Service,而是先收口写侧用例,再把轮询、回调和事件触发接入同一边界。
|
||||
|
||||
## 三、目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Polling[轮询调度] --> Activity[按活跃度计算下次间隔]
|
||||
Activity --> Request[RequestCardObservation]
|
||||
|
||||
Business[关键业务用例] --> Trigger[CreateSyncTriggerSeries]
|
||||
Query[关键查询用例] --> Trigger
|
||||
Trigger --> A1[立即尝试]
|
||||
Trigger --> A2[3分钟后尝试]
|
||||
Trigger --> A3[5分钟后尝试]
|
||||
A1 --> Gate[请求协调器]
|
||||
A2 --> Gate
|
||||
A3 --> Gate
|
||||
Gate --> Request
|
||||
|
||||
Manual[现有手动刷新接口] --> Request
|
||||
|
||||
Callback[运营商实名回调] --> ACL[运营商防腐层]
|
||||
ACL --> RealObs[标准实名观测]
|
||||
|
||||
Request --> Gateway[Gateway Adapter]
|
||||
Gateway --> Observation[标准化观测值]
|
||||
RealObs --> Apply[ApplyCardObservation]
|
||||
Observation --> Apply
|
||||
|
||||
Apply --> Card[保存卡状态]
|
||||
Apply --> Events[记录领域事件和 Outbox]
|
||||
Events --> Package[套餐激活与流量扣减]
|
||||
Events --> StopResume[停复机评估]
|
||||
Events --> ActivityMark[更新活跃标记]
|
||||
Request --> Integration[统一 Integration Log]
|
||||
ACL --> Integration
|
||||
Events --> Audit[关键业务 Audit Event]
|
||||
```
|
||||
|
||||
各入口边界:
|
||||
|
||||
| 入口 | 是否调用 Gateway | 执行方式 | 作用 |
|
||||
|---|---:|---|---|
|
||||
| 轮询 | 是 | 异步、持续重排 | 最终一致性兜底 |
|
||||
| 运营商回调 | 否 | 同步应用状态,联动异步可靠投递 | 实名成功急速通道 |
|
||||
| 业务事件触发 | 是 | 异步 `0/3/5` 三次 Best Effort | 提高正在操作资产的命中概率 |
|
||||
| 现有手动刷新 | 是 | 保持现有响应契约 | 用户明确要求立即刷新 |
|
||||
|
||||
手动刷新不是第四种自动策略,也不能被业务埋点调用。
|
||||
|
||||
## 四、DDD 设计
|
||||
|
||||
### 4.1 目录
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── domain/cardstate/
|
||||
│ ├── card.go 卡状态聚合及状态转换
|
||||
│ ├── observation.go 实名、流量、网络状态观测值
|
||||
│ ├── events.go 状态变化领域事件
|
||||
│ └── repository.go 聚合仓储接口
|
||||
├── application/cardsync/
|
||||
│ ├── request_observation.go 查询 Gateway 并获取标准观测
|
||||
│ ├── apply_observation.go 在事务内应用观测和领域事件
|
||||
│ ├── create_trigger_series.go 创建事件触发序列
|
||||
│ ├── execute_trigger_attempt.go 执行单次阶梯尝试
|
||||
│ ├── mark_activity.go 更新活跃标记
|
||||
│ └── refresh_asset.go 承接现有显式刷新接口
|
||||
├── infrastructure/adapter/gateway/
|
||||
│ └── card_observation_adapter.go 复用现有 Gateway Client
|
||||
├── infrastructure/adapter/carrier_callback/
|
||||
│ ├── translator.go 防腐层统一接口
|
||||
│ ├── cmcc.go
|
||||
│ ├── cucc.go
|
||||
│ └── ctcc.go
|
||||
├── infrastructure/messaging/cardsync/
|
||||
│ ├── trigger_publisher.go Outbox/Asynq 任务发布
|
||||
│ └── trigger_handler.go 阶梯任务处理器
|
||||
└── query/cardsync/
|
||||
└── activity_query.go 活跃状态和下次轮询查询
|
||||
```
|
||||
|
||||
### 4.2 实名要求判断
|
||||
|
||||
不得继续使用以下判断:
|
||||
|
||||
```go
|
||||
if card.CardCategory == constants.CardCategoryIndustry {
|
||||
// 跳过实名
|
||||
}
|
||||
```
|
||||
|
||||
现有 `tb_carrier.realname_link_type` 可以直接决定该运营商接入是否需要实名,不新增重复能力字段:
|
||||
|
||||
| `realname_link_type` | 实名要求 | 实名入口 |
|
||||
|---|---|---|
|
||||
| `none` | 不需要实名 | 不提供实名链接,不创建实名查询任务 |
|
||||
| `template` | 需要实名 | 使用模板生成实名链接 |
|
||||
| `gateway` | 需要实名 | 调用 Gateway 获取实名链接 |
|
||||
|
||||
- 资产 `realname_policy` 只决定“先实名后购买”或“先购买后实名”的业务顺序。
|
||||
- `realname_link_type=none` 时,资产有效实名策略统一视为 `none`。
|
||||
- `realname_link_type!=none` 时,资产 `realname_policy=none` 属于冲突数据,发布前列出并修正,禁止运行时静默选择其中一方。
|
||||
- `card_category` 只保留业务分类和展示用途,不参与实名轮询、复机或套餐激活判断。
|
||||
|
||||
运营商是否配置回调 Adapter 只影响有没有回调急速通道,不改变 `realname_link_type` 对实名要求的判断。
|
||||
|
||||
### 4.3 标准化观测值
|
||||
|
||||
```go
|
||||
type SyncSource string
|
||||
|
||||
const (
|
||||
SyncSourcePolling SyncSource = "polling"
|
||||
SyncSourceBusinessEvent SyncSource = "business_event"
|
||||
SyncSourceOpenAPI SyncSource = "open_api"
|
||||
SyncSourceManualRefresh SyncSource = "manual_refresh"
|
||||
SyncSourceCarrierCallback SyncSource = "carrier_callback"
|
||||
)
|
||||
|
||||
type ObservationMeta struct {
|
||||
Source SyncSource
|
||||
TriggerScene string
|
||||
TriggerSeries string
|
||||
Attempt int
|
||||
Provider string
|
||||
ObservedAt time.Time
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
type RealnameObservation struct {
|
||||
CardID uint
|
||||
ICCID string
|
||||
Verified bool
|
||||
Meta ObservationMeta
|
||||
}
|
||||
|
||||
type TrafficObservation struct {
|
||||
CardID uint
|
||||
GatewayReadingMB float64
|
||||
Meta ObservationMeta
|
||||
}
|
||||
|
||||
type NetworkObservation struct {
|
||||
CardID uint
|
||||
CardStatus string
|
||||
Extend string
|
||||
GatewayIMEI string
|
||||
Meta ObservationMeta
|
||||
}
|
||||
```
|
||||
|
||||
领域层不使用 `map[string]any` 传递核心状态,防止不同入口遗漏字段或混淆单位。
|
||||
|
||||
### 4.4 观测应用规则
|
||||
|
||||
`ApplyCardObservation` 是唯一允许把上游数据写入卡领域状态的入口:
|
||||
|
||||
1. 加载并锁定卡聚合。
|
||||
2. 根据观测类型执行实名、流量或网络状态规则。
|
||||
3. 状态未变化时只更新时间和观测来源,不重复产生业务事件。
|
||||
4. 状态变化时保存聚合,并在同一事务写 Outbox 和关键 Audit Event。
|
||||
5. 实名 `0 -> 1` 产生 `CardRealnamed`,触发卡级/设备级套餐激活和停复机评估。
|
||||
6. 流量正增量产生 `CardTrafficIncreased`,由套餐应用服务扣减套餐流量。
|
||||
7. 网络状态变化产生 `CardNetworkStatusChanged`,由停复机策略重新评估。
|
||||
8. 状态变化或流量增加时更新卡活跃标记。
|
||||
|
||||
解除实名回调不构造 `Verified=false` 观测,不修改卡状态。
|
||||
|
||||
## 五、轮询优化:只做活跃度调频
|
||||
|
||||
### 5.1 全局开关
|
||||
|
||||
`enable_polling` 是资产是否参与自动轮询的唯一业务开关:
|
||||
|
||||
- `false`:从所有轮询队列移除,事件触发也不因此失效;用户主动操作仍可触发 Best Effort 同步。
|
||||
- `true`:按照活跃度和运营商能力进入对应 Gateway 查询队列。
|
||||
- 设备关闭轮询时,继续沿用现有设备与绑定卡级联规则。
|
||||
|
||||
不再提供“实名轮询开关、流量轮询开关、状态轮询开关”给业务人员配置。
|
||||
|
||||
### 5.2 活跃标记
|
||||
|
||||
卡增加或维护以下轮询调度字段:
|
||||
|
||||
```text
|
||||
polling_activity_level active / inactive
|
||||
last_polling_activity_at 最后活跃时间
|
||||
last_polling_activity_scene 最后活跃场景
|
||||
last_upstream_change_at 上游观测最后发生变化时间
|
||||
```
|
||||
|
||||
以下情况标记为活跃:
|
||||
|
||||
| 场景 | 说明 |
|
||||
|---|---|
|
||||
| C 端、后台或开放接口查询资产实时信息 | 表明当前有人关心该资产 |
|
||||
| 获取实名链接 | 表明即将发生实名操作 |
|
||||
| 停机、复机、切卡、重启、恢复出厂设置 | 表明上游状态正在变化 |
|
||||
| 套餐支付、激活、失效或流量重置 | 可能影响流量和停复机 |
|
||||
| 运营商回调 | 上游已发生变化 |
|
||||
| 轮询发现实名、流量或网络状态变化 | 卡当前确实活跃 |
|
||||
|
||||
卡在配置时间内没有业务活动,且连续轮询未发现上游变化时转为 `inactive`。该判断在每次重排队时完成,不新增全表扫描任务。
|
||||
|
||||
第一版建议配置而非写死:
|
||||
|
||||
```text
|
||||
inactive_after = 30m
|
||||
active_realname_interval = 现有实名默认间隔
|
||||
active_traffic_interval = 现有流量默认间隔
|
||||
active_network_interval = 现有状态默认间隔
|
||||
inactive_realname_interval = 15m
|
||||
inactive_traffic_interval = 15m
|
||||
inactive_network_interval = 15m
|
||||
```
|
||||
|
||||
现有 `tb_polling_config` 的有效间隔迁移为活跃卡默认值,但不再按 `card_condition/card_category` 匹配多套业务资格。第一版调整为一套全局活跃/不活跃间隔;运营商配置只描述接口能力和上游限频,不再决定某张卡是否“有资格”轮询。上线前使用生产数据做只读测算,确认 Gateway QPS 和最长兜底延迟后再调整数值。
|
||||
|
||||
### 5.3 调度规则
|
||||
|
||||
```text
|
||||
enable_polling=false
|
||||
-> 不入自动轮询队列
|
||||
|
||||
enable_polling=true + active
|
||||
-> 使用全局活跃间隔
|
||||
|
||||
enable_polling=true + inactive
|
||||
-> 使用全局不活跃间隔
|
||||
|
||||
realname_link_type=none
|
||||
-> 不创建实名查询任务
|
||||
```
|
||||
|
||||
已实名卡仍可低频查询实名状态,以发现上游实名逆转;未实名行业卡也不能因卡类别被排除。
|
||||
|
||||
## 六、业务事件触发
|
||||
|
||||
### 6.1 不是新增接口
|
||||
|
||||
本需求不新增 `POST /api/admin/card-sync/requests`,也不新增独立“事件同步”按钮。
|
||||
|
||||
现有接口保持:
|
||||
|
||||
```http
|
||||
POST /api/admin/assets/:identifier/refresh
|
||||
POST /api/c/v1/asset/refresh
|
||||
```
|
||||
|
||||
现有批量轮询运维接口可以保留,但必须改为调用新的 Application 用例,不再自行维护另一套同步逻辑。
|
||||
|
||||
业务事件触发由 Application UseCase 或 Query 完成后调用内部端口:
|
||||
|
||||
```go
|
||||
type SyncTriggerCommand struct {
|
||||
Scene string
|
||||
ResourceType string
|
||||
ResourceID uint
|
||||
CardIDs []uint
|
||||
SyncTypes []string
|
||||
ExpectedState map[string]any
|
||||
Source SyncSource
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
```
|
||||
|
||||
写操作在业务成功后触发;有数据库事务的关键写操作通过 Outbox 发布触发事件,避免提交成功后进程退出造成埋点丢失。读操作只查询本地快照,不等待 Gateway;在返回前 Best Effort 写入 Asynq,入队失败不得改变原接口响应。
|
||||
|
||||
### 6.2 阶梯式尝试
|
||||
|
||||
每个触发序列默认创建三个 Asynq 任务:
|
||||
|
||||
| 尝试 | 计划时间 | Asynq 自动重试 |
|
||||
|---:|---:|---:|
|
||||
| 1 | 立即 | 0 |
|
||||
| 2 | 事件发生后 3 分钟 | 0 |
|
||||
| 3 | 事件发生后 5 分钟 | 0 |
|
||||
|
||||
每次任务都有确定性的 `series_id + attempt` 幂等键。单次失败不会额外重试,也不会取消后续两次;三次结束后由常规轮询兜底。
|
||||
|
||||
存在明确预期状态时允许提前完成序列:
|
||||
|
||||
- 复机后已观测为开机。
|
||||
- 停机后已观测为停机。
|
||||
- 获取实名链接后已通过回调或查询确认实名。
|
||||
- 切卡后设备当前卡已经是目标 ICCID。
|
||||
|
||||
序列提前完成后,剩余任务启动时检查状态并直接记为 `completed`,不再请求 Gateway。
|
||||
|
||||
### 6.3 关键埋点
|
||||
|
||||
埋点放在应用用例成功边界,不散落在 Handler 的 `response.Success` 前后。现有 Handler 直接调用 Gateway 的路径在迁移时收口到 Application。
|
||||
|
||||
| 现有入口/用例 | 触发内容 | 预期状态 |
|
||||
|---|---|---|
|
||||
| `GET /api/c/v1/asset/info` | 资产绑定卡的实名、流量、网络状态 | 无 |
|
||||
| `GET /api/admin/assets/:identifier/realtime-status` | 对应卡或设备绑定卡的实时数据 | 无 |
|
||||
| `GET /api/open/v1/cards/traffic` | 流量 | 无 |
|
||||
| `GET /api/open/v1/cards/status` | 网络状态 | 无 |
|
||||
| `GET /api/open/v1/cards/realname-status` | 实名 | 无 |
|
||||
| `GET /api/open/v1/devices/traffic` | 设备信息、绑定卡流量 | 无 |
|
||||
| C 端和后台获取实名链接 | 实名 | 已实名 |
|
||||
| 后台、C 端、开放接口停机/复机成功 | 网络状态 | 停机或开机 |
|
||||
| 自动停复机 Gateway 调用成功 | 网络状态 | 停机或开机 |
|
||||
| 订单支付、钱包购买套餐、套餐激活成功 | 实名、流量、网络状态 | 无 |
|
||||
| 设备切卡或切换模式成功 | 设备信息、源卡和目标卡网络状态、目标卡流量 | 目标 ICCID |
|
||||
| 设备重启、恢复出厂设置、WiFi 设置成功 | 设备信息、绑定卡网络状态 | 无 |
|
||||
| 卡绑定/解绑、设备或卡分配/回收 | 只标记活跃;确有上游操作时再创建同步序列 | 无 |
|
||||
|
||||
补充规则:
|
||||
|
||||
- `POST /api/admin/assets/:identifier/refresh` 和 `POST /api/c/v1/asset/refresh` 已经直接同步,不再额外创建 `0/3/5` 序列。
|
||||
- 运营商实名回调直接应用观测,不再反查 Gateway;回调成功后终止相同卡的待执行实名序列。
|
||||
- 轮询发现状态变化只更新活跃标记,不反向创建新的事件序列。
|
||||
- 同一次设备操作涉及多张绑定卡时使用同一 `correlation_id`,但每张卡独立限流和记录结果。
|
||||
|
||||
### 6.4 冷却、合并与限流
|
||||
|
||||
不使用一个固定五分钟 `SET NX` 键拦截所有事件。请求协调器只处理同场景合并、单卡互斥和最小请求间隔;Gateway 返回超频后不建立额外退避状态。
|
||||
|
||||
#### 同场景触发合并
|
||||
|
||||
```text
|
||||
cardsync:series:{scene}:{resource_type}:{resource_id}:{sync_type}
|
||||
```
|
||||
|
||||
- 同一场景、同一资源、同一同步类型已有未结束序列时,新触发合并到原序列。
|
||||
- 合并只防止页面轮询、开放接口重试等重复创建大量 `0/3/5` 任务。
|
||||
- 停复机、支付、切卡等不同业务场景不会被一个查询场景长期压制。
|
||||
- 序列键只覆盖最后一次计划任务和短暂缓冲,不作为全局五分钟冷却。
|
||||
|
||||
#### 单卡请求互斥
|
||||
|
||||
```text
|
||||
cardsync:inflight:{provider}:{sync_type}:{card_id}
|
||||
```
|
||||
|
||||
- 只覆盖一次 Gateway 请求的执行时间,TTL 为请求超时加安全余量。
|
||||
- 防止轮询、手动刷新和事件任务同时请求并重复应用同一观测。
|
||||
- 轮询命中互斥时延迟短时间重排;事件尝试命中互斥时只跳过当前尝试,后续阶梯任务仍存在。
|
||||
|
||||
#### 运营商最小请求间隔
|
||||
|
||||
最小间隔按运营商接入和接口类型配置,例如实名、流量、状态可以不同,不能统一写死为五分钟。
|
||||
|
||||
- 默认兜底值建议为 10 秒,仅用于阻止近乎同时的重复调用。
|
||||
- 如果上游明确给出更严格限制,以运营商配置为准。
|
||||
- 高价值状态变更事件命中最小间隔时,延迟到最近允许时间,不直接丢弃整个序列。
|
||||
|
||||
#### 超频结果处理
|
||||
|
||||
- Gateway 返回超频时,当前尝试记录为 `rate_limited` 后直接结束。
|
||||
- 不记录 `blocked_until`,不读取 `Retry-After`,也不执行 `30s/60s/120s` 退避。
|
||||
- 不为当前尝试额外补发任务,原定 3 分钟、5 分钟尝试保持不变。
|
||||
- 后续轮询仍按正常调度时间继续,是否再次超频由当时上游实际情况决定。
|
||||
- 轮询、事件和手动刷新仍共享本系统的并发控制,避免本系统自身在同一时刻并发打满 Gateway。
|
||||
|
||||
因此,“5 分钟”是第三次尝试的计划时间,不是禁止新业务事件同步的冷却时间。
|
||||
|
||||
### 6.5 开放接口行为
|
||||
|
||||
开放接口继续返回本地快照,不等待 Gateway:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent as 代理系统
|
||||
participant API as Open API
|
||||
participant DB as PostgreSQL
|
||||
participant Trigger as CreateSyncTriggerSeries
|
||||
participant Worker as Sync Attempt Worker
|
||||
|
||||
Agent->>API: 查询实名/状态/流量
|
||||
API->>DB: 查询当前本地快照
|
||||
DB-->>API: 当前数据
|
||||
API-->>Agent: 按现有结构立即返回
|
||||
API->>Trigger: Best Effort 创建 0/3/5 序列
|
||||
Trigger-->>Worker: 同场景重复请求自动合并
|
||||
```
|
||||
|
||||
## 七、运营商实名回调防腐层
|
||||
|
||||
### 7.1 防腐层职责
|
||||
|
||||
“百分百信任回调”表示信任其业务结论,不表示让运营商报文直接进入领域模型。
|
||||
|
||||
```go
|
||||
type CarrierRealnameCallbackTranslator interface {
|
||||
TranslateSuccess(body []byte) (CarrierRealnameNotice, error)
|
||||
TranslateRemoval(body []byte) (CarrierRealnameRemovalNotice, error)
|
||||
SuccessResponse() CallbackResponse
|
||||
FailureResponse(err error) CallbackResponse
|
||||
}
|
||||
```
|
||||
|
||||
各运营商 Adapter 负责:
|
||||
|
||||
- 解析 JSON、XML 或表单字段。
|
||||
- 提取并校验 19/20 位 ICCID、MSISDN 等标识。
|
||||
- 把运营商状态码翻译为“实名成功”或“解除实名通知”。
|
||||
- 屏蔽运营商字段名、状态码和成功响应格式。
|
||||
- 生成脱敏 Integration Log 摘要。
|
||||
|
||||
领域层只接收标准 `RealnameObservation`,不依赖移动、联通、电信的报文结构。
|
||||
|
||||
#### ICCID 19/20 位解析
|
||||
|
||||
回调必须复用现有双列存储和按长度路由规则:
|
||||
|
||||
```text
|
||||
收到 19 位 ICCID
|
||||
-> 原值查询 tb_iot_card.iccid_19
|
||||
|
||||
收到 20 位 ICCID
|
||||
-> 原值查询 tb_iot_card.iccid_20
|
||||
|
||||
收到其他长度
|
||||
-> invalid_payload,不进入实名用例
|
||||
```
|
||||
|
||||
- 先去除首尾空白,再调用现有 ICCID Validator,只接受 19 位或 20 位字母数字值。
|
||||
- 禁止把 20 位回调值截成 19 位后降级查询。
|
||||
- 禁止为 19 位值自行补第 20 位校验位。
|
||||
- 查询 Miss 时不切换另一列重试,记录原始长度、运营商和脱敏 ICCID 摘要。
|
||||
- 复用 `IotCardStore.GetByICCID` 的长度路由语义;防腐层只负责提取和校验,不自己拼接模糊 SQL。
|
||||
- `iccid_19` 必须在未删除卡中保持唯一。发布前检查重复前缀并建立 Partial Unique Index,避免 19 位回调错误命中任意一张卡。
|
||||
- Repository 查询若发现多条匹配必须返回冲突,不允许使用 `First` 静默选择。
|
||||
- Integration Log 的 `resource_key` 保存回调原始 19/20 位值,展示时按审计权限脱敏。
|
||||
|
||||
### 7.2 路由
|
||||
|
||||
```http
|
||||
POST /api/callback/carriers/cmcc/realname
|
||||
POST /api/callback/carriers/cucc/realname
|
||||
POST /api/callback/carriers/ctcc/realname
|
||||
|
||||
POST /api/callback/carriers/cmcc/realname/remove
|
||||
POST /api/callback/carriers/cucc/realname/remove
|
||||
POST /api/callback/carriers/ctcc/realname/remove
|
||||
```
|
||||
|
||||
- 成功回调:防腐层转换后直接调用 `ApplyCardObservation(Verified=true)`。
|
||||
- 解除回调:只写统一 Integration Log,状态记为 `ignored`,不修改卡状态。
|
||||
- 广电或其他没有回调能力的接入继续依赖事件和轮询。
|
||||
- 不验证回调是否真的来自运营商,不执行 Gateway 二次查询。
|
||||
- 不调用旧平台 `inner_callback`、第三方推送或删除实名接口。
|
||||
|
||||
### 7.3 响应原则
|
||||
|
||||
- 找到卡并应用成功:返回运营商要求的成功报文。
|
||||
- 重复成功回调:幂等返回成功,不重复激活套餐。
|
||||
- 找不到卡:Integration Log 记 `not_found`,仍返回成功,避免无意义高频重推。
|
||||
- 报文无法解析:记录 `invalid_payload`;若运营商有固定成功应答要求,仍按接入约定返回,避免不可控重试风暴。
|
||||
- 状态已落库但异步联动失败:返回成功,联动通过 Outbox 重试。
|
||||
|
||||
## 八、统一审计契约
|
||||
|
||||
同步运行数据不在本方案定义独立表和独立页面,统一由 `04-全局多视角审计方案.md` 管理。
|
||||
|
||||
本模块需要向审计系统提供:
|
||||
|
||||
| 记录 | 进入位置 |
|
||||
|---|---|
|
||||
| 每次 Gateway 实际请求或被限流、合并的尝试 | Integration Log |
|
||||
| 每次运营商实名/解除实名回调 | Integration Log |
|
||||
| 实名、流量、网络状态发生业务变化 | Audit Event + Integration Log |
|
||||
| 手动刷新、手动实名修改 | Audit Event + Integration Log |
|
||||
| 连续失败达到阈值、超频持续异常 | 风险 Audit Event |
|
||||
| 普通高频轮询成功且数据未变化 | 仅 Integration Log |
|
||||
|
||||
必须携带:
|
||||
|
||||
```text
|
||||
provider / operation / resource / trigger_scene / trigger_series
|
||||
attempt / result / duration / request_id / correlation_id
|
||||
state_changed / provider_code / error_summary
|
||||
```
|
||||
|
||||
资产详情的“查看同步轨迹”跳转审计中心外部集成视角,不再建设 `/operations/card-sync` 独立监控页。
|
||||
|
||||
## 九、接口和前端调整
|
||||
|
||||
### 9.1 后端接口
|
||||
|
||||
本需求不新增显式同步接口。
|
||||
|
||||
现有手动刷新接口内部改为调用 `RefreshAssetUseCase`,响应结构和权限保持不变。现有轮询监控接口增加:
|
||||
|
||||
- 活跃卡数量、不活跃卡数量。
|
||||
- 各活跃级别的实际轮询 QPS。
|
||||
- 因互斥或运营商最小间隔而延迟的任务数,以及 Gateway 超频失败次数。
|
||||
|
||||
资产实时状态或详情 Query 增加可选调度信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"polling": {
|
||||
"enabled": true,
|
||||
"activity_level": "active",
|
||||
"last_activity_at": "2026-07-15T10:00:00+08:00",
|
||||
"last_activity_scene": "client_asset_info",
|
||||
"next_poll_at": "2026-07-15T10:01:00+08:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同步明细查询复用全局审计接口:
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/integrations
|
||||
?provider=gateway
|
||||
&resource_type=iot_card
|
||||
&resource_key=89860...
|
||||
&trigger_scene=
|
||||
&trigger_series=
|
||||
```
|
||||
|
||||
### 9.2 前端
|
||||
|
||||
保留现有资产刷新按钮,不新增第二个同步按钮。
|
||||
|
||||
资产详情增加紧凑的轮询状态展示:
|
||||
|
||||
```text
|
||||
自动轮询:已开启
|
||||
活跃状态:活跃
|
||||
最后活跃:2分钟前,C端资产详情
|
||||
下次兜底:约1分钟后
|
||||
同步轨迹:查看
|
||||
```
|
||||
|
||||
“查看”跳转:
|
||||
|
||||
```text
|
||||
/operations/audit?tab=integrations&resource_type=iot_card&resource_key={identifier}
|
||||
```
|
||||
|
||||
轮询监控页只增加活跃/不活跃分布和限流状态,不重复实现 Integration Log 表格、详情抽屉和导出。
|
||||
|
||||
## 十、代码迁移范围
|
||||
|
||||
### 10.1 必须删除或收口
|
||||
|
||||
- 删除 `PollingRealnameHandler` 按 `CardCategoryIndustry` 跳过实名的判断。
|
||||
- 修正 `pkg/constants/iot.go` 中“普通卡必需实名、行业卡无需实名”的绝对化注释和依赖逻辑。
|
||||
- `PollingRealnameHandler`、`PollingCarddataHandler`、`PollingCardStatusHandler` 只调用 Application 用例,不再直接更新卡或执行业务联动。
|
||||
- 删除重复流量增量算法,只保留领域实现。
|
||||
- `RefreshCardDataFromGateway` 改为调用 `RequestCardObservation + ApplyCardObservation`。
|
||||
- `ManualUpdateRealnameStatus` 改为调用领域用例。
|
||||
- 获取实名链接后的 `ManualTriggerService.TriggerSingle` 改为 `CreateSyncTriggerSeries`。
|
||||
- C 端和后台设备操作中直接调用 Gateway 的路径迁入 Application,再在用例成功边界创建触发序列。
|
||||
|
||||
### 10.2 保留并复用
|
||||
|
||||
- Gateway Client 的加密、签名和 HTTP 封装。
|
||||
- Redis 分片 Sorted Set 调度基础设施。
|
||||
- Asynq Worker。
|
||||
- 卡流量同步锁,迁移为通用请求互斥实现。
|
||||
- 现有资产手动刷新接口和权限契约。
|
||||
- 现有批量轮询运维能力;其执行逻辑和记录迁入新用例与统一审计后,再下线旧专用日志表。
|
||||
|
||||
### 10.3 数据变更
|
||||
|
||||
- 不新增实名要求字段,继续以 `tb_carrier.realname_link_type` 判断是否需要实名及链接生成方式。
|
||||
- 发布前检查 `iccid_19` 重复数据并建立未删除数据范围内的唯一索引,保证 19 位回调精确命中。
|
||||
- 卡增加轮询活跃标记字段,或由独立调度状态表保存;实现阶段根据写入频率决定,不能把高频调度心跳写入核心卡表。
|
||||
- 现有 `tb_polling_config` 条件匹配迁移为单一全局活跃/不活跃策略,不再使用 `card_condition/card_category` 形成隐式轮询资格。
|
||||
- 事件任务载荷增加 `scene/series_id/attempt/source/request_id/correlation_id/expected_state`。
|
||||
- 不创建 `tb_card_sync_execution`。
|
||||
|
||||
## 十一、发布与人工验证
|
||||
|
||||
停机发布,API 与 Worker 同时切换:
|
||||
|
||||
1. 验证 `realname_link_type=none/template/gateway` 分别对应无需实名、模板实名和 Gateway 实名,不再按行业卡统一跳过。
|
||||
2. 验证 `enable_polling=false` 会移除自动轮询,但业务事件和手动刷新仍能按各自规则工作。
|
||||
3. 验证活跃卡使用活跃间隔,不活跃卡使用低频间隔,重新活跃后立即恢复。
|
||||
4. 验证一次业务事件形成立即、3 分钟、5 分钟三个任务。
|
||||
5. 验证停复机、实名和切卡达到预期状态后,剩余任务不再请求 Gateway。
|
||||
6. 验证同场景高频查询只合并重复序列,不压制新的停复机或支付事件。
|
||||
7. 验证单卡互斥只覆盖请求执行时间,不形成五分钟全局冷却。
|
||||
8. 验证 Gateway 超频时当前尝试直接记为 `rate_limited`,不创建退避状态,后续阶梯任务仍保留。
|
||||
9. 验证现有后台和 C 端刷新接口契约不变,且不额外创建阶梯序列。
|
||||
10. 验证移动、联通、电信回调均先经过防腐层,19 位和 20 位 ICCID 分别按双列精确路由,重复成功回调不重复激活套餐。
|
||||
11. 验证解除实名回调只写 Integration Log,不修改卡实名状态。
|
||||
12. 验证开放接口仍立即返回本地数据,事件触发失败不改变响应。
|
||||
13. 验证普通同步、状态变化、手动刷新和连续失败能够在审计中心按资源和触发序列查询。
|
||||
878
docs/7月迭代/独立方案/新增需求/02-企业微信审批接入.md
Normal file
878
docs/7月迭代/独立方案/新增需求/02-企业微信审批接入.md
Normal file
@@ -0,0 +1,878 @@
|
||||
# 新增需求 02:企业微信审批接入
|
||||
|
||||
> 状态:已合并至标准评审稿,本文保留为实施明细。
|
||||
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
|
||||
> 范围:退款审批、平台员工线下充值审批,以及企业微信模板配置、回调和轮询补偿。
|
||||
|
||||
## 一、已确认决策
|
||||
|
||||
1. 不建设通用审批流引擎,审批节点、审批人、会签/或签和流程配置全部由企业微信审批模板负责。
|
||||
2. 本系统只建设企业微信审批接入、状态镜像和审批终态后的业务处理。
|
||||
3. 退款金额在申请时固定,企微只决定通过或驳回;不允许审批人在终态修改退款金额。
|
||||
4. 线下充值不再要求操作密码,企微审批通过后自动入账。
|
||||
5. 企微审批通过后又撤销,不自动冲正已经完成的退款或充值,只记录高等级异常、发送通知并人工处理。
|
||||
6. 回调是主通道,每 2 分钟查询审批详情作为待审批单兜底。
|
||||
7. 回调与轮询必须进入同一个状态同步用例,业务终态处理只能执行一次。
|
||||
8. 企微模板 ID 和控件 ID 都可能因管理员编辑模板而变化,必须使用稳定业务场景码、不可变模板版本和控件映射快照。
|
||||
9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application,旧业务单级通过、驳回、退回和确认入账接口下线,不保留两套审批入口。
|
||||
10. 系统无法从旧模板 ID 自动发现编辑后生成的新模板 ID;模板变更必须先暂停业务场景,再发布新映射并原子切换,避免继续向旧模板提交。
|
||||
11. 退款仍为财务人工退款,本系统不调用微信、支付宝等支付渠道退款接口;只有代理钱包支付订单自动回溯原扣款代理主钱包,不向个人客户或资产钱包自动回款。
|
||||
12. 系统账号与企微成员使用 Web 登录二维码自助绑定,普通运营不手工查找或录入 `userid`;管理端只查看状态和强制解绑。
|
||||
|
||||
## 二、系统边界
|
||||
|
||||
企业微信负责:
|
||||
|
||||
- 审批模板编辑。
|
||||
- 审批节点和审批人配置。
|
||||
- 审批中的通过、驳回、撤销和删除。
|
||||
- 审批意见和审批附件展示。
|
||||
- 企业微信端待办提醒。
|
||||
|
||||
本系统负责:
|
||||
|
||||
- 退款和线下充值业务单创建。
|
||||
- 把本地业务快照和附件提交到企微。
|
||||
- 保存企微审批单号和状态镜像。
|
||||
- 接收、解密企微回调并查询审批详情。
|
||||
- 审批通过后记录人工退款终态、按规则回溯代理主钱包,或执行线下充值入账。
|
||||
- 审批驳回、撤销、删除后的本地业务状态更新。
|
||||
- 站内结果通知、异常告警和审计。
|
||||
|
||||
本系统不再保存审批节点、审批任务、审批人候选集合或本地审批动作。
|
||||
|
||||
## 三、总体流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 平台员工
|
||||
participant API as Refund/Recharge Application
|
||||
participant DB as PostgreSQL
|
||||
participant Outbox as Outbox
|
||||
participant Worker as WeCom Submit Worker
|
||||
participant WeCom as 企业微信审批
|
||||
participant Callback as 企微回调
|
||||
participant Sync as SyncApprovalStatus
|
||||
participant Biz as 业务终态用例
|
||||
|
||||
User->>API: 创建退款/线下充值申请
|
||||
API->>DB: 保存业务单和企微审批实例(submitting)
|
||||
API->>Outbox: 同事务写 SubmissionRequested
|
||||
API-->>User: 返回业务单和提交中状态
|
||||
Outbox->>Worker: 投递提交任务
|
||||
Worker->>WeCom: 上传附件、applyevent
|
||||
WeCom-->>Worker: sp_no
|
||||
Worker->>DB: 保存 sp_no,状态改为 pending
|
||||
|
||||
WeCom->>Callback: 加密审批事件
|
||||
Callback->>Callback: 验签、解密、保存事件
|
||||
Callback->>Sync: 提交状态同步
|
||||
Sync->>WeCom: getapprovaldetail
|
||||
WeCom-->>Sync: 审批详情
|
||||
Sync->>DB: 幂等更新审批状态和详情快照
|
||||
Sync->>Outbox: 首次进入终态时写业务处理事件
|
||||
Outbox->>Biz: 可靠执行退款/充值终态用例
|
||||
```
|
||||
|
||||
## 四、基础配置
|
||||
|
||||
企微基础连接配置使用现有 Viper 统一配置体系,不通过通用 Key-Value 页面展示明文密钥:
|
||||
|
||||
```yaml
|
||||
wecom:
|
||||
corp_id: ""
|
||||
agent_id: 0
|
||||
agent_secret: ""
|
||||
callback_token: ""
|
||||
callback_encoding_aes_key: ""
|
||||
callback_path: "/api/callback/wecom/approval"
|
||||
account_binding_redirect_url: "https://后台域名/api/callback/wecom/account-binding"
|
||||
approval_poll_interval: 2m
|
||||
template_verify_interval: 10m
|
||||
request_timeout: 10s
|
||||
```
|
||||
|
||||
环境变量覆盖敏感项:
|
||||
|
||||
```text
|
||||
JUNHONG_WECOM_CORP_ID
|
||||
JUNHONG_WECOM_AGENT_ID
|
||||
JUNHONG_WECOM_AGENT_SECRET
|
||||
JUNHONG_WECOM_CALLBACK_TOKEN
|
||||
JUNHONG_WECOM_CALLBACK_ENCODING_AES_KEY
|
||||
JUNHONG_WECOM_ACCOUNT_BINDING_REDIRECT_URL
|
||||
```
|
||||
|
||||
后台只能查询“是否已配置、最近连通时间、最近错误”,不能返回 Secret、Token 或 EncodingAESKey。
|
||||
|
||||
企微自建应用还必须配置:
|
||||
|
||||
- Web 登录回调可信域名与 `account_binding_redirect_url` 域名一致。
|
||||
- 应用可见范围覆盖需要发起退款或线下充值审批的平台员工,否则扫码时企微会提示无权限。
|
||||
- `CorpID`、`AgentID`、用于换取身份的 Access Token 必须属于同一个自建应用配置。
|
||||
|
||||
用户提供的 demo 中已经出现完整密钥,正式接入前必须在企业微信后台轮换,并禁止将新值写入代码、Markdown、日志或审计快照。
|
||||
|
||||
## 五、模板映射与版本
|
||||
|
||||
### 5.1 为什么不能只配置 template_id
|
||||
|
||||
企业微信模板编辑后可能生成新的模板 ID;删除并重新增加控件时,控件 ID 也会变化。业务代码如果写死:
|
||||
|
||||
```go
|
||||
templateID = "..."
|
||||
amountControlID = "Text-..."
|
||||
```
|
||||
|
||||
模板一旦编辑,新审批立即提交失败,历史审批也无法说明当时使用了哪套字段。
|
||||
|
||||
因此分为三层:
|
||||
|
||||
```text
|
||||
稳定业务场景 scene_code
|
||||
↓ 当前启用
|
||||
不可变模板版本 template_version
|
||||
↓ 包含
|
||||
template_id + control_mapping + template_snapshot
|
||||
```
|
||||
|
||||
### 5.2 稳定业务场景
|
||||
|
||||
第一版固定两个场景:
|
||||
|
||||
| scene_code | 中文名称 | 业务类型 |
|
||||
|---|---|---|
|
||||
| `refund_approval` | 退款审批 | refund |
|
||||
| `offline_recharge_approval` | 线下充值审批 | agent_recharge |
|
||||
|
||||
业务代码只引用 `scene_code`,不直接引用企微模板 ID。
|
||||
|
||||
### 5.3 场景运行状态
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_wecom_approval_scene (
|
||||
scene_code VARCHAR(64) PRIMARY KEY,
|
||||
scene_name VARCHAR(255) NOT NULL,
|
||||
status INT NOT NULL DEFAULT 0,
|
||||
current_template_version_id BIGINT,
|
||||
paused_reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
updated_by BIGINT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
`status`:`0=已暂停, 1=启用, 2=暂停中`。不建立数据库外键,`current_template_version_id` 由领域规则保证引用有效版本。
|
||||
|
||||
企微不会根据旧模板 ID 告诉本系统“这个模板已经产生了一个新 ID”。如果旧 ID 仍可访问,仅轮询 `gettemplatedetail` 也无法发现新模板。因此模板编辑采用明确的维护流程:
|
||||
|
||||
```text
|
||||
将 scene_code 改为暂停中,立即阻止新申请和新提交租约
|
||||
→ 等待已经取得提交租约的 Worker 完成或释放
|
||||
→ 场景进入已暂停
|
||||
→ 在企微编辑模板并取得新 template_id
|
||||
→ 本系统读取新模板并完成控件映射
|
||||
→ 发布不可变模板版本
|
||||
→ 同一事务切换 current_template_version_id 并恢复场景
|
||||
```
|
||||
|
||||
场景暂停后,退款或线下充值创建接口在写业务单前直接拒绝新申请,并返回“审批模板维护中”;已取得 `sp_no` 的历史审批继续接收回调和轮询,不受影响。
|
||||
|
||||
提交 Worker 调用企微前通过条件更新取得短期 `submit_lease_until`。暂停操作先把场景改为 `暂停中`,此后不再发放新租约;租约全部释放或到期后才显示“已暂停”。这样运营人员看到“已暂停”时,可以确认没有旧模板提交正在进行。
|
||||
|
||||
租约覆盖附件上传和 `applyevent` 调用并由 Worker 定时续期;处理结束时使用 `defer` 释放,提交结果未知也必须先持久化状态再释放。暂停流程只认可未过期租约已经全部消失,不能仅按任务进程是否存活判断。
|
||||
|
||||
### 5.4 模板版本表
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_wecom_approval_template_version (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scene_code VARCHAR(64) NOT NULL,
|
||||
version_no INT NOT NULL,
|
||||
template_id VARCHAR(128) NOT NULL,
|
||||
template_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
status INT NOT NULL DEFAULT 1,
|
||||
control_mapping JSONB NOT NULL,
|
||||
template_snapshot JSONB NOT NULL,
|
||||
template_fingerprint VARCHAR(64) NOT NULL,
|
||||
last_verified_at TIMESTAMPTZ,
|
||||
last_verify_error TEXT,
|
||||
published_by BIGINT NOT NULL,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
disabled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (scene_code, version_no)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_wecom_template_active_scene
|
||||
ON tb_wecom_approval_template_version (scene_code)
|
||||
WHERE status = 1;
|
||||
```
|
||||
|
||||
`status`:`0=已停用, 1=启用, 2=失效`。
|
||||
|
||||
发布新版本时,在一个事务内停用旧版本并创建新版本。旧审批实例继续引用旧版本记录,不更新历史快照。
|
||||
|
||||
### 5.5 控件映射
|
||||
|
||||
稳定业务字段映射到当前模板控件:
|
||||
|
||||
```json
|
||||
{
|
||||
"shop_name": {
|
||||
"control": "Text",
|
||||
"control_id": "Text-xxx",
|
||||
"required": true
|
||||
},
|
||||
"business_no": {
|
||||
"control": "Text",
|
||||
"control_id": "Text-yyy",
|
||||
"required": true
|
||||
},
|
||||
"amount": {
|
||||
"control": "Money",
|
||||
"control_id": "Money-zzz",
|
||||
"required": true
|
||||
},
|
||||
"attachment": {
|
||||
"control": "File",
|
||||
"control_id": "File-aaa",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
退款场景必填业务字段:
|
||||
|
||||
```text
|
||||
shop_name, refund_no, order_no, asset_identifier,
|
||||
requested_refund_amount, refund_reason, attachment, submitter
|
||||
```
|
||||
|
||||
线下充值场景必填业务字段:
|
||||
|
||||
```text
|
||||
shop_name, recharge_no, amount, remark, attachment, submitter
|
||||
```
|
||||
|
||||
模板发布时必须调用 `gettemplatedetail` 校验:
|
||||
|
||||
- `template_id` 可访问。
|
||||
- 映射的每个控件 ID 确实存在。
|
||||
- 控件类型与业务字段要求一致。
|
||||
- 必填业务字段全部完成映射。
|
||||
- 同一个控件不能绑定两个业务字段。
|
||||
|
||||
### 5.6 模板失效检测
|
||||
|
||||
后台任务每 10 分钟验证所有启用模板:
|
||||
|
||||
1. 调用 `gettemplatedetail`。
|
||||
2. 使用控件 ID、类型和名称生成 SHA-256 fingerprint。
|
||||
3. 模板不可访问或 fingerprint 变化时,将版本标记为 `status=2`。
|
||||
4. 模板失效时同时暂停对应场景,禁止创建和提交新审批,但不影响已提交审批的回调和状态查询。
|
||||
5. 发送站内系统告警,提示管理员发布新的模板映射版本。
|
||||
|
||||
该检测只能发现“当前模板 ID 已失效或内容发生变化”,不能自动发现企微生成的新模板 ID,所以不能替代上述暂停和发布流程。
|
||||
|
||||
提交审批前,如果 `last_verified_at` 超过验证间隔,Worker 先同步验证一次。验证失败不调用 `applyevent`。
|
||||
|
||||
## 六、内部账号与企微成员绑定
|
||||
|
||||
创建人必须有明确的企微 `userid`,不使用固定手机号冒充所有申请人,也不要求运营人员进入企微后台查找成员 ID。
|
||||
|
||||
### 6.1 绑定流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 已登录平台员工
|
||||
participant FE as 管理后台
|
||||
participant API as WeComIdentity Application
|
||||
participant Redis as Redis绑定会话
|
||||
participant Login as 企业微信Web登录
|
||||
participant WeCom as 企业微信API
|
||||
participant DB as PostgreSQL
|
||||
|
||||
User->>FE: 点击绑定企业微信
|
||||
FE->>API: 创建绑定会话
|
||||
API->>Redis: 保存一次性state,TTL 5分钟
|
||||
API-->>FE: 返回企微登录URL和session_id
|
||||
FE->>Login: 新窗口打开企微二维码
|
||||
User->>Login: 使用企业微信扫码确认
|
||||
Login->>API: 回调code + state
|
||||
API->>Redis: 原子消费state
|
||||
API->>WeCom: auth/getuserinfo(code)
|
||||
WeCom-->>API: userid
|
||||
API->>DB: 校验唯一性并保存绑定
|
||||
API-->>FE: postMessage通知绑定结果
|
||||
```
|
||||
|
||||
后端构造官方 Web 登录地址:
|
||||
|
||||
```text
|
||||
https://login.work.weixin.qq.com/wwlogin/sso/login
|
||||
?login_type=CorpApp
|
||||
&appid={CorpID}
|
||||
&agentid={AgentID}
|
||||
&redirect_uri={URLEncode后的回调地址}
|
||||
&state={一次性随机值}
|
||||
```
|
||||
|
||||
回调取得的 `code` 只能使用一次且 5 分钟过期。后端使用同一自建应用的 Access Token 调用:
|
||||
|
||||
```http
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo
|
||||
?access_token={access_token}
|
||||
&code={code}
|
||||
```
|
||||
|
||||
返回 `userid` 才允许绑定;返回 `openid` 表示不是本企业成员,直接拒绝。企微身份接口不返回 `corp_id`,企业归属由登录 URL 中的 `CorpID` 和用于换取身份的自建应用 Access Token 共同限定。
|
||||
|
||||
第一版使用官方登录页面新窗口,不引入前端 SDK。回调成功页使用固定后台 Origin 的 `window.opener.postMessage` 通知原页面并关闭窗口;窗口通信失败时,原页面通过 `session_id` 轮询会话状态兜底。
|
||||
|
||||
### 6.2 绑定会话
|
||||
|
||||
绑定会话只存 Redis,不建长期数据库表。`state` 与前端查询会话分开保存,避免回调消费 `state` 后无法查询结果:
|
||||
|
||||
```text
|
||||
key: RedisWecomAccountBindingStateKey(state)
|
||||
ttl: 5分钟
|
||||
value:
|
||||
session_id
|
||||
|
||||
key: RedisWecomAccountBindingSessionKey(session_id)
|
||||
ttl: 10分钟
|
||||
value:
|
||||
target_account_id
|
||||
initiator_account_id
|
||||
allowed_origin
|
||||
status
|
||||
error_code
|
||||
created_at
|
||||
```
|
||||
|
||||
- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话。
|
||||
- `state` 使用密码学安全随机数,回调时通过 Lua 原子读取并删除;后续成功或失败结果写入独立的 `session_id` 会话。
|
||||
- 回调不接受前端传入的 `account_id`,绑定目标只能来自服务端会话。
|
||||
- 同一账号再次创建会话时,旧会话立即失效。
|
||||
- `allowed_origin` 从服务端后台域名配置生成,不能接受请求参数覆盖。
|
||||
- `session_id` 仅用于当前登录用户查询结果,不能作为绑定凭证。
|
||||
|
||||
### 6.3 绑定数据
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_account_wecom_mapping (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL UNIQUE,
|
||||
wecom_userid VARCHAR(128) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
corp_id VARCHAR(64) NOT NULL,
|
||||
agent_id BIGINT NOT NULL,
|
||||
bind_source VARCHAR(32) NOT NULL DEFAULT 'self_scan',
|
||||
bound_by BIGINT NOT NULL,
|
||||
status INT NOT NULL DEFAULT 1,
|
||||
verified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
- 平台员工创建退款或线下充值时必须存在启用绑定,否则拒绝创建并返回可直接拉起绑定窗口的错误状态。
|
||||
- `account_id` 与 `wecom_userid` 都是一对一唯一;企微成员已绑定其他账号时拒绝覆盖,必须先由超级管理员解除旧绑定。
|
||||
- 重新绑定同一账号必须再次扫码,成功后在事务内替换旧身份并记录前后值审计。
|
||||
- 自助解绑不影响历史审批;历史实例继续使用提交时保存的 `creator_wecom_userid` 快照,新审批在重新绑定前禁止创建。
|
||||
- `display_name` 通过读取成员接口获取;权限不足时允许为空,但不影响以 `userid` 发起审批。
|
||||
- 企微审批详情中的审批人 `userid` 原样保存为快照;能匹配本地账号时额外返回本地账号 ID。
|
||||
- 用户离职或映射失效不修改历史审批人快照。
|
||||
|
||||
普通运营界面不提供手工录入 `userid`。超级管理员仅能查看、强制解绑并要求员工重新扫码,不提供直接改写成员 ID 的入口。
|
||||
|
||||
## 七、审批实例
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_wecom_approval_instance (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
biz_type VARCHAR(64) NOT NULL,
|
||||
biz_id BIGINT NOT NULL,
|
||||
biz_no VARCHAR(64) NOT NULL DEFAULT '',
|
||||
round_no INT NOT NULL DEFAULT 1,
|
||||
scene_code VARCHAR(64) NOT NULL,
|
||||
template_version_id BIGINT NOT NULL,
|
||||
template_id_snapshot VARCHAR(128) NOT NULL,
|
||||
control_mapping_snapshot JSONB NOT NULL,
|
||||
creator_account_id BIGINT NOT NULL,
|
||||
creator_wecom_userid VARCHAR(128) NOT NULL,
|
||||
sp_no VARCHAR(128),
|
||||
status INT NOT NULL DEFAULT 0,
|
||||
submitted_snapshot JSONB NOT NULL,
|
||||
approval_detail_snapshot JSONB,
|
||||
approver_snapshot JSONB,
|
||||
apply_error TEXT,
|
||||
submit_lease_until TIMESTAMPTZ,
|
||||
callback_at TIMESTAMPTZ,
|
||||
last_polled_at TIMESTAMPTZ,
|
||||
status_changed_at TIMESTAMPTZ,
|
||||
business_processed_at TIMESTAMPTZ,
|
||||
business_process_result VARCHAR(20),
|
||||
business_process_error TEXT,
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (biz_type, biz_id, round_no)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_wecom_approval_sp_no
|
||||
ON tb_wecom_approval_instance (sp_no)
|
||||
WHERE sp_no IS NOT NULL;
|
||||
```
|
||||
|
||||
内部状态:
|
||||
|
||||
| status | 含义 | 对应企微状态 |
|
||||
|---:|---|---|
|
||||
| 0 | 提交中 | 尚未取得 sp_no |
|
||||
| 1 | 审批中 | `sp_status=1` |
|
||||
| 2 | 已通过 | `sp_status=2` |
|
||||
| 3 | 已驳回 | `sp_status=3` |
|
||||
| 4 | 已撤销 | `sp_status=4` |
|
||||
| 5 | 通过后撤销 | `sp_status=6` |
|
||||
| 6 | 已删除 | `sp_status=7` |
|
||||
| 7 | 提交失败 | 明确未创建审批 |
|
||||
| 8 | 提交结果未知 | 请求超时,无法确认是否创建 |
|
||||
|
||||
`submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key,不保存临时 `media_id`、签名 URL 或敏感密钥。
|
||||
|
||||
## 八、DDD 设计
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── domain/wecomapproval/
|
||||
│ ├── approval.go 审批状态机和终态幂等规则
|
||||
│ ├── scene.go 场景暂停、启用和版本切换不变量
|
||||
│ ├── template.go 不可变模板版本和控件映射
|
||||
│ ├── events.go 审批通过/驳回/异常事件
|
||||
│ └── repository.go
|
||||
├── application/wecomapproval/
|
||||
│ ├── change_scene_status.go
|
||||
│ ├── publish_template_version.go
|
||||
│ ├── submit_approval.go
|
||||
│ ├── sync_approval_status.go
|
||||
│ ├── handle_callback.go
|
||||
│ ├── poll_pending.go
|
||||
│ └── resubmit_approval.go
|
||||
├── domain/wecomidentity/
|
||||
│ ├── binding.go 账号与企微成员一对一绑定规则
|
||||
│ └── repository.go
|
||||
├── application/wecomidentity/
|
||||
│ ├── create_binding_session.go
|
||||
│ ├── complete_binding.go
|
||||
│ ├── get_my_binding.go
|
||||
│ └── unbind_account.go
|
||||
├── domain/refund/
|
||||
│ ├── refund.go 退款金额和状态不变量
|
||||
│ └── events.go
|
||||
├── application/refund/
|
||||
│ ├── create_refund.go
|
||||
│ ├── complete_wecom_approved.go
|
||||
│ └── reject_from_wecom.go
|
||||
├── application/recharge/
|
||||
│ ├── create_offline_recharge.go
|
||||
│ ├── complete_wecom_approved.go
|
||||
│ └── reject_from_wecom.go
|
||||
└── infrastructure/adapter/wecom/
|
||||
├── client.go
|
||||
├── token_provider.go
|
||||
├── web_login.go
|
||||
├── identity.go
|
||||
├── approval.go
|
||||
├── media.go
|
||||
└── callback_crypto.go
|
||||
```
|
||||
|
||||
退款和充值的旧 Service 不再作为审批业务入口。列表查询进入 `internal/query/refund`、`internal/query/recharge` 和 `internal/query/wecomapproval`。
|
||||
|
||||
## 九、审批提交
|
||||
|
||||
### 9.1 为什么异步提交
|
||||
|
||||
本地业务单与企微远程审批无法放在同一个数据库事务中,因此采用本地事务 + Outbox:
|
||||
|
||||
```text
|
||||
业务单创建成功
|
||||
审批实例 status=0
|
||||
Outbox: WeComApprovalSubmissionRequested
|
||||
```
|
||||
|
||||
Worker 处理:
|
||||
|
||||
1. 仅在场景启用且租约为空或已过期时,通过条件更新取得提交租约。
|
||||
2. 加载不可变模板版本和提交快照。
|
||||
3. 确认实例引用的模板版本有效且最近验证成功。
|
||||
4. 从对象存储下载本地附件到临时文件。
|
||||
5. 逐个调用 `media/upload` 获取临时 `media_id`。
|
||||
6. 按控件映射构建 `apply_data`。
|
||||
7. 使用模板审批人配置,固定 `use_template_approver=1`。
|
||||
8. 调用 `applyevent`。
|
||||
9. 保存 `sp_no`,状态变为审批中并释放租约。
|
||||
|
||||
附件的本地对象存储 Key 是权威记录;企微 `media_id` 只是提交期间使用的临时值。
|
||||
|
||||
### 9.2 提交结果未知
|
||||
|
||||
`applyevent` 网络超时后可能已经在企微创建审批,因此不能无脑重试,否则可能产生重复审批:
|
||||
|
||||
- 收到明确 `errcode != 0`:状态改为提交失败,可修正配置后重新提交。
|
||||
- 建连失败且确认请求未发送:允许自动重试。
|
||||
- 请求已发送但响应超时/连接中断:状态改为提交结果未知,不自动再次调用 `applyevent`。
|
||||
- 提交结果未知进入异常页面,由管理员在企微核对后选择“确认未创建并重新提交”或“绑定已有 sp_no”。
|
||||
|
||||
## 十、回调和轮询
|
||||
|
||||
### 10.1 回调路由
|
||||
|
||||
```http
|
||||
GET /api/callback/wecom/approval
|
||||
POST /api/callback/wecom/approval
|
||||
```
|
||||
|
||||
- GET 按企微协议完成 URL 校验。
|
||||
- POST 使用 SHA1 签名校验和 AES-CBC 解密。
|
||||
- 解密后的 `receiveID` 必须等于配置的 CorpID。
|
||||
- 回调原始密文、解密结果摘要和处理状态写 Integration Log;不写文件系统。
|
||||
- 回调快速保存事件并返回 `success`,实际状态以 `getapprovaldetail` 为准。
|
||||
|
||||
### 10.2 轮询补偿
|
||||
|
||||
每 2 分钟查询:
|
||||
|
||||
```sql
|
||||
status = 1
|
||||
AND (last_polled_at IS NULL OR last_polled_at <= NOW() - INTERVAL '2 minutes')
|
||||
```
|
||||
|
||||
单批限制 100 条,按 `last_polled_at` 排序。回调和轮询都调用 `SyncApprovalStatus`。
|
||||
|
||||
### 10.3 状态同步幂等
|
||||
|
||||
```text
|
||||
1. 根据 sp_no 加载审批实例
|
||||
2. 调 getapprovaldetail
|
||||
3. 保存审批详情和审批人快照
|
||||
4. 使用 version 乐观锁更新状态
|
||||
5. 状态没有变化时结束
|
||||
6. 首次进入终态时在同一事务写对应业务终态 Outbox 事件
|
||||
7. 业务 Worker 调用对应终态用例,失败按错误类型重试
|
||||
8. business_processed_at 非空时不得重复执行资金动作
|
||||
```
|
||||
|
||||
## 十一、业务状态处理
|
||||
|
||||
### 11.1 退款
|
||||
|
||||
创建退款时:
|
||||
|
||||
- `requested_refund_amount` 已完成合法性校验并固定。
|
||||
- `approved_refund_amount` 不再由审批接口输入;企微通过时写为申请金额。
|
||||
- 退款凭证必须先保存本地对象存储,再上传企微副本。
|
||||
- 财务在系统外完成人工退款并通过企微审批确认结果;本系统不请求任何支付渠道退款 API。
|
||||
|
||||
退款状态在现有枚举基础上追加:
|
||||
|
||||
| status | 含义 |
|
||||
|---:|---|
|
||||
| 1 | 待审批 |
|
||||
| 2 | 已通过/人工退款已确认 |
|
||||
| 3 | 已拒绝 |
|
||||
| 4 | 已退回,仅保留历史兼容,新企微审批不再产生 |
|
||||
| 5 | 已撤销/审批已删除 |
|
||||
|
||||
不得将历史 `4=已退回` 改写为撤销,避免旧数据语义变化。
|
||||
|
||||
企微状态处理:
|
||||
|
||||
| 企微状态 | 本地退款动作 |
|
||||
|---|---|
|
||||
| 通过 | 记录人工退款完成;代理钱包支付订单回溯原扣款代理主钱包;更新订单状态,随后异步佣金回扣和资产处理 |
|
||||
| 驳回 | 退款状态改为已拒绝,保存审批详情摘要 |
|
||||
| 撤销/删除 | 退款状态改为已撤销;允许申请人修改后创建下一轮审批 |
|
||||
| 通过后撤销 | 已退款则不冲正,记录严重异常并通知财务;尚未执行则阻止退款 |
|
||||
|
||||
原 `POST /api/admin/refunds/{id}/approve`、`reject`、`return` 下线。
|
||||
|
||||
重新申请使用:
|
||||
|
||||
```http
|
||||
POST /api/admin/refunds/{id}/resubmit
|
||||
```
|
||||
|
||||
它只允许已驳回、已撤销或已删除且业务尚未退款的申请,修改业务资料后创建 `round_no + 1` 的企微审批。
|
||||
|
||||
审批通过后的业务 Worker 处理失败时,审批实例保持“已通过”,`business_process_result=failed`,由任务重试;前端明确区分“审批已通过”和“退款终态处理失败”。只有退款终态事务成功后才写 `business_processed_at`。
|
||||
|
||||
现有 `internal/service/refund/service.go` 中 `BuyerTypePersonal -> refundAssetWalletPayment` 与本次确认规则冲突,迁移退款用例时删除该分支及其专用退款回款逻辑。个人客户或资产钱包相关订单只记录人工退款结果,不自动增加资产钱包余额;代理钱包支付订单继续按原扣款流水定位并回溯原代理主钱包。
|
||||
|
||||
### 11.2 平台员工线下充值
|
||||
|
||||
创建线下充值后立即创建企微审批实例,不再暴露本地“确认入账”和“驳回”按钮。
|
||||
|
||||
| 企微状态 | 本地充值动作 |
|
||||
|---|---|
|
||||
| 通过 | 无操作密码,自动增加代理主钱包余额并写钱包流水 |
|
||||
| 驳回 | 状态改为已驳回,保存企微审批摘要 |
|
||||
| 撤销/删除 | 状态改为已关闭,可重新创建充值申请 |
|
||||
| 通过后撤销 | 已入账不自动扣回,记录严重异常并通知财务 |
|
||||
|
||||
原 `POST /api/admin/agent-recharges/{id}/offline-pay` 和 `reject` 下线。
|
||||
|
||||
线上微信/支付宝充值不进入企微审批,保持支付回调流程。
|
||||
|
||||
审批通过后的入账任务失败时按相同规则重试;只有钱包余额和流水事务成功后才写 `business_processed_at`。若审批先变为通过后撤销且入账任务尚未成功,后续入账任务检测当前状态后终止,不再加钱。
|
||||
|
||||
## 十二、API 设计
|
||||
|
||||
### 12.1 基础配置状态
|
||||
|
||||
```http
|
||||
GET /api/admin/wecom/config/status
|
||||
```
|
||||
|
||||
返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误,不返回任何密钥。
|
||||
|
||||
### 12.2 审批场景状态
|
||||
|
||||
```http
|
||||
GET /api/admin/wecom/approval-scenes
|
||||
PUT /api/admin/wecom/approval-scenes/{scene_code}/status
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 0,
|
||||
"reason": "企微模板编辑中"
|
||||
}
|
||||
```
|
||||
|
||||
暂停和恢复必须写高等级审计。请求暂停后可能先返回 `status=2`,前端轮询场景列表,只有进入 `status=0` 才允许提示运营人员开始编辑企微模板。已有启用版本的场景只允许在暂停状态发布新版本;首次初始化没有当前版本时可直接发布。
|
||||
|
||||
### 12.3 读取企微模板
|
||||
|
||||
```http
|
||||
POST /api/admin/wecom/approval-templates/inspect
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scene_code": "refund_approval",
|
||||
"template_id": "企微模板ID"
|
||||
}
|
||||
```
|
||||
|
||||
返回模板名称和可映射控件列表。
|
||||
|
||||
### 12.4 发布模板映射
|
||||
|
||||
```http
|
||||
POST /api/admin/wecom/approval-templates/publish
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scene_code": "refund_approval",
|
||||
"template_id": "企微模板ID",
|
||||
"control_mapping": {
|
||||
"shop_name": "Text-xxx",
|
||||
"refund_no": "Text-yyy",
|
||||
"requested_refund_amount": "Money-zzz",
|
||||
"attachment": "File-aaa"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
后端重新读取模板并校验,不能信任前端提交的控件类型和名称。发布成功后在同一事务内停用旧版本、写入新版本、把尚未取得 `sp_no` 且仍为 `status=0` 的实例改绑到新版本、更新场景当前版本并恢复场景;任一步失败都保持暂停,不允许部分切换。
|
||||
|
||||
### 12.5 模板版本列表
|
||||
|
||||
```http
|
||||
GET /api/admin/wecom/approval-templates?scene_code=refund_approval
|
||||
```
|
||||
|
||||
### 12.6 账号绑定
|
||||
|
||||
```http
|
||||
GET /api/admin/wecom/account-binding/me
|
||||
POST /api/admin/wecom/account-binding/sessions
|
||||
GET /api/admin/wecom/account-binding/sessions/{session_id}
|
||||
DELETE /api/admin/wecom/account-binding/me
|
||||
|
||||
GET /api/admin/wecom/account-bindings?page=1&page_size=20&binding_status=
|
||||
DELETE /api/admin/wecom/account-bindings/{account_id}
|
||||
|
||||
GET /api/callback/wecom/account-binding?code={code}&state={state}
|
||||
```
|
||||
|
||||
创建会话返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "01J...",
|
||||
"login_url": "https://login.work.weixin.qq.com/wwlogin/sso/login?...",
|
||||
"expires_at": "2026-07-15T15:05:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
列表接口不返回 Secret 或完整企微配置。普通平台用户只能查询和解绑自己的绑定;绑定列表和强制解绑仅超级管理员可用,强制解绑必须记录高等级审计。
|
||||
|
||||
### 12.7 审批记录
|
||||
|
||||
```http
|
||||
GET /api/admin/wecom/approvals
|
||||
?biz_type=refund
|
||||
&status=1
|
||||
&sp_no=
|
||||
&biz_no=
|
||||
&page=1&page_size=20
|
||||
|
||||
GET /api/admin/wecom/approvals/{id}
|
||||
POST /api/admin/wecom/approvals/{id}/sync
|
||||
POST /api/admin/wecom/approvals/{id}/bind-sp-no
|
||||
```
|
||||
|
||||
`bind-sp-no` 仅用于提交结果未知的人工恢复,必须写高等级审计日志。
|
||||
|
||||
### 12.8 业务详情响应
|
||||
|
||||
退款和充值详情统一增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval": {
|
||||
"source": "wecom",
|
||||
"instance_id": 123,
|
||||
"sp_no": "202607150001",
|
||||
"status": 2,
|
||||
"status_name": "已通过",
|
||||
"template_name": "退款审批",
|
||||
"round_no": 1,
|
||||
"creator_name": "张三",
|
||||
"approvers": [],
|
||||
"submitted_at": "2026-07-15T10:00:00+08:00",
|
||||
"status_changed_at": "2026-07-15T10:05:00+08:00",
|
||||
"business_process_result": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 十三、前端方案
|
||||
|
||||
### 13.1 审批入口变化
|
||||
|
||||
- 删除本系统待审批任务、审批节点配置、审批人配置和审批动作页面。
|
||||
- 退款和线下充值列表不再显示“通过、驳回、退回、确认入账”按钮。
|
||||
- 创建成功后展示“正在提交企业微信审批”;取得 `sp_no` 后展示“企业微信审批中”。
|
||||
- 审批操作全部在企业微信完成。
|
||||
|
||||
### 13.2 业务详情
|
||||
|
||||
退款和充值详情增加只读“企业微信审批”区块:
|
||||
|
||||
- 审批单号。
|
||||
- 当前状态和状态更新时间。
|
||||
- 模板名称和模板版本。
|
||||
- 申请人。
|
||||
- 审批人、审批结果、意见和时间线。
|
||||
- 业务处理结果。
|
||||
- “立即同步”图标按钮,仅触发 `getapprovaldetail`,不提供审批按钮。
|
||||
|
||||
通过后撤销且业务已执行时使用红色异常状态条,明确显示“资金动作未自动冲正,需人工处理”。
|
||||
|
||||
### 13.3 企微配置页
|
||||
|
||||
路由:`/system/wecom`
|
||||
|
||||
Tab:
|
||||
|
||||
1. **连接状态**:只显示配置状态和最近连通结果。
|
||||
2. **审批模板**:按业务场景展示当前版本、模板 ID、验证状态和历史版本。
|
||||
3. **账号绑定**:查看平台员工绑定状态、企微名称和最近验证时间;不允许编辑 userid。
|
||||
4. **异常审批**:提交未知、模板失效、终态业务处理失败、通过后撤销。
|
||||
|
||||
模板发布交互:
|
||||
|
||||
```text
|
||||
暂停业务场景并等待状态变为“已暂停”
|
||||
→ 在企微完成模板编辑并取得新 template_id
|
||||
→ 选择业务场景
|
||||
→ 输入新 template_id
|
||||
→ 点击“读取模板”
|
||||
→ 左侧显示业务字段,右侧下拉选择企微控件
|
||||
→ 后端校验
|
||||
→ 发布新版本并自动恢复场景
|
||||
```
|
||||
|
||||
不允许运营人员直接编辑 `control_mapping` JSON。
|
||||
场景处于暂停状态时必须在退款和线下充值创建页明确显示维护原因,不能等异步提交后才暴露错误。
|
||||
|
||||
账号绑定交互:
|
||||
|
||||
- 当前用户个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间。
|
||||
- 点击“绑定企业微信”后打开官方企微二维码窗口,原页面显示 5 分钟倒计时。
|
||||
- 绑定成功后窗口自动关闭,原页面刷新绑定状态;失败时显示企微返回的可操作原因。
|
||||
- 创建退款或线下充值时发现未绑定,页面原地展示“绑定企业微信”按钮,绑定成功后继续填写,不要求用户先去配置页。
|
||||
- 管理员的账号绑定列表只提供筛选、查看和强制解绑;不提供 userid 输入框。
|
||||
|
||||
### 13.4 审批运行页
|
||||
|
||||
列表路由:`/operations/wecom-approvals`
|
||||
|
||||
详情路由:`/operations/wecom-approvals/{id}`,复用同一页面并打开详情抽屉,供业务详情和站内通知稳定跳转。
|
||||
|
||||
页面用于运行监控,不提供审批动作:
|
||||
|
||||
- 按业务类型、企微状态、提交状态和业务处理结果筛选。
|
||||
- 查询 `sp_no`、业务单号、申请人和模板版本。
|
||||
- 查看企微审批详情快照和本地业务处理结果。
|
||||
- 对审批中记录执行“立即同步”。
|
||||
- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新提交”。
|
||||
- 对业务处理失败记录查看错误和任务重试状态。
|
||||
|
||||
## 十四、Token、日志和限流
|
||||
|
||||
- Access Token 缓存在 Redis,TTL 使用 `expires_in - 300秒`。
|
||||
- 使用 Redis 锁避免多个进程同时刷新 Token。
|
||||
- `auth/getuserinfo`、读取成员、`gettemplatedetail`、`applyevent`、`getapprovaldetail` 和 `media/upload` 分别记录接口名称、耗时、errcode、errmsg 和 request_id。
|
||||
- 日志禁止记录 access_token、Secret、EncodingAESKey、解密密钥和完整附件内容。
|
||||
- 绑定、换绑、自助解绑、管理员强制解绑写统一审计日志;Access Token 获取和企微身份查询写 Integration Log。
|
||||
- `applyevent` 不做网络层盲目重试;详情查询允许指数退避重试。
|
||||
- 审批轮询与回调同步共享企微 API 并发限制。
|
||||
|
||||
## 十五、存量数据和发布
|
||||
|
||||
停机发布:
|
||||
|
||||
1. 在企微自建应用配置 Web 登录可信域名和平台员工可见范围。
|
||||
2. 创建企微模板版本、账号绑定和审批实例表。
|
||||
3. 发布企微身份 Adapter、账号绑定回调、审批回调、轮询 Worker 和业务终态用例。
|
||||
4. 要求会发起审批的平台员工完成扫码绑定。
|
||||
5. 发布并验证退款、线下充值两个模板映射版本。
|
||||
6. 下线本地审批动作路由和前端按钮。
|
||||
7. 存量已通过/已拒绝记录保留原业务审批快照,展示 `approval.source=legacy`。
|
||||
8. 存量待审批退款和线下充值仅在创建人已绑定企微时提交;未绑定记录进入迁移待处理列表。
|
||||
9. 提交失败的存量记录进入异常审批页面,不允许继续走旧接口处理。
|
||||
|
||||
## 十六、人工验证
|
||||
|
||||
1. 场景进入暂停中后,新退款或充值申请和新提交租约被阻止;进入已暂停时没有旧模板提交仍在执行,历史审批仍可正常同步。
|
||||
2. 编辑企微模板导致 template_id 变化后,旧模板版本仍可展示,发布新映射时原子切换并恢复场景。
|
||||
3. 删除再新增控件后,旧 control ID 不会被继续使用。
|
||||
4. 退款审批通过后按申请金额确认人工退款结果,重复回调和轮询不会重复处理退款终态。
|
||||
5. 线下充值审批通过后无需操作密码自动入账,重复终态不会重复加钱。
|
||||
6. 驳回、撤销、删除状态正确同步。
|
||||
7. 通过后撤销不自动冲正,异常页面、站内消息和审计记录完整。
|
||||
8. 回调失效时,2 分钟轮询可以推进终态。
|
||||
9. 回调和轮询同时到达时,只有一个处理器执行业务动作。
|
||||
10. 附件始终保留本地对象存储 Key,企微 media_id 失效不影响历史资料下载。
|
||||
11. Secret、Token、EncodingAESKey 和附件内容不出现在 API、日志和审计详情中。
|
||||
12. 已登录平台员工扫码后自动获得企微 userid,不需要人工查询或录入成员 ID。
|
||||
13. 绑定 `state` 过期、重复回调、非企业成员扫码时均不会产生绑定。
|
||||
14. 同一企微 userid 尝试绑定第二个系统账号时被拒绝,原绑定不被覆盖。
|
||||
15. 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止该账号发起新审批。
|
||||
457
docs/7月迭代/独立方案/新增需求/03-站内通知详细方案.md
Normal file
457
docs/7月迭代/独立方案/新增需求/03-站内通知详细方案.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# 新增需求 03:站内通知详细方案
|
||||
|
||||
> 状态:已合并至标准评审稿,本文保留为实施明细。
|
||||
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
|
||||
> 范围:后台账号、代理账号、企业账号和个人客户的站内通知。
|
||||
|
||||
## 一、定位
|
||||
|
||||
站内通知只负责“用户登录本系统后可以看到的消息”,不承担企业微信审批流程,也不直接发送企业微信消息。
|
||||
|
||||
第一版消息来源:
|
||||
|
||||
| 来源 | 接收人 | 示例 |
|
||||
|---|---|---|
|
||||
| 企微审批结果 | 业务申请人、相关平台角色 | 退款审批通过、线下充值审批驳回 |
|
||||
| 套餐临期 | 代理账号、企业业务员、个人客户 | 套餐剩余 15/7/3 天 |
|
||||
| 数据同步异常 | 平台运维角色 | 模板失效、回调找不到资产、连续同步失败 |
|
||||
| 系统配置异常 | 平台超管 | 企微配置不可用、支付配置失效 |
|
||||
|
||||
企微审批中的“待我审批”由企业微信自身提醒,不在本系统重复创建审批待办。
|
||||
|
||||
## 二、设计原则
|
||||
|
||||
1. 业务事务不直接写 `tb_notification`,关键事件通过 Outbox 可靠投递。
|
||||
2. 一条事件向多个接收人发送时,每个接收人保存一条独立通知。
|
||||
3. 使用 `event_id + recipient_kind + recipient_id` 保证重复消费不生成重复消息。
|
||||
4. 通知正文是发送时快照,使用受控纯文本模板,不保存任意 HTML。
|
||||
5. 通知只能跳转到受控业务类型,前端不接受后端返回任意 URL。
|
||||
6. 通知已读状态只属于当前接收人,任何查询和更新都必须带接收人条件。
|
||||
7. 过期通知不进入列表和未读数,但历史数据按保留策略清理,不由用户删除。
|
||||
8. 站内通知是简单写模型,使用 Application + Query + Infrastructure,不为形式强行创建领域聚合。
|
||||
9. 后续新增短信、企微消息等外部渠道时使用独立 Delivery,不在站内通知 Handler 中追加外部调用。
|
||||
|
||||
## 三、流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Biz as 业务 Application
|
||||
participant DB as 业务数据库
|
||||
participant Outbox as Outbox
|
||||
participant Relay as Outbox Relay
|
||||
participant Worker as Notification Worker
|
||||
participant Notice as tb_notification
|
||||
participant Web as 前端
|
||||
|
||||
Biz->>DB: 提交业务状态
|
||||
Biz->>Outbox: 同事务写通知事件
|
||||
Relay->>Worker: 至少一次投递
|
||||
Worker->>Worker: 解析接收人和受控模板
|
||||
Worker->>Notice: 幂等批量插入
|
||||
Web->>Notice: 查询未读数/列表
|
||||
Web->>Notice: 当前接收人标记已读
|
||||
```
|
||||
|
||||
非关键系统告警允许直接入 Asynq,但必须携带稳定 `event_id`;不能在重试时重新生成。
|
||||
|
||||
## 四、数据模型
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_notification (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(64) NOT NULL,
|
||||
recipient_kind VARCHAR(32) NOT NULL,
|
||||
recipient_id BIGINT NOT NULL,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
ref_type VARCHAR(64),
|
||||
ref_id BIGINT,
|
||||
ref_key VARCHAR(128),
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
read_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (event_id, recipient_kind, recipient_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_recipient_unread
|
||||
ON tb_notification (recipient_kind, recipient_id, is_read, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_notification_recipient_category
|
||||
ON tb_notification (recipient_kind, recipient_id, category, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_notification_expired
|
||||
ON tb_notification (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `recipient_kind` | `account` 或 `personal_customer` |
|
||||
| `category` | `approval / expiry / sync / system` |
|
||||
| `type` | 具体通知类型常量 |
|
||||
| `severity` | `info / warning / error / critical` |
|
||||
| `ref_type` | 受控业务引用类型 |
|
||||
| `ref_id` | 业务主键,可为空 |
|
||||
| `ref_key` | 业务编号或资产标识快照,用于列表展示和资源删除后的追溯 |
|
||||
|
||||
不在通知表保存接收人名称、店铺名称等实时关联字段;正文已经是发送时快照,查询时也不需要联表才能展示。
|
||||
|
||||
## 五、通知类型
|
||||
|
||||
```go
|
||||
const (
|
||||
NotifyTypeWeComApprovalApproved = "wecom.approval.approved"
|
||||
NotifyTypeWeComApprovalRejected = "wecom.approval.rejected"
|
||||
NotifyTypeWeComApprovalCancelled = "wecom.approval.cancelled"
|
||||
NotifyTypeWeComApprovalRevoked = "wecom.approval.revoked_after_approved"
|
||||
NotifyTypeWeComTemplateInvalid = "wecom.template.invalid"
|
||||
NotifyTypePackageExpiring = "package.expiring"
|
||||
NotifyTypeCardSyncFailed = "card_sync.failed"
|
||||
NotifyTypeCarrierCallbackCardNotFound = "carrier_callback.card_not_found"
|
||||
NotifyTypeSystemAlert = "system.alert"
|
||||
)
|
||||
```
|
||||
|
||||
类别映射由后端常量维护,前端只按返回值展示,不自行猜测:
|
||||
|
||||
```go
|
||||
func NotificationCategory(notifyType string) string
|
||||
```
|
||||
|
||||
## 六、发布命令
|
||||
|
||||
```go
|
||||
type Recipient struct {
|
||||
Kind string
|
||||
ID uint
|
||||
}
|
||||
|
||||
type PublishNotificationCommand struct {
|
||||
EventID string
|
||||
Recipients []Recipient
|
||||
Type string
|
||||
Severity string
|
||||
TemplateData map[string]any
|
||||
RefType string
|
||||
RefID uint
|
||||
RefKey string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Application 只提交通知类型和受控模板数据,不直接提交最终 HTML。Notification Worker 通过代码内模板注册表渲染:
|
||||
|
||||
```go
|
||||
type TemplateRenderer func(data map[string]any) (title string, body string, err error)
|
||||
|
||||
var notificationTemplates = map[string]TemplateRenderer{
|
||||
constants.NotifyTypeWeComApprovalApproved: renderWeComApprovalApproved,
|
||||
constants.NotifyTypePackageExpiring: renderPackageExpiring,
|
||||
}
|
||||
```
|
||||
|
||||
模板字段不足时任务失败并重试,禁止生成标题为空或只有业务编号的残缺通知。
|
||||
|
||||
## 七、接收人解析
|
||||
|
||||
业务 Application 在发布事件前确定稳定业务接收人:
|
||||
|
||||
| 场景 | 接收人来源 |
|
||||
|---|---|
|
||||
| 退款/充值审批结果 | 申请人账号 ID |
|
||||
| 通过后撤销 | 申请人 + 财务角色当前启用账号 |
|
||||
| 企微模板失效 | 平台超管角色账号 |
|
||||
| 数据同步连续失败 | 平台运维角色账号 |
|
||||
| 代理套餐临期 | 资产所属代理及配置的业务员账号 |
|
||||
| 企业套餐临期 | 企业关联业务员账号 |
|
||||
| 个人客户套餐临期 | 资产当前绑定的个人客户 ID |
|
||||
|
||||
角色接收人应在事件消费时批量解析当前启用账号,具体用户接收人直接使用事件快照中的 ID。
|
||||
|
||||
不存在接收人时记录消费结果 `no_recipient`,不能无限重试。
|
||||
|
||||
## 八、API
|
||||
|
||||
后台账号、代理账号和企业账号统一使用 `/api/admin`,后端从登录上下文取得当前账号 ID。
|
||||
|
||||
### 8.1 未读总数
|
||||
|
||||
```http
|
||||
GET /api/admin/notifications/unread-count
|
||||
GET /api/c/v1/notifications/unread-count
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"count": 12,
|
||||
"display_count": "12"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
超过 99 时 `display_count` 返回 `99+`,前端也必须兼容直接按 count 计算。
|
||||
|
||||
查询条件:
|
||||
|
||||
```sql
|
||||
recipient_kind = ?
|
||||
AND recipient_id = ?
|
||||
AND is_read = FALSE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
```
|
||||
|
||||
第一版直接查询 PostgreSQL,不额外维护 Redis 未读计数,避免数据库与缓存双写不一致。
|
||||
|
||||
### 8.2 分类未读数
|
||||
|
||||
```http
|
||||
GET /api/admin/notifications/unread-summary
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total": 12,
|
||||
"categories": {
|
||||
"approval": 3,
|
||||
"expiry": 5,
|
||||
"sync": 2,
|
||||
"system": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
个人客户第一版只返回总数,不开放运维类分类。
|
||||
|
||||
### 8.3 通知列表
|
||||
|
||||
```http
|
||||
GET /api/admin/notifications
|
||||
?category=approval
|
||||
&type=
|
||||
&severity=
|
||||
&is_read=false
|
||||
&page=1&page_size=20
|
||||
|
||||
GET /api/c/v1/notifications?page=1&page_size=20
|
||||
```
|
||||
|
||||
响应项:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1001,
|
||||
"category": "approval",
|
||||
"type": "wecom.approval.approved",
|
||||
"severity": "info",
|
||||
"title": "退款审批已通过",
|
||||
"body": "退款单 RF202607150001 已通过企业微信审批。",
|
||||
"ref_type": "refund",
|
||||
"ref_id": 42,
|
||||
"ref_key": "RF202607150001",
|
||||
"is_read": false,
|
||||
"read_at": null,
|
||||
"created_at": "2026-07-15T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
列表固定按 `created_at DESC, id DESC` 排序并服务端分页,最大 `page_size=50`。
|
||||
|
||||
### 8.4 单条标记已读
|
||||
|
||||
```http
|
||||
PUT /api/admin/notifications/{id}/read
|
||||
PUT /api/c/v1/notifications/{id}/read
|
||||
```
|
||||
|
||||
条件更新:
|
||||
|
||||
```sql
|
||||
UPDATE tb_notification
|
||||
SET is_read = TRUE, read_at = NOW()
|
||||
WHERE id = ?
|
||||
AND recipient_kind = ?
|
||||
AND recipient_id = ?
|
||||
AND is_read = FALSE;
|
||||
```
|
||||
|
||||
记录不存在、无权访问或已经已读时统一返回成功,保持幂等且不泄露其他用户通知是否存在。
|
||||
|
||||
### 8.5 批量标记已读
|
||||
|
||||
```http
|
||||
PUT /api/admin/notifications/read-all
|
||||
PUT /api/c/v1/notifications/read-all
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "approval"
|
||||
}
|
||||
```
|
||||
|
||||
`category` 为空表示当前接收人的全部未过期通知。接口返回本次实际更新数量。
|
||||
|
||||
### 8.6 点击并读取跳转信息
|
||||
|
||||
前端通常可直接使用列表中的 `ref_type/ref_id`。为了处理业务被删除、权限变化等情况,允许:
|
||||
|
||||
```http
|
||||
GET /api/admin/notifications/{id}/target
|
||||
```
|
||||
|
||||
后端先校验通知属于当前用户,再返回受控目标:
|
||||
|
||||
```json
|
||||
{
|
||||
"target_type": "refund_detail",
|
||||
"target_id": 42,
|
||||
"available": true
|
||||
}
|
||||
```
|
||||
|
||||
该接口不返回任意 URL。
|
||||
|
||||
## 九、受控跳转
|
||||
|
||||
| ref_type | target_type | 后台页面 |
|
||||
|---|---|---|
|
||||
| `refund` | `refund_detail` | `/refunds/{id}` |
|
||||
| `agent_recharge` | `agent_recharge_detail` | `/agent-recharges/{id}` |
|
||||
| `wecom_approval` | `wecom_approval_detail` | `/operations/wecom-approvals/{id}` |
|
||||
| `iot_card` | `iot_card_detail` | `/iot-cards/{id}` |
|
||||
| `device` | `device_detail` | `/devices/{id}` |
|
||||
| `card_sync` | `card_sync_execution` | `/operations/card-sync?execution_id={id}` |
|
||||
| `system_config` | `system_config` | `/system/config` |
|
||||
|
||||
前端维护 `target_type -> route` 映射。未知类型只展示通知,不执行跳转。
|
||||
|
||||
跳转目标页面仍按原业务权限重新校验,拥有通知不等于永久拥有业务数据访问权。
|
||||
|
||||
## 十、前端方案
|
||||
|
||||
### 10.1 顶部铃铛
|
||||
|
||||
```text
|
||||
登录成功/布局挂载
|
||||
→ 立即请求 unread-count
|
||||
→ 每 30 秒轮询
|
||||
→ 页面不可见时暂停
|
||||
→ 恢复可见时立即刷新
|
||||
```
|
||||
|
||||
- 未读为 0 时不显示数字。
|
||||
- 1~99 显示实际数字,超过 99 显示 `99+`。
|
||||
- 请求失败保留上一次数字,不闪回 0。
|
||||
- 铃铛区域固定宽度,数字变化不得造成导航布局位移。
|
||||
|
||||
### 10.2 通知抽屉
|
||||
|
||||
点击铃铛打开右侧抽屉:
|
||||
|
||||
- 顶部显示“全部、审批、临期、系统”分段控件。
|
||||
- 默认加载最近 10 条,提供“查看全部消息”。
|
||||
- 未读消息使用左侧状态点,不用整行高饱和背景。
|
||||
- 点击消息先本地进入已读视觉状态,再并行调用标记已读和目标解析。
|
||||
- 标记已读失败时,下次未读轮询恢复服务端真实状态。
|
||||
|
||||
### 10.3 通知中心
|
||||
|
||||
后台路由:`/notifications`
|
||||
|
||||
页面结构:
|
||||
|
||||
```text
|
||||
分类 Tab + 已读状态筛选 + 严重级别筛选
|
||||
通知时间线列表
|
||||
全部已读按钮
|
||||
服务端分页
|
||||
```
|
||||
|
||||
错误和严重通知显示级别图标;普通通知不使用警告色。
|
||||
|
||||
个人客户使用简化通知列表,只显示套餐临期、订单和资产相关消息,不显示平台运维消息。
|
||||
|
||||
## 十一、权限与数据安全
|
||||
|
||||
- Notification Query 从登录 Context 固定注入接收人,不接收前端传入 `recipient_id`。
|
||||
- 后台账号只能查看自己的消息,平台超管也不能通过通知接口查看其他人的消息。
|
||||
- 运维需要排查通知投递时,使用全局审计/运维查询接口,不复用用户通知列表接口。
|
||||
- 正文禁止包含操作密码、支付密钥、身份证完整号码、长期对象存储 URL 和完整企微回调正文。
|
||||
- 附件只提示“包含附件”,用户进入有权限的业务详情后获取短期下载 URL。
|
||||
- 所有已读接口不区分“通知不存在”和“通知不属于当前用户”。
|
||||
|
||||
## 十二、保留与清理
|
||||
|
||||
| 通知类型 | 展示期限 | 数据保留期限 |
|
||||
|---|---:|---:|
|
||||
| 套餐临期 | 到期后自动不展示 | 180 天 |
|
||||
| 审批结果 | 不自动过期 | 365 天 |
|
||||
| 同步异常 | 30 天 | 180 天 |
|
||||
| 系统告警 | 由事件指定 | 365 天 |
|
||||
|
||||
每日低峰期使用现有数据清理任务按批次删除超过保留期限的数据。清理不修改业务审计和领域流水。
|
||||
|
||||
## 十三、外部渠道扩展
|
||||
|
||||
未来如果需要企微消息或短信,新增独立投递表:
|
||||
|
||||
```text
|
||||
tb_notification_delivery
|
||||
notification_event_id
|
||||
recipient
|
||||
channel
|
||||
status
|
||||
attempts
|
||||
provider_message_id
|
||||
last_error
|
||||
```
|
||||
|
||||
站内通知写入和外部渠道发送分别消费同一业务事件,互不影响。不能在 `NotificationHandler` 写入站内消息后直接循环调用企微或短信接口,否则某个外部渠道故障会拖慢所有站内消息。
|
||||
|
||||
## 十四、代码结构
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── application/notification/
|
||||
│ ├── publish_notification.go
|
||||
│ ├── mark_read.go
|
||||
│ └── mark_all_read.go
|
||||
├── query/notification/
|
||||
│ ├── list_notifications.go
|
||||
│ ├── unread_count.go
|
||||
│ └── resolve_target.go
|
||||
├── infrastructure/messaging/
|
||||
│ └── notification_event_handler.go
|
||||
├── infrastructure/persistence/
|
||||
│ └── notification_repository.go
|
||||
├── handler/admin/notification.go
|
||||
└── handler/app/client_notification.go
|
||||
```
|
||||
|
||||
通知没有复杂状态机,不创建空洞的 `domain/notification` 聚合。Application 负责写操作和幂等,Query 直接使用 GORM 查询读取模型。
|
||||
|
||||
## 十五、人工验证
|
||||
|
||||
1. 同一事件重复消费不会为同一接收人生成重复通知。
|
||||
2. 同一事件的两个接收人各自生成一条通知,已读状态互不影响。
|
||||
3. 用户无法读取或标记其他用户通知。
|
||||
4. 过期通知不进入未读数和列表。
|
||||
5. 全部已读只影响当前用户和指定分类。
|
||||
6. 业务权限变化后,通知仍可展示,但目标接口重新校验权限。
|
||||
7. 前端隐藏页签时暂停轮询,恢复后立即刷新。
|
||||
8. 企微审批待办不重复生成站内待办,审批结果可以正常通知申请人。
|
||||
9. 外部消息渠道失败不影响站内通知生成。
|
||||
10. 通知正文、日志和 API 不包含密钥、操作密码或长期附件 URL。
|
||||
809
docs/7月迭代/独立方案/新增需求/04-全局多视角审计方案.md
Normal file
809
docs/7月迭代/独立方案/新增需求/04-全局多视角审计方案.md
Normal file
@@ -0,0 +1,809 @@
|
||||
# 新增需求 04:全局多视角审计方案
|
||||
|
||||
> 状态:已合并至标准评审稿,本文保留为实施明细。
|
||||
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
|
||||
> 范围:全系统业务写操作、安全操作、外部集成和审计查询前端。
|
||||
|
||||
## 一、目标
|
||||
|
||||
审计系统必须能够从不同视角回答:
|
||||
|
||||
```text
|
||||
谁做了什么?
|
||||
某个资源经历了什么?
|
||||
一次请求引发了哪些跨模块变化?
|
||||
哪些操作失败、被拒绝或存在风险?
|
||||
外部 Gateway、运营商和企业微信交互发生了什么?
|
||||
资金变化对应哪个业务动作和审批结果?
|
||||
```
|
||||
|
||||
不是把所有日志塞进一张表,而是建立统一审计事件,并通过 Query 组合访问日志、领域流水和外部集成记录。
|
||||
|
||||
## 二、当前代码问题
|
||||
|
||||
现有实现已经有账号审计和资产审计,但没有形成全系统能力:
|
||||
|
||||
- `tb_account_operation_log` 和 `tb_asset_operation_log` 字段、操作类型和查询入口不一致。
|
||||
- 账号、资产审计通过 goroutine Best Effort 写入,进程退出或数据库短暂失败时会丢失。
|
||||
- 部分调用方又在审计服务外增加一层 goroutine,形成双重异步。
|
||||
- 退款审批等资金操作没有统一业务审计。
|
||||
- 线下充值借用账号操作日志,资源类型和关联关系不准确。
|
||||
- 访问日志只脱敏请求体,响应体仍按原文记录,可能泄露 Token、个人信息和敏感配置。
|
||||
- 现有审计表只能从单个账号或单个资产查看,无法按请求、关联业务、异常等级和外部交互串联。
|
||||
|
||||
## 三、四类记录边界
|
||||
|
||||
| 类型 | 权威性 | 存储 | 用途 |
|
||||
|---|---|---|---|
|
||||
| Access Log | 非业务权威 | 文件/日志平台 | HTTP 调试、性能和请求追踪 |
|
||||
| Audit Event | 业务审计权威 | PostgreSQL | 谁对什么做了什么、结果如何 |
|
||||
| Domain Ledger | 领域事实权威 | 现有业务表 | 钱包流水、退款单、审批实例、订单状态 |
|
||||
| Integration Log | 外部交互权威 | PostgreSQL | Gateway、运营商回调、企微 API、回调和同步尝试 |
|
||||
|
||||
规则:
|
||||
|
||||
- Audit Event 不替代钱包流水、退款记录和企微审批详情。
|
||||
- Access Log 不作为业务审计依据。
|
||||
- 数据同步的轮询、事件阶梯尝试、手动刷新和运营商回调统一进入 `tb_integration_log`,不再新建 `tb_card_sync_execution`。
|
||||
- 高频轮询只有在状态变化、人工强制触发、连续失败或高风险异常时生成 Audit Event。
|
||||
- Audit Query 可以把四类记录组合成时间线,但必须标明数据来源。
|
||||
|
||||
## 四、审计事件模型
|
||||
|
||||
### 4.1 主表
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_audit_event (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(128) NOT NULL,
|
||||
action_name VARCHAR(255) NOT NULL,
|
||||
|
||||
actor_kind VARCHAR(32) NOT NULL,
|
||||
actor_id BIGINT,
|
||||
actor_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
actor_user_type INT,
|
||||
|
||||
source VARCHAR(64) NOT NULL,
|
||||
tenant_type VARCHAR(32),
|
||||
tenant_id BIGINT,
|
||||
shop_id BIGINT,
|
||||
enterprise_id BIGINT,
|
||||
|
||||
result VARCHAR(16) NOT NULL,
|
||||
risk_level VARCHAR(16) NOT NULL DEFAULT 'normal',
|
||||
summary TEXT NOT NULL,
|
||||
error_code VARCHAR(64),
|
||||
error_message TEXT,
|
||||
|
||||
before_data JSONB,
|
||||
after_data JSONB,
|
||||
metadata JSONB,
|
||||
|
||||
request_id VARCHAR(64),
|
||||
correlation_id VARCHAR(64),
|
||||
parent_event_id VARCHAR(64),
|
||||
|
||||
ip_address VARCHAR(64),
|
||||
user_agent TEXT,
|
||||
request_path VARCHAR(255),
|
||||
request_method VARCHAR(16),
|
||||
|
||||
content_hash VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_event_time
|
||||
ON tb_audit_event (occurred_at DESC, id DESC);
|
||||
CREATE INDEX idx_audit_event_actor
|
||||
ON tb_audit_event (actor_kind, actor_id, occurred_at DESC);
|
||||
CREATE INDEX idx_audit_event_action
|
||||
ON tb_audit_event (category, action, occurred_at DESC);
|
||||
CREATE INDEX idx_audit_event_result
|
||||
ON tb_audit_event (risk_level, result, occurred_at DESC);
|
||||
CREATE INDEX idx_audit_event_request
|
||||
ON tb_audit_event (request_id, occurred_at ASC);
|
||||
CREATE INDEX idx_audit_event_correlation
|
||||
ON tb_audit_event (correlation_id, occurred_at ASC);
|
||||
```
|
||||
|
||||
### 4.2 资源关系表
|
||||
|
||||
一次退款审批通过会同时影响退款单、订单、钱包、钱包流水、套餐和资产,只在主表保存一个 `resource_id` 无法完整查询。
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_audit_event_resource (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
audit_event_id BIGINT NOT NULL,
|
||||
resource_type VARCHAR(64) NOT NULL,
|
||||
resource_id BIGINT,
|
||||
resource_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
relation VARCHAR(20) NOT NULL DEFAULT 'affected',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_audit_event_resource
|
||||
ON tb_audit_event_resource (
|
||||
audit_event_id,
|
||||
resource_type,
|
||||
COALESCE(resource_id, 0),
|
||||
resource_key,
|
||||
relation
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_resource_timeline
|
||||
ON tb_audit_event_resource (resource_type, resource_id, audit_event_id DESC);
|
||||
CREATE INDEX idx_audit_resource_key
|
||||
ON tb_audit_event_resource (resource_type, resource_key, audit_event_id DESC);
|
||||
```
|
||||
|
||||
不建立数据库外键。`relation`:
|
||||
|
||||
| relation | 含义 |
|
||||
|---|---|
|
||||
| `primary` | 本次操作的主要对象 |
|
||||
| `affected` | 被本次操作修改的对象 |
|
||||
| `reference` | 只用于解释上下文的关联对象 |
|
||||
|
||||
### 4.3 操作者
|
||||
|
||||
`actor_kind`:
|
||||
|
||||
```text
|
||||
admin_user
|
||||
agent_user
|
||||
enterprise_user
|
||||
personal_customer
|
||||
open_api_account
|
||||
system_task
|
||||
carrier_callback
|
||||
wecom_callback
|
||||
```
|
||||
|
||||
系统和外部回调允许 `actor_id=NULL`,但必须保存清晰的 `actor_name`,例如“移动实名回调”“企微审批状态同步”“套餐到期任务”。
|
||||
|
||||
### 4.4 来源
|
||||
|
||||
`source` 表示入口,不表示操作者:
|
||||
|
||||
```text
|
||||
admin_api
|
||||
personal_api
|
||||
open_api
|
||||
asynq
|
||||
scheduled_job
|
||||
gateway
|
||||
carrier_callback.cmcc
|
||||
carrier_callback.cucc
|
||||
carrier_callback.ctcc
|
||||
wecom_callback
|
||||
wecom_polling
|
||||
data_migration
|
||||
```
|
||||
|
||||
### 4.5 结果与风险
|
||||
|
||||
`result`:`success / failed / denied / partial`。
|
||||
|
||||
`risk_level`:`normal / warning / high / critical`。
|
||||
|
||||
默认风险示例:
|
||||
|
||||
| 操作 | 风险等级 |
|
||||
|---|---|
|
||||
| 普通字段修改成功 | normal |
|
||||
| 批量部分失败 | warning |
|
||||
| 权限拒绝、重复回调冲突 | warning |
|
||||
| 钱包余额、退款、角色权限、支付配置修改 | high |
|
||||
| 企微通过后撤销但资金已执行、审计写入失败 | critical |
|
||||
|
||||
## 五、领域模型和代码结构
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── domain/audit/
|
||||
│ ├── event.go 不可变 AuditEvent 实体及不变量
|
||||
│ ├── actor.go 操作者值对象
|
||||
│ ├── resource.go 多资源关联
|
||||
│ ├── sanitizer.go 审计数据脱敏规则
|
||||
│ ├── action_registry.go 操作编码、名称、分类和默认风险
|
||||
│ └── repository.go
|
||||
├── application/audit/
|
||||
│ ├── append_event.go 成功/失败审计写入
|
||||
│ ├── append_with_tx.go 关键业务事务内写入
|
||||
│ └── append_system_event.go
|
||||
├── query/audit/
|
||||
│ ├── event_list.go
|
||||
│ ├── actor_view.go
|
||||
│ ├── resource_view.go
|
||||
│ ├── request_view.go
|
||||
│ ├── correlation_view.go
|
||||
│ ├── risk_view.go
|
||||
│ ├── integration_view.go
|
||||
│ └── finance_view.go
|
||||
└── infrastructure/persistence/
|
||||
└── audit_repository.go
|
||||
```
|
||||
|
||||
Audit Event 具有独立 `event_id`,因此是不可变领域实体,不是值对象;Actor、Resource 和字段差异是值对象。事件创建后不提供 Update/Delete Repository 方法。
|
||||
|
||||
## 六、操作注册表
|
||||
|
||||
操作编码禁止在各 Service 中自由拼字符串:
|
||||
|
||||
```go
|
||||
type ActionDefinition struct {
|
||||
Code string
|
||||
Name string
|
||||
Category string
|
||||
DefaultRiskLevel string
|
||||
SensitiveFields []string
|
||||
}
|
||||
|
||||
var ActionRegistry = map[string]ActionDefinition{
|
||||
"account.role.assign": {
|
||||
Name: "分配账号角色", Category: "security", DefaultRiskLevel: "high",
|
||||
},
|
||||
"refund.wecom.approved": {
|
||||
Name: "企微审批通过并确认人工退款", Category: "finance", DefaultRiskLevel: "high",
|
||||
},
|
||||
"recharge.wecom.approved": {
|
||||
Name: "企微审批通过并完成线下充值", Category: "finance", DefaultRiskLevel: "high",
|
||||
},
|
||||
"iot_card.realname.callback": {
|
||||
Name: "运营商回调更新实名状态", Category: "asset", DefaultRiskLevel: "normal",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
DTO `description`、前端中文名称和筛选项都从同一注册表生成,避免操作编码与展示名称不一致。
|
||||
|
||||
## 七、写入可靠性
|
||||
|
||||
### 7.1 关键成功事件
|
||||
|
||||
以下事件必须与业务变更同事务写入:
|
||||
|
||||
- 钱包余额变化。
|
||||
- 人工退款结果确认和代理钱包回溯。
|
||||
- 线下充值入账。
|
||||
- 账号角色和权限变化。
|
||||
- 支付、企微和系统关键配置变化。
|
||||
- 卡实名、网络状态被人工修改。
|
||||
- 手工绑定企微 `sp_no`。
|
||||
|
||||
Application 在事务内调用:
|
||||
|
||||
```go
|
||||
auditWriter.AppendWithTx(ctx, tx, event)
|
||||
```
|
||||
|
||||
审计写入失败时事务回滚,禁止业务成功但关键审计缺失。
|
||||
|
||||
### 7.2 异步系统事件
|
||||
|
||||
轮询、事件阶梯同步、运营商回调和企微轮询等外部交互统一进入 Integration Log。只有以下情况同时写 Audit Event:
|
||||
|
||||
- 状态发生业务变化。
|
||||
- 连续失败达到阈值。
|
||||
- 人工强制触发。
|
||||
- 出现高风险异常。
|
||||
|
||||
系统事件通过同一业务事务或 Outbox 可靠写入,不启动裸 goroutine。
|
||||
|
||||
### 7.3 失败和拒绝事件
|
||||
|
||||
业务事务已经回滚时,使用独立短事务写 `failed/denied` 审计。审计写入失败不能把原业务错误改成成功,但必须记录 `critical` 应用日志和监控指标。
|
||||
|
||||
## 八、关联链路
|
||||
|
||||
### 8.1 request_id
|
||||
|
||||
表示一次 HTTP 请求,由现有 Request ID 中间件产生。一次请求触发的所有审计事件使用同一个 `request_id`。
|
||||
|
||||
### 8.2 correlation_id
|
||||
|
||||
表示跨请求、跨任务的完整业务链路:
|
||||
|
||||
```text
|
||||
退款申请创建
|
||||
→ 企微审批提交
|
||||
→ 企微回调
|
||||
→ 人工退款结果入账
|
||||
→ 代理钱包退款流水(仅代理钱包支付订单)
|
||||
→ 佣金回扣
|
||||
→ 套餐失效和停机
|
||||
```
|
||||
|
||||
以上事件共享退款申请创建时生成的 `correlation_id`。Asynq、Outbox、企微实例和同步任务载荷都必须传递该值。
|
||||
|
||||
### 8.3 parent_event_id
|
||||
|
||||
表示直接因果关系。例如“套餐失效”事件的父事件是“退款终态处理成功”,用于前端画出树状链路。
|
||||
|
||||
## 九、数据脱敏
|
||||
|
||||
### 9.1 永不进入审计的数据
|
||||
|
||||
```text
|
||||
password
|
||||
operation_password
|
||||
access_token / refresh_token
|
||||
agent_secret / app_secret
|
||||
callback_token / encoding_aes_key
|
||||
支付私钥、公钥原文
|
||||
短信验证码
|
||||
完整身份证号
|
||||
对象存储签名 URL
|
||||
企微 media_id
|
||||
```
|
||||
|
||||
这些字段直接删除,不保存为 `******`,避免字段存在本身造成误用。
|
||||
|
||||
### 9.2 按权限展示的数据
|
||||
|
||||
手机、IP、ICCID、钱包余额和第三方交易号可以存储受控快照,但 Query 根据权限返回:
|
||||
|
||||
- 普通审计权限:脱敏展示。
|
||||
- 敏感审计权限:展示完整值。
|
||||
- 导出权限单独控制,不能因为可查看页面就默认可导出完整值。
|
||||
|
||||
### 9.3 大字段
|
||||
|
||||
`before_data`、`after_data` 和 `metadata` 分别限制 16KB。超过后保存:
|
||||
|
||||
```json
|
||||
{
|
||||
"_truncated": true,
|
||||
"_original_bytes": 38210,
|
||||
"_summary": "批量修改500条资产",
|
||||
"_artifact_ref": "batch_task:123"
|
||||
}
|
||||
```
|
||||
|
||||
批量明细保存在对应任务结果表或对象存储,不塞入审计 JSON。
|
||||
|
||||
## 十、外部集成日志
|
||||
|
||||
企业微信、运营商回调、Gateway 请求和事件同步尝试使用通用 Integration Log。同步尝试即使在真正发送 HTTP 前因合并、限流或预期状态已满足而结束,也写一条结果记录,保证能够解释“为什么没有请求上游”。
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_integration_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
integration_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
provider VARCHAR(32) NOT NULL,
|
||||
direction VARCHAR(16) NOT NULL,
|
||||
operation VARCHAR(64) NOT NULL,
|
||||
external_id VARCHAR(128),
|
||||
|
||||
resource_type VARCHAR(64),
|
||||
resource_id BIGINT,
|
||||
resource_key VARCHAR(128),
|
||||
|
||||
trigger_source VARCHAR(32),
|
||||
trigger_scene VARCHAR(128),
|
||||
trigger_series VARCHAR(64),
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ,
|
||||
attempt INT NOT NULL DEFAULT 1,
|
||||
|
||||
result VARCHAR(20) NOT NULL,
|
||||
http_status INT,
|
||||
provider_code VARCHAR(64),
|
||||
provider_message TEXT,
|
||||
request_summary JSONB,
|
||||
response_summary JSONB,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
state_changed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
metadata JSONB,
|
||||
|
||||
request_id VARCHAR(64),
|
||||
correlation_id VARCHAR(64),
|
||||
audit_event_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integration_log_time
|
||||
ON tb_integration_log (created_at DESC, id DESC);
|
||||
CREATE INDEX idx_integration_log_provider
|
||||
ON tb_integration_log (provider, operation, result, created_at DESC);
|
||||
CREATE INDEX idx_integration_log_resource
|
||||
ON tb_integration_log (resource_type, resource_id, created_at DESC);
|
||||
CREATE INDEX idx_integration_log_resource_key
|
||||
ON tb_integration_log (resource_type, resource_key, created_at DESC);
|
||||
CREATE INDEX idx_integration_log_trigger
|
||||
ON tb_integration_log (trigger_series, attempt);
|
||||
CREATE INDEX idx_integration_log_correlation
|
||||
ON tb_integration_log (correlation_id, created_at ASC);
|
||||
```
|
||||
|
||||
`direction`:`inbound / outbound`。
|
||||
|
||||
`result` 至少支持:
|
||||
|
||||
```text
|
||||
success
|
||||
failed
|
||||
not_found
|
||||
invalid_payload
|
||||
ignored
|
||||
merged
|
||||
rate_limited
|
||||
completed
|
||||
cancelled
|
||||
```
|
||||
|
||||
数据同步字段约定:
|
||||
|
||||
- `provider=gateway`:实名、流量、卡状态和设备信息查询。
|
||||
- `provider=cmcc/cucc/ctcc` 且 `direction=inbound`:运营商实名或解除实名回调。
|
||||
- `trigger_source`:`polling/business_event/open_api/manual_refresh/carrier_callback`。
|
||||
- `trigger_scene`:触发埋点场景,例如 `client_asset_info`、`card_resume`、`device_switch_card`。
|
||||
- `trigger_series + attempt`:关联一次 `0/3/5` 阶梯同步。
|
||||
- `result=merged/rate_limited/completed` 时允许没有 HTTP 状态和响应摘要,因为没有真正请求上游。
|
||||
- `state_changed=true` 时通过 `audit_event_id` 关联对应业务状态变化事件。
|
||||
|
||||
普通高频轮询成功也写 Integration Log,但不生成 Audit Event。查询和清理必须使用时间索引及分批删除,不能在业务请求中同步聚合全量历史。
|
||||
|
||||
外部交互正文只保存脱敏摘要。企微加密报文、运营商原始回调和 Gateway 加密请求不进入普通审计详情。
|
||||
|
||||
## 十一、审计范围
|
||||
|
||||
### 11.1 必须审计的写操作
|
||||
|
||||
| 模块 | 操作 |
|
||||
|---|---|
|
||||
| 账号权限 | 创建、禁用、删除、角色分配、密码重置 |
|
||||
| 资产 | 分配、回收、绑定、解绑、停复机、实名策略、手工实名、限速 |
|
||||
| 套餐 | 创建、修改、上下架、分配、价格、已用量和到期时间调整 |
|
||||
| 钱包资金 | 充值、扣款、退款、信用变化、人工调整 |
|
||||
| 订单退款 | 创建退款、企微终态、人工退款确认、代理钱包回溯、资产后处理 |
|
||||
| 线下充值 | 创建、企微终态、入账、通过后撤销异常 |
|
||||
| 系统配置 | 支付配置、企微配置状态、模板版本发布、导出字段权限 |
|
||||
| 数据同步 | 人工强制同步、回调更新业务状态、连续失败告警 |
|
||||
| 导入导出 | 创建任务、下载敏感导出、批量任务结果 |
|
||||
| 登录安全 | 登录成功/失败、Token 撤销、开放接口签名失败和重放拒绝 |
|
||||
|
||||
### 11.2 不进入业务审计的普通读操作
|
||||
|
||||
- 普通列表、详情和未读数查询只进入 Access Log。
|
||||
- 下载敏感附件、导出、查看完整密钥状态、查看完整个人信息属于敏感读取,需要 Audit Event。
|
||||
|
||||
## 十二、多视角 API
|
||||
|
||||
### 12.1 全局事件视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/events
|
||||
?category=finance
|
||||
&action=
|
||||
&result=success
|
||||
&risk_level=high
|
||||
&source=wecom_callback
|
||||
&start_at=
|
||||
&end_at=
|
||||
&keyword=
|
||||
&page=1&page_size=50
|
||||
```
|
||||
|
||||
`keyword` 只匹配事件摘要、资源编号、操作者名称和 request_id,不对 JSONB 做无索引模糊扫描。
|
||||
|
||||
### 12.2 事件详情
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/events/{event_id}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
- 操作者快照。
|
||||
- 入口、租户和请求信息。
|
||||
- 主要资源、影响资源和引用资源。
|
||||
- 前后数据差异。
|
||||
- 错误信息和风险等级。
|
||||
- request/correlation/parent 关联。
|
||||
- 可访问的领域流水和 Integration Log 链接。
|
||||
|
||||
### 12.3 操作者视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/actors
|
||||
?actor_kind=admin_user
|
||||
&keyword=
|
||||
&range=30d
|
||||
&page=1&page_size=20
|
||||
|
||||
GET /api/admin/audit/actors/{actor_kind}/{actor_id}/summary?range=30d
|
||||
GET /api/admin/audit/actors/{actor_kind}/{actor_id}/events?page=1&page_size=50
|
||||
```
|
||||
|
||||
人员列表返回操作者 ID、名称、类型、最近操作时间、操作量、失败量和最高风险等级;`keyword` 只做 ID、账号和名称的前缀匹配,不扫描审计 JSON。Summary 返回操作量、失败量、拒绝量、高风险量、常用操作、常操作资源和最近登录 IP。
|
||||
|
||||
### 12.4 资源视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/resources/search
|
||||
?resource_type=
|
||||
&keyword=RF202607150001
|
||||
&page=1&page_size=20
|
||||
|
||||
GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline
|
||||
?include_related=true
|
||||
&page=1&page_size=50
|
||||
```
|
||||
|
||||
示例资源:`iot_card / device / refund / order / agent_wallet / wecom_approval / account / system_config`。
|
||||
|
||||
资源搜索属于 Query 模块,允许直接查询退款、订单、充值、账号和资产等读模型,不要求加载领域聚合。返回候选项的 `resource_type/resource_id/resource_key/display_name`,解决同一关键词命中多个资源的问题。静态 `resources/search` 路由必须先于动态资源路由注册。
|
||||
|
||||
`include_related=true` 时通过资源关系表返回影响该资源的跨模块事件,但不无限递归加载关联资源。
|
||||
|
||||
### 12.5 请求视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/requests/{request_id}/timeline
|
||||
```
|
||||
|
||||
按时间展示一次 HTTP 请求产生的 Access Log 摘要、Audit Event、Outbox 和 Integration Log。
|
||||
|
||||
### 12.6 业务链路视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/correlations/{correlation_id}/timeline
|
||||
```
|
||||
|
||||
用于查看退款、企微审批、钱包和资产处理等跨请求链路。返回节点包含 `parent_event_id`,前端可展示因果树。
|
||||
|
||||
### 12.7 风险视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/risks/overview?range=24h
|
||||
GET /api/admin/audit/risks/events?risk_level=critical&page=1&page_size=50
|
||||
```
|
||||
|
||||
Overview:
|
||||
|
||||
- 高风险和严重事件数量。
|
||||
- 权限拒绝、开放接口重放、资金冲突和外部回调异常趋势。
|
||||
- 按操作人、来源和资源类型聚合。
|
||||
|
||||
### 12.8 外部集成视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/integrations
|
||||
?provider=wecom
|
||||
&direction=inbound
|
||||
&operation=getapprovaldetail
|
||||
&result=failed
|
||||
&external_id=
|
||||
&resource_type=
|
||||
&resource_key=
|
||||
&trigger_source=
|
||||
&trigger_scene=
|
||||
&trigger_series=
|
||||
&page=1&page_size=50
|
||||
|
||||
GET /api/admin/audit/integrations/{integration_log_id}
|
||||
```
|
||||
|
||||
列表查询 `tb_integration_log`,并返回关联审计事件和业务链路 ID;详情返回脱敏后的请求摘要、响应摘要、耗时、错误、尝试次数、计划时间、触发场景和关联资源,不返回密钥、完整回调正文或附件内容。
|
||||
|
||||
当 `trigger_series` 有值时,详情同时返回该序列的全部尝试,前端按“立即、3 分钟、5 分钟”展示,不要求用户逐条搜索。同步记录不再通过独立 `/card-sync/executions` 接口查询。
|
||||
|
||||
### 12.9 资金视角
|
||||
|
||||
```http
|
||||
GET /api/admin/audit/finance/timeline
|
||||
?business_no=
|
||||
&wallet_id=
|
||||
&transaction_no=
|
||||
&page=1&page_size=50
|
||||
```
|
||||
|
||||
Finance Query 组合:
|
||||
|
||||
- Audit Event。
|
||||
- 代理钱包和资产钱包流水。
|
||||
- 退款单、充值单和订单。
|
||||
- 企微审批实例。
|
||||
|
||||
每条记录明确 `record_source`,钱包流水仍是金额变化的权威数据。
|
||||
|
||||
### 12.10 导出
|
||||
|
||||
```http
|
||||
POST /api/admin/audit/exports
|
||||
```
|
||||
|
||||
复用现有导出任务系统。导出字段按角色配置,默认不允许导出完整 IP、手机号、ICCID、前后 JSON 和外部响应摘要。
|
||||
|
||||
## 十三、权限
|
||||
|
||||
建议权限码:
|
||||
|
||||
```text
|
||||
audit:global:view
|
||||
audit:actor:view
|
||||
audit:resource:view
|
||||
audit:request:view
|
||||
audit:risk:view
|
||||
audit:integration:view
|
||||
audit:finance:view
|
||||
audit:sensitive:view
|
||||
audit:export
|
||||
```
|
||||
|
||||
第一版审计中心只开放给超级管理员和具有对应权限的平台角色。
|
||||
|
||||
代理、企业账号不进入全局审计中心;它们在资产、订单、充值等业务详情页只能查看自己有权限资源的资源时间线,且返回字段经过脱敏。
|
||||
|
||||
Service/Query 必须重新应用资源权限,前端隐藏 Tab 不能替代后端授权。
|
||||
|
||||
## 十四、前端多视角界面
|
||||
|
||||
### 14.1 审计中心
|
||||
|
||||
路由:`/operations/audit`
|
||||
|
||||
使用工作台式紧凑布局,不做营销式卡片页面。顶部为时间范围、关键词和常用过滤器,下方使用 Tab:
|
||||
|
||||
1. **全局事件**:按时间倒序的审计事件表。
|
||||
2. **人员行为**:操作者列表、风险统计和行为时间线。
|
||||
3. **资源轨迹**:输入资源类型和标识,展示资源生命周期。
|
||||
4. **请求链路**:按 request_id/correlation_id 展示跨模块时间线。
|
||||
5. **资金审计**:订单、审批、钱包流水和退款/充值组合视图。
|
||||
6. **风险事件**:失败、拒绝、高风险和严重事件。
|
||||
7. **外部集成**:Gateway、企微、运营商回调和事件同步阶梯尝试。
|
||||
|
||||
Tab 根据权限返回,前端不展示无权限入口。
|
||||
|
||||
外部集成 Tab 增加紧凑的来源切换:`全部 / Gateway 同步 / 运营商回调 / 企业微信`。选择 Gateway 同步后展示触发来源、触发场景、资源、序列、尝试、结果、耗时和状态是否变化;点击序列打开三次尝试时间线。
|
||||
|
||||
### 14.2 全局事件表
|
||||
|
||||
字段:
|
||||
|
||||
```text
|
||||
时间 | 风险 | 操作者 | 操作 | 主要资源 | 来源 | 结果 | request_id
|
||||
```
|
||||
|
||||
- 支持服务端分页和列筛选。
|
||||
- 点击行打开右侧详情抽屉。
|
||||
- 风险仅用图标和有限颜色表达,普通成功事件保持中性色。
|
||||
- `request_id`、资源编号可点击进入对应视角。
|
||||
|
||||
### 14.3 事件详情抽屉
|
||||
|
||||
分区:
|
||||
|
||||
```text
|
||||
事件摘要
|
||||
操作者与入口
|
||||
关联资源
|
||||
字段变更对比
|
||||
请求与业务链路
|
||||
错误和外部交互
|
||||
```
|
||||
|
||||
字段变更使用结构化键值对比,不直接把整段 JSON 原样堆在页面。未知字段可以折叠显示原始 JSON。
|
||||
|
||||
敏感字段默认脱敏;有 `audit:sensitive:view` 权限时提供“显示敏感数据”按钮,并对该次查看再写一条敏感读取审计。
|
||||
|
||||
### 14.4 人员行为视角
|
||||
|
||||
左侧为操作者搜索和筛选,右侧显示:
|
||||
|
||||
- 30 天操作量、失败率、高风险操作数。
|
||||
- 常用来源 IP。
|
||||
- 操作类型分布。
|
||||
- 最近行为时间线。
|
||||
- 涉及资源列表。
|
||||
|
||||
不做基于行为的自动封禁,只提供审计判断依据。
|
||||
左侧人员列表调用 `/api/admin/audit/actors`,选择人员后再并行加载 Summary 和事件列表,避免前端从全局事件自行聚合。
|
||||
|
||||
### 14.5 资源轨迹视角
|
||||
|
||||
支持输入 ICCID、设备虚拟号、退款单号、订单号、充值单号、账号名称等,先调用 `/api/admin/audit/resources/search` 返回候选资源;只有一个候选时直接打开时间线,多个候选时由用户选择,前端不自行猜测资源类型。
|
||||
|
||||
时间线同时展示:
|
||||
|
||||
- 业务状态变化。
|
||||
- 人工操作。
|
||||
- 企微审批结果。
|
||||
- 资金流水链接。
|
||||
- 重要 Gateway/回调事件。
|
||||
|
||||
普通高频轮询成功不进入资源审计时间线,避免有效信息被淹没。
|
||||
|
||||
### 14.6 请求和业务链路视角
|
||||
|
||||
使用纵向时间线展示节点,节点固定包含时间、来源、动作、结果和耗时。父子事件使用缩进和连接线表达,不使用复杂自由拖拽图编辑器。
|
||||
|
||||
### 14.7 风险视角
|
||||
|
||||
顶部显示 24 小时趋势和风险分类,下方是风险事件表。支持一键跳转操作者、资源和 correlation 链路,但第一版不建设风险处置工单。
|
||||
|
||||
## 十五、Access Log 修正
|
||||
|
||||
当前访问日志的响应体需要使用与请求体相同的递归脱敏逻辑:
|
||||
|
||||
```go
|
||||
responseBody := sanitizeBody(c.Response().Body())
|
||||
```
|
||||
|
||||
并增加路由级策略:
|
||||
|
||||
| 路由 | Body 记录策略 |
|
||||
|---|---|
|
||||
| 登录、Token、支付配置 | 只记录字段名和长度,不记录值 |
|
||||
| 企微/支付/运营商回调 | 记录摘要和哈希,不记录完整正文 |
|
||||
| 文件上传下载 | 不记录文件内容和签名 URL |
|
||||
| 普通 JSON API | 脱敏后最多 50KB |
|
||||
|
||||
Access Log 建议保留 30 天;不得因为 Access Log 已存在而省略关键业务 Audit Event。
|
||||
|
||||
## 十六、保留策略
|
||||
|
||||
| 数据 | 保留期限 |
|
||||
|---|---:|
|
||||
| 资金、权限、审批和关键配置 Audit Event | 5 年 |
|
||||
| 普通资产和业务操作 Audit Event | 2 年 |
|
||||
| Integration Log | 180 天 |
|
||||
| Gateway 高频同步 Integration Log | 正常 30 天,异常或状态变化 180 天 |
|
||||
| Access Log | 30 天 |
|
||||
|
||||
Audit Event 默认不在线删除。数据量达到单表维护阈值后按月分区,超期分区归档到低成本存储,再由专用维护流程删除;应用账号不具备 Update/Delete 审计表权限。
|
||||
|
||||
## 十七、一次性审计切换
|
||||
|
||||
本需求与普通 DDD 触碰式迁移不同:审计基础设施、写入入口、Query API 和前端多视角界面在同一次停机发布中全局切换,不能让新旧审计长期并行。
|
||||
|
||||
### 17.1 新写入
|
||||
|
||||
- 数据同步、企微审批、站内通知和后续新功能只写统一 `tb_audit_event` 与 `tb_integration_log`。
|
||||
- 账号、角色、权限、资产、套餐、订单、退款、充值、钱包、系统配置、导入导出和登录安全等现有敏感操作同时切换到统一 Audit Writer。
|
||||
- 卡同步不创建 `tb_card_sync_execution`;轮询、手动刷新、事件阶梯尝试和运营商回调统一写 Integration Log。
|
||||
- 发布后旧 `tb_account_operation_log`、`tb_asset_operation_log`、手动轮询触发日志等表不再产生新记录。
|
||||
- 禁止双写新旧审计表,避免同一操作产生两条不一致记录。
|
||||
|
||||
一次性切换审计不等于一次性把所有旧业务模块迁移为 DDD。未迁移的旧 Service 通过统一审计 Adapter 写入新模型;退款、资金、卡状态等复杂且本次触碰的用例按 DDD 规范迁入 Application/Domain。
|
||||
|
||||
### 17.2 历史数据
|
||||
|
||||
- 旧审计表保留只读,不在线回填到新表。
|
||||
- 审计 Query 使用 `UNION ALL` 把旧账号、资产和手动触发日志标准化为历史投影,返回 `record_source=legacy_account/legacy_asset/legacy_polling`。
|
||||
- 历史记录只用于查询,不允许更新或补写。
|
||||
- 后续达到归档条件后离线迁移或归档旧表,不影响本次新写入一次性切换。
|
||||
|
||||
## 十八、代码迁移重点
|
||||
|
||||
- 删除 `account_audit.Service.LogOperation` 和 `asset_audit.Service.LogOperation` 内部裸 goroutine。
|
||||
- 删除调用方外围 `go auditService.LogOperation(...)`。
|
||||
- 为尚未迁入 DDD 的旧 Service 提供统一 Audit Writer Adapter,并在本次发布中替换全部敏感操作旧写入口。
|
||||
- 退款、线下充值资金事务使用 `AppendWithTx`。
|
||||
- 卡实名领域用例在状态变化事务内写审计。
|
||||
- 轮询、事件同步、手动刷新和运营商回调统一写 Integration Log;只有状态变化、连续失败、手动操作和高风险异常追加 Audit Event。
|
||||
- 企微模板发布、手工绑定 sp_no、通过后撤销写高风险事件。
|
||||
- `pkg/logger/middleware.go` 对响应体和特殊路由实施脱敏策略。
|
||||
- 现有账号、资产和业务日志详情接口统一改为调用 `query/audit` 的资源时间线或历史投影。
|
||||
|
||||
## 十九、人工验证
|
||||
|
||||
1. 关键资金事务在审计写入失败时整体回滚。
|
||||
2. 同一事件不会因 Outbox/Asynq 重试生成重复审计。
|
||||
3. 一次退款能够从退款单、订单、钱包和资产四个资源视角查到同一事件。
|
||||
4. request_id 能串联一次 API 请求产生的多条审计记录。
|
||||
5. correlation_id 能串联退款申请、企微审批、退款、佣金和资产处理。
|
||||
6. 普通用户不能访问全局、人员、风险和集成视角。
|
||||
7. 敏感字段默认脱敏,查看完整敏感字段的动作本身再次被审计。
|
||||
8. Access Log 请求和响应均不泄露 Token、Secret、操作密码和签名 URL。
|
||||
9. 高频轮询成功只进入外部集成视角,不会淹没业务资源时间线,状态变化和连续失败仍可见。
|
||||
10. 企微通过后撤销且资金已执行时生成 critical 事件,不自动冲正。
|
||||
11. 历史账号/资产日志可以通过统一审计页面查询,发布后的新写入不再双写旧表。
|
||||
12. 审计导出字段受角色权限控制,页面可见不等于可导出完整敏感值。
|
||||
13. 人员、资源和集成视角都有独立查询接口,前端不通过下载全局事件后自行聚合。
|
||||
14. 同一次事件同步的立即、3 分钟、5 分钟尝试能通过 `trigger_series` 连续展示,合并、限流和提前完成都有可解释结果。
|
||||
15. 发布后旧账号、资产和轮询日志表不再新增记录,新旧历史仍能在同一审计界面查询。
|
||||
446
docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md
Normal file
446
docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# 新增需求 05:代理钱包扫码充值
|
||||
|
||||
> 状态:已合并至标准评审稿,本文保留为实施明细。
|
||||
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
|
||||
> 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。
|
||||
|
||||
## 一、已确认决策
|
||||
|
||||
1. 代理在后台为自己的店铺主钱包充值。
|
||||
2. 支付方式支持微信扫码和支付宝扫码。
|
||||
3. 单笔最低充值金额为 100 元,即 `10000` 分。
|
||||
4. 代理在线充值不进入企业微信审批,支付成功后直接幂等增加代理主钱包余额。
|
||||
5. 平台员工线下代充值仍按 `02-企业微信审批接入.md` 走企微审批,与本方案隔离。
|
||||
6. 后端返回支付二维码内容,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。
|
||||
7. 微信使用 Native 支付,支付宝使用 `alipay.trade.precreate` 当面付预创建。
|
||||
8. 支付回调、钱包入账、钱包流水和审计必须幂等,重复回调不能重复加钱。
|
||||
9. 当前代理充值复杂写逻辑迁移到 Application/Domain,旧 Service 不再保留另一套在线入账逻辑。
|
||||
|
||||
## 二、现状与缺口
|
||||
|
||||
现有代码已经具备代理充值记录、微信/富友回调和钱包入账骨架,但还不能满足本需求:
|
||||
|
||||
- `CreateAgentRechargeRequest` 只允许 `wechat/offline`,没有支付宝。
|
||||
- `AgentRechargeMinAmount=1`,当前最低金额是 1 分。
|
||||
- 创建接口只生成充值记录,没有创建统一 `tb_payment` 支付单,也没有真正向支付渠道预下单获取二维码。
|
||||
- 微信支付仅保存当前生效支付配置,响应中没有 `code_url`。
|
||||
- 支付宝回调只分发套餐订单和客户资产钱包充值,没有代理充值订单类型。
|
||||
- `HandlePaymentCallback` 在旧 Service 中直接更新充值单、钱包和流水,业务状态和幂等边界没有收口为独立用例。
|
||||
- 前端技术方案只写了“保留收款码和支付状态页面”,没有支付方式选择、最低金额、二维码过期和支付成功状态细节。
|
||||
|
||||
现有评审中“代理在线充值不审批”的结论保持不变,本次补齐的是扫码支付和最低金额的完整技术方案。
|
||||
|
||||
## 三、业务流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Agent as 代理用户
|
||||
participant Web as 代理后台
|
||||
participant API as Recharge Application
|
||||
participant DB as PostgreSQL
|
||||
participant Pay as 微信/支付宝
|
||||
participant Callback as 支付回调
|
||||
participant Wallet as AgentWallet Domain
|
||||
|
||||
Agent->>Web: 输入金额并选择支付方式
|
||||
Web->>API: 创建扫码充值单
|
||||
API->>DB: 创建充值单和支付单
|
||||
API->>Pay: Native/PreCreate 预下单
|
||||
Pay-->>API: 二维码内容
|
||||
API-->>Web: 返回二维码和过期时间
|
||||
Web-->>Agent: 展示二维码并轮询支付状态
|
||||
Agent->>Pay: 扫码完成支付
|
||||
Pay->>Callback: 异步支付通知
|
||||
Callback->>API: ConfirmAgentRechargePayment
|
||||
API->>DB: 校验支付单、金额和状态
|
||||
API->>Wallet: CreditRecharge
|
||||
Wallet->>DB: 同事务增加余额、写流水、完成充值单
|
||||
API-->>Pay: 返回成功
|
||||
Web->>API: 查询到已完成
|
||||
Web-->>Agent: 展示最新钱包余额
|
||||
```
|
||||
|
||||
支付成功不创建 `wecom_approval_instance`,充值详情固定返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_source": "none",
|
||||
"approval": null
|
||||
}
|
||||
```
|
||||
|
||||
## 四、DDD 设计
|
||||
|
||||
### 4.1 目录
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── domain/agentwallet/
|
||||
│ ├── wallet.go 代理主钱包聚合及余额不变量
|
||||
│ ├── recharge.go 充值单状态转换
|
||||
│ ├── events.go 充值到账领域事件
|
||||
│ └── repository.go 钱包与充值聚合仓储接口
|
||||
├── application/agentrecharge/
|
||||
│ ├── create_qr_recharge.go 创建充值单和扫码支付
|
||||
│ ├── confirm_payment.go 支付回调确认并入账
|
||||
│ ├── close_expired.go 关闭过期未支付充值单
|
||||
│ └── get_payment_status.go 轻量支付状态查询
|
||||
├── infrastructure/adapter/payment/
|
||||
│ ├── wechat_native.go 微信 Native 预下单
|
||||
│ └── alipay_precreate.go 支付宝当面付预创建
|
||||
├── infrastructure/persistence/
|
||||
│ └── agent_recharge_repository.go
|
||||
└── query/agentrecharge/
|
||||
├── list.go
|
||||
└── detail.go
|
||||
```
|
||||
|
||||
### 4.2 领域状态
|
||||
|
||||
继续使用现有充值状态,不为在线充值增加审批状态:
|
||||
|
||||
| 状态 | 含义 | 在线充值使用方式 |
|
||||
|---:|---|---|
|
||||
| 1 | 待支付 | 已创建二维码,等待扫码 |
|
||||
| 2 | 已支付 | 支付已确认,钱包入账事务处理中 |
|
||||
| 3 | 已完成 | 钱包余额和流水已完成 |
|
||||
| 4 | 已关闭 | 超时未支付或主动取消 |
|
||||
| 5 | 已退款 | 历史或后续人工退款结果 |
|
||||
| 6 | 已驳回 | 仅旧数据或线下审批兼容,在线充值不产生 |
|
||||
|
||||
`status=2` 是短暂业务处理状态。支付回调事务正常完成时直接推进到 `3`;若钱包入账发生可恢复错误,则保持 `2` 并由可靠任务继续处理。
|
||||
|
||||
### 4.3 钱包入账不变量
|
||||
|
||||
`AgentWallet.CreditRecharge` 必须保证:
|
||||
|
||||
- 只允许向目标店铺的 `wallet_type=main` 钱包入账。
|
||||
- 入账金额必须等于充值单金额和支付单金额。
|
||||
- 同一充值单只能生成一条成功钱包流水。
|
||||
- 钱包余额、`version`、充值单状态和钱包流水在同一数据库事务更新。
|
||||
- 钱包乐观锁冲突时由 Application 重新加载后有限重试,不能重复创建流水。
|
||||
- 支付渠道成功不等于业务已经完成;只有钱包事务成功后充值单才变为已完成。
|
||||
|
||||
## 五、支付预下单
|
||||
|
||||
### 5.1 统一支付单
|
||||
|
||||
代理在线充值必须创建 `tb_payment` 记录,不能只依赖 `ARCH` 单号前缀判断支付渠道。
|
||||
|
||||
新增支付业务类型:
|
||||
|
||||
```go
|
||||
const PaymentOrderTypeAgentRecharge = "agent_recharge"
|
||||
```
|
||||
|
||||
充值单和支付单在同一事务创建:
|
||||
|
||||
```text
|
||||
tb_agent_recharge_record.status = 1
|
||||
tb_payment.status = 0
|
||||
tb_payment.order_type = agent_recharge
|
||||
tb_payment.order_id = recharge_id
|
||||
tb_payment.payment_method = wechat / alipay
|
||||
tb_payment.amount = recharge_amount
|
||||
tb_payment.payment_config_id = 创建时使用的配置ID
|
||||
```
|
||||
|
||||
先完成本地事务,再调用第三方预下单。预下单失败时把支付单标记为失败并关闭本次充值单,代理重新创建,不复用来源不明确的旧二维码。
|
||||
|
||||
### 5.2 微信扫码
|
||||
|
||||
微信使用 Native 下单:
|
||||
|
||||
```text
|
||||
微信支付 v3 TransactionNative
|
||||
-> 返回 code_url
|
||||
```
|
||||
|
||||
现有微信 SDK 已包含 `TransactionNative`,需要在项目支付 Adapter 中封装,不在 Handler 直接调用 SDK。
|
||||
|
||||
若当前生效支付配置为:
|
||||
|
||||
- `wechat`:使用微信 v3 Native。
|
||||
- `wechat_v2`:补充 v2 Native 统一下单实现。
|
||||
- `fuiou`:只有现有富友配置明确支持后台扫码产品时才返回微信可用;不支持时前端隐藏微信扫码入口,不擅自用 JSAPI 代替。
|
||||
|
||||
### 5.3 支付宝扫码
|
||||
|
||||
支付宝使用当前 SDK 已提供的:
|
||||
|
||||
```text
|
||||
alipay.trade.precreate
|
||||
-> 返回 qr_code
|
||||
```
|
||||
|
||||
不复用现有 WAP 支付 URL。创建时校验当前支付配置中的:
|
||||
|
||||
```text
|
||||
ali_app_id
|
||||
ali_private_key
|
||||
ali_public_key
|
||||
ali_notify_url
|
||||
```
|
||||
|
||||
配置不完整时支付宝方式显示为不可用,不能创建只有本地记录而没有有效二维码的充值单。
|
||||
|
||||
### 5.4 二维码响应
|
||||
|
||||
后端统一返回二维码内容,不返回二维码图片:
|
||||
|
||||
```json
|
||||
{
|
||||
"recharge_id": 88,
|
||||
"recharge_no": "ARCH20260715143000000001",
|
||||
"payment_no": "ARCH20260715143000000001",
|
||||
"payment_method": "wechat",
|
||||
"amount": 10000,
|
||||
"qr_content": "weixin://wxpay/bizpayurl?...",
|
||||
"expires_at": "2026-07-15T15:00:00+08:00",
|
||||
"status": 1,
|
||||
"status_name": "待支付"
|
||||
}
|
||||
```
|
||||
|
||||
微信返回 `code_url`、支付宝返回 `qr_code`,Application 统一映射为 `qr_content`。
|
||||
|
||||
## 六、支付回调与直接入账
|
||||
|
||||
### 6.1 回调校验
|
||||
|
||||
微信和支付宝回调继续执行现有签名、安全和金额校验,并增加代理充值分发:
|
||||
|
||||
```text
|
||||
payment.order_type = agent_recharge
|
||||
-> ConfirmAgentRechargePayment
|
||||
```
|
||||
|
||||
必须校验:
|
||||
|
||||
- 支付单存在且支付方式与回调渠道一致。
|
||||
- `payment_config_id` 与创建支付单时配置一致。
|
||||
- 回调金额等于支付单和充值单金额。
|
||||
- 第三方交易号没有被其他支付单占用。
|
||||
- 充值单属于支付单中的 `order_id`,不能只依赖订单号前缀。
|
||||
|
||||
### 6.2 回调事务
|
||||
|
||||
```text
|
||||
1. 按 payment_no 加载支付单
|
||||
2. 支付单 pending -> paid 条件更新
|
||||
3. 充值单 1 -> 2 条件更新
|
||||
4. 加载代理主钱包并执行 CreditRecharge
|
||||
5. 钱包余额和 version 更新
|
||||
6. 创建唯一钱包流水
|
||||
7. 充值单 2 -> 3,写 paid_at/completed_at
|
||||
8. 写 Audit Event
|
||||
```
|
||||
|
||||
幂等键:
|
||||
|
||||
```text
|
||||
agent_recharge:{recharge_id}:credit
|
||||
```
|
||||
|
||||
数据库还需要保证钱包流水 `reference_type=agent_recharge + reference_id=recharge_id` 唯一。Redis 只用于削减重复并发,不能替代数据库幂等。
|
||||
|
||||
### 6.3 回调失败恢复
|
||||
|
||||
- 第三方校验失败:拒绝回调,不改变业务状态。
|
||||
- 支付状态已成功、钱包事务失败:充值单保持已支付,写 Outbox/恢复任务继续入账。
|
||||
- 钱包流水已存在但充值单未完成:恢复任务只补齐充值单状态。
|
||||
- 重复回调:查询到支付单或充值单已完成后直接返回渠道成功报文。
|
||||
- 本功能不引入审批,也不会因为支付金额较大转入审批。
|
||||
|
||||
## 七、金额与权限
|
||||
|
||||
### 7.1 金额
|
||||
|
||||
```go
|
||||
const AgentRechargeMinAmount int64 = 10000
|
||||
```
|
||||
|
||||
- 单位固定为分。
|
||||
- DTO 使用 `min=10000`,Service/Application 必须再次校验。
|
||||
- 前端输入单位为元,提交前转换为分。
|
||||
- 最大金额继续沿用现有系统上限,后续调整单独配置。
|
||||
- 禁止前端通过浮点数直接计算金额,元转分使用字符串或十进制定点处理。
|
||||
|
||||
### 7.2 权限
|
||||
|
||||
- 代理只能为当前登录账号所属店铺的主钱包充值。
|
||||
- 后端从登录上下文校验 `shop_id`,不能只相信请求参数。
|
||||
- 平台和超级管理员可以查看全部充值记录;是否允许代代理发起在线扫码充值保持现有权限。
|
||||
- 企业账号无权访问代理充值接口。
|
||||
- 代理不能查看其他店铺的充值单、支付状态或二维码。
|
||||
|
||||
## 八、API 设计
|
||||
|
||||
### 8.1 可用支付方式
|
||||
|
||||
```http
|
||||
GET /api/admin/agent-recharges/payment-methods
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"method": "wechat", "name": "微信支付", "enabled": true},
|
||||
{"method": "alipay", "name": "支付宝", "enabled": true}
|
||||
],
|
||||
"min_amount": 10000,
|
||||
"max_amount": 100000000
|
||||
}
|
||||
```
|
||||
|
||||
该路由必须先于 `/:id` 动态路由注册。
|
||||
|
||||
### 8.2 创建扫码充值
|
||||
|
||||
沿用现有接口:
|
||||
|
||||
```http
|
||||
POST /api/admin/agent-recharges
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"shop_id": 101,
|
||||
"amount": 10000,
|
||||
"payment_method": "wechat",
|
||||
"request_id": "01J2RECHARGE..."
|
||||
}
|
||||
```
|
||||
|
||||
`payment_method` 调整为:
|
||||
|
||||
```text
|
||||
wechat / alipay / offline
|
||||
```
|
||||
|
||||
其中 `offline` 仅平台员工线下代充值使用,并继续走企微审批;代理用户只能选择 `wechat/alipay`。
|
||||
|
||||
`request_id` 用于防止前端重复点击创建多个二维码订单,同一店铺下建立业务唯一约束或 Redis 防重键。
|
||||
|
||||
### 8.3 查询支付状态
|
||||
|
||||
```http
|
||||
GET /api/admin/agent-recharges/{id}/payment-status
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 3,
|
||||
"status_name": "已完成",
|
||||
"payment_status": 1,
|
||||
"payment_status_name": "已支付",
|
||||
"paid_at": "2026-07-15T14:35:00+08:00",
|
||||
"completed_at": "2026-07-15T14:35:01+08:00",
|
||||
"wallet_balance": 510000
|
||||
}
|
||||
```
|
||||
|
||||
该接口只返回当前登录代理有权访问的充值单,不返回支付密钥、签名参数或其他店铺余额。
|
||||
|
||||
## 九、前端方案
|
||||
|
||||
### 9.1 入口
|
||||
|
||||
代理后台钱包页面保留“充值”按钮,点击后打开充值弹窗或抽屉,不新增营销页面。
|
||||
|
||||
控件:
|
||||
|
||||
- 金额使用数字输入框,单位为元,明确最低 100 元。
|
||||
- 支付方式使用微信/支付宝分段控件,带对应图标。
|
||||
- 不可用渠道禁用并显示简短原因。
|
||||
- 主按钮为“生成支付二维码”。
|
||||
|
||||
### 9.2 二维码状态
|
||||
|
||||
创建成功后展示:
|
||||
|
||||
```text
|
||||
充值金额
|
||||
支付方式
|
||||
二维码
|
||||
二维码剩余有效时间
|
||||
支付状态
|
||||
取消/重新生成
|
||||
```
|
||||
|
||||
- 前端使用 `qr_content` 生成二维码,不请求后端图片文件。
|
||||
- 页面可见时每 3 秒查询一次轻量支付状态。
|
||||
- 页面隐藏时暂停轮询,恢复可见时立即查询。
|
||||
- 状态变为已完成、已关闭或离开页面时停止轮询。
|
||||
- 二维码过期后禁用原二维码,提供“重新生成”命令,重新创建充值单和支付单。
|
||||
- 支付完成后关闭二维码区域,刷新钱包余额并展示充值成功结果。
|
||||
- 全流程不展示审批状态或企微审批区块。
|
||||
|
||||
### 9.3 列表和详情
|
||||
|
||||
代理充值列表增加支付方式和支付状态:
|
||||
|
||||
```text
|
||||
充值单号 | 金额 | 支付方式 | 支付状态 | 充值状态 | 创建时间 | 完成时间
|
||||
```
|
||||
|
||||
在线充值详情返回 `approval_source=none`。平台员工线下代充值详情继续展示企微审批信息,两类记录按 `payment_method` 区分。
|
||||
|
||||
## 十、审计与通知
|
||||
|
||||
统一审计至少记录:
|
||||
|
||||
```text
|
||||
代理创建充值单
|
||||
支付预下单成功/失败
|
||||
微信/支付宝支付回调成功/失败
|
||||
钱包入账成功/失败
|
||||
重复回调被幂等忽略
|
||||
充值单超时关闭
|
||||
```
|
||||
|
||||
支付渠道交互写 `tb_integration_log`,钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。
|
||||
|
||||
在线充值不产生审批通知。充值成功后可以生成普通资金结果站内通知,但不能显示“审批通过”。
|
||||
|
||||
## 十一、代码迁移范围
|
||||
|
||||
### 11.1 必须修改
|
||||
|
||||
- `AgentRechargeMinAmount` 从 `1` 调整为 `10000`。
|
||||
- `CreateAgentRechargeRequest.payment_method` 增加 `alipay`,金额校验改为 `min=10000`。
|
||||
- 创建代理在线充值时同时创建 `tb_payment` 记录。
|
||||
- 新增 `PaymentOrderTypeAgentRecharge`。
|
||||
- 微信支付 Adapter 增加 Native 预下单。
|
||||
- 支付宝 Adapter 增加 `TradePreCreate`。
|
||||
- 支付宝回调增加代理充值分发。
|
||||
- 微信/富友代理充值回调统一改为按支付单分发,不只依赖 `ARCH` 前缀。
|
||||
- 原 `agent_recharge.Service.HandlePaymentCallback` 迁入 `ConfirmAgentRechargePayment` 用例。
|
||||
- 前端增加支付方式查询、二维码展示和支付状态轮询。
|
||||
|
||||
### 11.2 保持不变
|
||||
|
||||
- 代理在线充值不进入企微审批。
|
||||
- 平台员工线下代充值继续走企微审批。
|
||||
- 代理只能充值自己的店铺主钱包。
|
||||
- 钱包流水仍是资金变化权威记录。
|
||||
- 现有支付回调验签和金额校验原则保持不变。
|
||||
|
||||
## 十二、发布与人工验证
|
||||
|
||||
停机发布,API 与回调服务同时切换:
|
||||
|
||||
1. 验证 99.99 元被后端拒绝,100 元可以创建充值单。
|
||||
2. 验证代理只能为自己的店铺创建微信或支付宝充值。
|
||||
3. 验证微信 Native 返回有效 `code_url`,前端能够扫码支付。
|
||||
4. 验证支付宝 PreCreate 返回有效 `qr_code`,前端能够扫码支付。
|
||||
5. 验证支付回调通过支付单类型分发到代理充值用例。
|
||||
6. 验证微信、支付宝回调金额不一致时不会增加钱包余额。
|
||||
7. 验证支付成功后不创建企微审批实例,直接完成钱包入账。
|
||||
8. 验证重复回调只产生一条钱包流水,余额只增加一次。
|
||||
9. 验证支付成功但钱包事务暂时失败时能够恢复完成,不需要代理重复支付。
|
||||
10. 验证二维码过期后充值单关闭,旧二维码不能继续显示为有效。
|
||||
11. 验证代理充值详情固定返回 `approval_source=none`,不展示审批区域。
|
||||
12. 验证平台员工线下代充值仍按原企微审批方案执行,不受在线充值改造影响。
|
||||
72
docs/7月迭代/独立方案/新增需求/06-换货归属与资产换货标识.md
Normal file
72
docs/7月迭代/独立方案/新增需求/06-换货归属与资产换货标识.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 禅道补充需求:换货归属与资产换货标识
|
||||
|
||||
> 来源:禅道 #98、#86。最终口径以标准评审稿为准。
|
||||
|
||||
## 一、现状
|
||||
|
||||
当前换货要求新旧资产已经属于同一店铺;归属不一致时直接拒绝。换货完成只更新资产状态,没有把旧资产店铺归属赋给平台库存中的新资产。
|
||||
|
||||
资产已有 `asset_status`、`generation`,换货单也保存新旧资产 ID,但资产详情没有统一返回前代和后代资产关系。
|
||||
|
||||
## 二、换货归属规则
|
||||
|
||||
- 新资产处于平台库存或已经属于旧资产店铺时可以换入。
|
||||
- 新资产已经属于其他店铺时拒绝换货,不能覆盖其他代理资产。
|
||||
- 换货完成事务内,新资产继承旧资产 `shop_id` 和租户标签,并写资产分配记录。
|
||||
- 旧资产保留原 `shop_id`,状态改为已换货,继续用于历史订单、退款和换货查询。
|
||||
- 前端不提供目标店铺选择;选择新资产后展示将继承的店铺名称。
|
||||
|
||||
处理顺序:
|
||||
|
||||
```text
|
||||
锁定换货单和新旧资产
|
||||
-> 校验新资产库存及归属
|
||||
-> 新资产继承旧资产店铺
|
||||
-> 迁移客户绑定、钱包和套餐资料
|
||||
-> 更新新旧资产状态
|
||||
-> 完成换货单
|
||||
-> 写资产分配记录和审计事件
|
||||
```
|
||||
|
||||
任一步失败整笔事务回滚。
|
||||
|
||||
## 三、资产详情换货链
|
||||
|
||||
卡和设备详情统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"exchange_trace": {
|
||||
"previous_asset": {
|
||||
"asset_type": "iot_card",
|
||||
"asset_id": 1001,
|
||||
"identifier": "89860...001",
|
||||
"exchange_no": "EXC202607170001"
|
||||
},
|
||||
"next_asset": {
|
||||
"asset_type": "iot_card",
|
||||
"asset_id": 1003,
|
||||
"identifier": "89860...003",
|
||||
"exchange_no": "EXC202607180001"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 有前代时显示“换货新资产”。
|
||||
- 有后代时显示“已换出旧资产”。
|
||||
- A→B→C 场景中,B 可以同时展示前代 A 和后代 C。
|
||||
- 关联资产必须再次经过后端数据权限校验;无权限时只返回标识,不返回可跳转 ID。
|
||||
|
||||
复用 `tb_exchange_order`,补充按旧资产查询已完成换货的方法,以及 `new_asset_type + new_asset_id + status` 查询索引,不新增关系表。
|
||||
|
||||
## 四、接口与前端
|
||||
|
||||
现有卡、设备详情接口增加 `exchange_trace`,不新增独立换货链接口。
|
||||
|
||||
前端在资产基础信息附近展示状态标签和关联资产链接。链接进入对应卡或设备详情,不直接跳换货单;换货单号作为辅助信息展示。
|
||||
|
||||
## 五、审计
|
||||
|
||||
记录旧资产、新资产、换货单、原店铺、新店铺、状态变更和操作人。新资产归属继承属于关键资产变更,必须与换货事务同时写 Audit Event。
|
||||
|
||||
81
docs/7月迭代/独立方案/新增需求/07-店铺业务员与钱包余额预警.md
Normal file
81
docs/7月迭代/独立方案/新增需求/07-店铺业务员与钱包余额预警.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 禅道补充需求:店铺业务员与钱包余额预警
|
||||
|
||||
> 来源:禅道 #96、#97。最终口径以标准评审稿为准。
|
||||
|
||||
## 一、边界
|
||||
|
||||
店铺业务员只表示平台内部的业务归属,用于列表筛选和通知接收。它不建立代理分销关系,不参与店铺上下级、佣金、提现或数据权限计算。
|
||||
|
||||
钱包预警阈值固定为 100 元,不做系统配置、角色配置或店铺配置。
|
||||
|
||||
## 二、店铺业务员
|
||||
|
||||
`tb_shop` 增加:
|
||||
|
||||
```text
|
||||
business_owner_account_id BIGINT NULL
|
||||
```
|
||||
|
||||
- 只允许绑定启用的平台账号。
|
||||
- 创建、编辑店铺时可不选择。
|
||||
- 店铺列表支持按业务员筛选,列表和详情返回账号名称。
|
||||
- 业务员停用或删除后保留关联和历史审计,但不再作为通知接收人;运营可以重新绑定。
|
||||
- 不建立数据库外键,Application 显式校验账号类型和状态。
|
||||
|
||||
API 调整:
|
||||
|
||||
```text
|
||||
POST /api/admin/shops
|
||||
PUT /api/admin/shops/{id}
|
||||
GET /api/admin/shops?business_owner_account_id={id}
|
||||
```
|
||||
|
||||
## 三、固定 100 元余额预警
|
||||
|
||||
固定常量:
|
||||
|
||||
```go
|
||||
const AgentWalletLowBalanceThreshold int64 = 10000 // 代理主钱包现金余额预警阈值:100元
|
||||
```
|
||||
|
||||
只计算代理主钱包现金可用余额:
|
||||
|
||||
```text
|
||||
cash_available = balance - frozen_balance
|
||||
```
|
||||
|
||||
信用额度不参与预警。触发条件:
|
||||
|
||||
```text
|
||||
before_cash_available > 10000
|
||||
after_cash_available <= 10000
|
||||
```
|
||||
|
||||
接收人:
|
||||
|
||||
- 店铺启用的主账号。
|
||||
- 店铺绑定且仍启用的平台业务员。
|
||||
|
||||
本期只发送站内通知,不发送企业微信消息。
|
||||
|
||||
## 四、防重复
|
||||
|
||||
- 首次跌至 100 元及以下时发送一次。
|
||||
- 持续低于阈值时,后续扣款不重复通知。
|
||||
- 余额充值到 100 元以上后重新布防,再次跌破可以重新发送。
|
||||
- 通知使用钱包 ID、预警轮次和接收人组成稳定业务键。
|
||||
|
||||
可以在主钱包保存 `low_balance_alert_active`,或者使用通知状态投影记录当前预警轮次;无论实现方式如何,资金事务不能因为通知失败而回滚,事件先写 Outbox。
|
||||
|
||||
## 五、前端
|
||||
|
||||
- 店铺创建、编辑增加可搜索的平台业务员下拉框。
|
||||
- 店铺列表和详情展示业务员,并支持筛选。
|
||||
- 资金概况在现金可用余额小于等于 100 元时显示红色“现金余额不足 100 元”。
|
||||
- 不展示阈值编辑入口。
|
||||
- 通知点击后受控跳转到对应店铺资金概况。
|
||||
|
||||
## 六、审计
|
||||
|
||||
记录业务员绑定变更、预警触发时的余额/冻结金额、接收人、通知事件 ID 和失败原因。信用额度不得写入余额预警判断结果。
|
||||
|
||||
81
docs/7月迭代/独立方案/新增需求/08-代理系列套餐批量授权.md
Normal file
81
docs/7月迭代/独立方案/新增需求/08-代理系列套餐批量授权.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 禅道补充需求:代理系列套餐批量授权
|
||||
|
||||
> 来源:禅道 #43。最终口径以标准评审稿为准。
|
||||
|
||||
## 一、现状与范围
|
||||
|
||||
系统已经存在代理系列授权和套餐授权数据,后端 `PUT /api/admin/shop-series-grants/{id}/packages` 也已经接受套餐数组。本需求不是重建授权模型,而是补齐套餐候选 Query 和前端批量交互。
|
||||
|
||||
当前主要问题:
|
||||
|
||||
- 前端只能逐个授权套餐。
|
||||
- 首次授权系列和后续管理套餐时,看不出哪些套餐已经授权给当前代理。
|
||||
- 公司成本价、代理授权成本价和建议售价缺少明确区分。
|
||||
|
||||
## 二、套餐候选 Query
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
GET /api/admin/shop-series-grants/{id}/package-options
|
||||
```
|
||||
|
||||
查询参数支持套餐名称、编码和授权状态。响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"package_id": 1001,
|
||||
"package_name": "移动月包",
|
||||
"package_code": "CMCC-M01",
|
||||
"company_cost_price": 5000,
|
||||
"authorized_cost_price": 6500,
|
||||
"suggested_retail_price": 8800,
|
||||
"is_authorized": true,
|
||||
"shelf_status": 1,
|
||||
"status": 1
|
||||
}
|
||||
```
|
||||
|
||||
金额单位统一为分。`authorized_cost_price` 在未授权时返回 `null`。
|
||||
|
||||
## 三、批量授权
|
||||
|
||||
继续使用现有批量写接口:
|
||||
|
||||
```text
|
||||
PUT /api/admin/shop-series-grants/{id}/packages
|
||||
```
|
||||
|
||||
请求一次提交多个套餐:
|
||||
|
||||
```json
|
||||
{
|
||||
"packages": [
|
||||
{"package_id": 1001, "cost_price": 6500},
|
||||
{"package_id": 1002, "cost_price": 7000}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 同一请求内套餐 ID 必须去重。
|
||||
- 已经授权且价格相同的套餐按幂等成功处理。
|
||||
- 已经授权但价格不同的套餐不能被“新增授权”静默改价,应走明确的价格更新操作。
|
||||
- 套餐必须属于当前授权系列,后端不能只相信前端候选列表。
|
||||
- 批量写入在同一事务完成,任一套餐不合法则整批失败。
|
||||
|
||||
## 四、前端
|
||||
|
||||
首次创建系列授权和后续“管理套餐”共用同一个套餐选择组件:
|
||||
|
||||
- 表格使用复选框多选。
|
||||
- 展示套餐名称、编码、公司成本价、当前授权成本价和建议售价。
|
||||
- 已授权套餐显示“已授权”标签并置灰,不可选择。
|
||||
- 支持只看未授权套餐和关键词搜索。
|
||||
- 提交前显示本次新增套餐数量和价格摘要。
|
||||
|
||||
已授权置灰只是交互反馈,后端仍负责幂等和归属校验。
|
||||
|
||||
## 五、权限与审计
|
||||
|
||||
公司成本价属于敏感字段,只有具备系列授权和成本价查看权限的角色可以读取。审计记录代理、系列、套餐列表、授权价格、操作人和变更前后数据。
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# 需求04:退款中资产禁止换货
|
||||
# 需求06:资产最后到期时间
|
||||
|
||||
---
|
||||
|
||||
## 需求04:退款中禁止换货
|
||||
|
||||
### 业务规则
|
||||
|
||||
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
|
||||
|
||||
未结束状态:1=待审批、4=已退回(退回给提交人修改中)。
|
||||
终态(不拦截):2=已通过、3=已拒绝。
|
||||
|
||||
### 数据模型
|
||||
|
||||
退款模型:`RefundRequest`(表 `tb_refund_request`)
|
||||
|
||||
资产字段为两个独立字段(无 asset_type/asset_id):
|
||||
- `iot_card_id *uint`:IoT卡ID(卡类资产)
|
||||
- `device_id *uint`:设备ID(设备类资产)
|
||||
|
||||
状态常量(`internal/model/refund.go`):
|
||||
```go
|
||||
RefundStatusPending = 1 // 待审批
|
||||
RefundStatusApproved = 2 // 已通过
|
||||
RefundStatusRejected = 3 // 已拒绝
|
||||
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
|
||||
```
|
||||
|
||||
### 实现位置
|
||||
|
||||
换货单创建入口:`internal/service/exchange/service.go` 创建前校验。
|
||||
|
||||
### 后端
|
||||
|
||||
**Store 新增方法**(`internal/store/postgres/refund_store.go`):
|
||||
|
||||
```go
|
||||
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
|
||||
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",
|
||||
cardID,
|
||||
model.RefundStatusPending, // 1=待审批
|
||||
model.RefundStatusReturned, // 4=已退回(拦截)
|
||||
).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
|
||||
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",
|
||||
deviceID,
|
||||
model.RefundStatusPending, // 1=待审批
|
||||
model.RefundStatusReturned, // 4=已退回(拦截)
|
||||
).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
```
|
||||
|
||||
**Service 校验**(`internal/service/exchange/service.go` 创建换货单前调用):
|
||||
|
||||
```go
|
||||
// validateNoActiveRefund 校验资产是否有未结束的退款申请
|
||||
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
|
||||
var hasActive bool
|
||||
var err error
|
||||
if asset.CardID != nil {
|
||||
hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
|
||||
} else if asset.DeviceID != nil {
|
||||
hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasActive {
|
||||
return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
无需改动。换货申请时后端返回错误,前端展示错误信息即可。
|
||||
|
||||
---
|
||||
|
||||
## 需求06:资产详情-所有套餐的最后到期时间
|
||||
|
||||
### 业务规则
|
||||
|
||||
资产详情页展示:**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。
|
||||
|
||||
(即所有排队套餐依次激活完毕后,资产最终什么时候到期)
|
||||
|
||||
### 接口
|
||||
|
||||
后台资产详情页实际调用的是:
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```
|
||||
|
||||
响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`)
|
||||
|
||||
该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。
|
||||
|
||||
### 后端
|
||||
|
||||
**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`):
|
||||
|
||||
```go
|
||||
// 所有套餐(生效中+待生效)中最晚的到期时间,无套餐时为 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) {
|
||||
var usages []*model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", cardID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
).
|
||||
Order("created_at ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
|
||||
// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐
|
||||
func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(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,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
).
|
||||
Order("created_at ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
```
|
||||
|
||||
> 注:待生效套餐的 `expires_at` 可能为 null(实名即生效尚未实名),取已知 expires_at 中的最大值。
|
||||
|
||||
**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
|
||||
}
|
||||
}
|
||||
resp.LastPackageExpiresAt = lastExpiry
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
资产详情"套餐信息"板块新增展示:
|
||||
|
||||
```
|
||||
最后到期时间:2027-01-01
|
||||
```
|
||||
|
||||
读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。
|
||||
@@ -1,154 +0,0 @@
|
||||
# 需求08:设备批量分配代理和套餐系列(Excel导入)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)来批量指定分配目标。
|
||||
|
||||
`Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同):
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_device_batch_allocation_task (
|
||||
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,
|
||||
task_no VARCHAR(30) NOT NULL,
|
||||
shop_id BIGINT NOT NULL,
|
||||
series_id BIGINT,
|
||||
operator_id BIGINT NOT NULL,
|
||||
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,
|
||||
failed_items JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
状态常量:1=处理中,2=已完成,3=失败。
|
||||
|
||||
失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。
|
||||
|
||||
---
|
||||
|
||||
## Model(`internal/model/device_batch_allocation_task.go`)
|
||||
|
||||
```go
|
||||
// DeviceBatchAllocationTask 设备批量分配任务模型
|
||||
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"`
|
||||
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"`
|
||||
FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
|
||||
StartedAt *time.Time `gorm:"column:started_at" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"`
|
||||
}
|
||||
|
||||
func (DeviceBatchAllocationTask) TableName() string {
|
||||
return "tb_device_batch_allocation_task"
|
||||
}
|
||||
```
|
||||
|
||||
> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。
|
||||
|
||||
---
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 下载 Excel 模板
|
||||
|
||||
```
|
||||
GET /admin/devices/batch-allocation/template
|
||||
```
|
||||
|
||||
响应:Excel 文件,表头为"设备号"(一列)。
|
||||
|
||||
### 2. 上传并提交
|
||||
|
||||
```
|
||||
POST /admin/devices/batch-allocation
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
字段:
|
||||
shop_id: 123 (必填,分配给哪个代理)
|
||||
series_id: 45 (可选,同时分配套餐系列)
|
||||
file: <Excel文件>
|
||||
```
|
||||
|
||||
**处理逻辑(Asynq 异步)**:
|
||||
|
||||
1. 解析 Excel,读取"设备号"列
|
||||
2. 逐行查找 `tb_device` 中匹配的设备(按 `virtual_no` 或 `imei`)
|
||||
3. 对找到的设备:更新 `shop_id` + `series_id`
|
||||
4. 未找到的:记录失败原因"设备号不存在"
|
||||
5. 已分配给其他代理的:记录"该设备已分配,请先回收"
|
||||
|
||||
### 3. 查询任务状态
|
||||
|
||||
```
|
||||
GET /admin/devices/batch-allocation/{task_id}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_no": "DBA20240101001",
|
||||
"status": 2,
|
||||
"status_name": "已完成",
|
||||
"total_count": 100,
|
||||
"success_count": 95,
|
||||
"fail_count": 5,
|
||||
"failed_items": [
|
||||
{ "line": 3, "virtual_no": "12345", "reason": "设备号不存在" },
|
||||
{ "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" }
|
||||
],
|
||||
"started_at": "2024-01-01T10:00:00Z",
|
||||
"completed_at": "2024-01-01T10:00:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 入口
|
||||
|
||||
设备管理页 > 批量操作 > "批量分配代理"
|
||||
|
||||
### 操作流程
|
||||
|
||||
1. 点击"批量分配代理"
|
||||
2. 弹框内:
|
||||
- 代理选择器(搜索+选择目标代理,必填)
|
||||
- 套餐系列选择器(可选)
|
||||
- Excel 上传区域(含模板下载链接)
|
||||
3. 上传后点击"提交" → `POST /admin/devices/batch-allocation`
|
||||
4. 提示"任务提交成功,正在处理..."
|
||||
5. 轮询 `GET /admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
|
||||
6. 展示结果:成功X条,失败X条,失败明细在页面展示
|
||||
|
||||
### Excel 规范
|
||||
|
||||
- 表头:`设备号`
|
||||
- 每行一个设备号(支持虚拟号或IMEI)
|
||||
@@ -1,194 +0,0 @@
|
||||
# 需求10:限速规则
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Gateway 已有 `SetSpeedLimit` 接口,支持卡和设备:
|
||||
|
||||
```go
|
||||
// internal/gateway/device.go
|
||||
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) 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/s),0=不限速
|
||||
Extend string `json:"extend,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**"根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则"**
|
||||
|
||||
设计思路:限速规则配置在**套餐**上(不同运营商、不同流量档位对应不同套餐,套餐本身就是差异载体)。套餐激活时自动调用 Gateway 设置限速。
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
```sql
|
||||
-- 套餐表增加限速字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN speed_limit_kbps INT NOT NULL DEFAULT 0
|
||||
COMMENT '限速值(KB/s),0=不限速';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model 变更
|
||||
|
||||
```go
|
||||
// internal/model/package.go
|
||||
type Package struct {
|
||||
// ...原有字段...
|
||||
SpeedLimitKbps int `gorm:"column:speed_limit_kbps;type:int;not null;default:0;comment:限速值(KB/s),0=不限速" json:"speed_limit_kbps"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 套餐激活时设置限速
|
||||
|
||||
在 `internal/service/package/activation_service.go` 中,套餐激活(`ActivatedAt` 赋值)时调用限速:
|
||||
|
||||
```go
|
||||
// activatePackageUsage 套餐激活核心逻辑(已有函数,追加限速调用)
|
||||
func (s *ActivationService) activatePackageUsage(ctx context.Context, usage *model.PackageUsage, pkg *model.Package) error {
|
||||
// ...现有激活逻辑...
|
||||
|
||||
// 套餐有限速配置时,调用 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/s),0=取消限速"`
|
||||
}
|
||||
```
|
||||
|
||||
直接调 Gateway,不更新套餐配置(临时操作)。
|
||||
|
||||
---
|
||||
|
||||
## API 变更(套餐管理)
|
||||
|
||||
### 创建/更新套餐新增字段
|
||||
|
||||
```
|
||||
POST /admin/packages
|
||||
PUT /admin/packages/{id}
|
||||
```
|
||||
|
||||
新增参数:
|
||||
```go
|
||||
SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值(KB/s),0=不限速"`
|
||||
```
|
||||
|
||||
### 套餐列表/详情响应新增字段
|
||||
|
||||
```go
|
||||
type PackageResponse struct {
|
||||
// ...原有字段...
|
||||
SpeedLimitKbps int `json:"speed_limit_kbps" description:"限速值(KB/s),0=不限速"`
|
||||
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. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。
|
||||
@@ -1,247 +0,0 @@
|
||||
# 需求14:导出功能
|
||||
|
||||
> 复用现有 ExportTask 体系(`tb_export_task` + Asynq)
|
||||
|
||||
---
|
||||
|
||||
## 导出模块总览
|
||||
|
||||
| 编号 | 模块 | 新增/修改 |
|
||||
|------|------|---------|
|
||||
| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 |
|
||||
| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** |
|
||||
| 6.8.3 | 套餐列表导出 | **全新** |
|
||||
| 6.8.4 | 退款管理退款列表导出 | **全新** |
|
||||
| 6.8.5 | 换货管理导出 | **全新** |
|
||||
| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) |
|
||||
|
||||
---
|
||||
|
||||
## 现有导出体系说明
|
||||
|
||||
系统已有异步导出框架(`internal/exporter/`):
|
||||
- `tb_export_task` 表记录导出任务
|
||||
- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`)
|
||||
- `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景
|
||||
- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行
|
||||
- 前端轮询任务状态后下载
|
||||
|
||||
新增导出模块需要:
|
||||
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` 字段)
|
||||
|
||||
---
|
||||
|
||||
## EXPD-001~003:IoT卡导出字段新增
|
||||
|
||||
**修改文件**:`internal/exporter/iot_card_scene.go`
|
||||
|
||||
在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段:
|
||||
|
||||
```go
|
||||
// Headers() 新增
|
||||
"套餐名称", "使用流量(MB)", "剩余流量(MB)"
|
||||
|
||||
// baseQuery() 或 Fetch() 新增 JOIN
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name
|
||||
FROM tb_package_usage pu
|
||||
JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL
|
||||
WHERE pu.iot_card_id = c.id AND pu.status = 1
|
||||
AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
) AS pkg ON TRUE
|
||||
|
||||
// iotCardExportRow 新增
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb"`
|
||||
```
|
||||
|
||||
剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算)
|
||||
|
||||
---
|
||||
|
||||
## 6.8.2:代理资金概况-预充值钱包流水导出
|
||||
|
||||
**新增接口**:`POST /admin/agent-wallet-transactions/export`
|
||||
|
||||
支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。
|
||||
|
||||
导出字段映射:
|
||||
|
||||
| 字段 | 数据来源 |
|
||||
|------|---------|
|
||||
| 店铺名称 | JOIN `tb_shop` |
|
||||
| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) |
|
||||
| 交易金额 | `amount / 100` 转元 |
|
||||
| 状态 | `status` 中文化 |
|
||||
| 资产类型 | `asset_type` 中文化 |
|
||||
| 资产标识 | `asset_identifier` |
|
||||
| 交易时间 | `created_at` |
|
||||
| 交易前金额 | `balance_before / 100` |
|
||||
| 交易后金额 | `balance_after / 100` |
|
||||
| 购买套餐名称 | `metadata` 中 JSON 字段 `package_name`,或 JOIN 订单 |
|
||||
| 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) |
|
||||
| 交易 ID | `id` |
|
||||
| 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) |
|
||||
| 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 |
|
||||
|
||||
---
|
||||
|
||||
## 6.8.3:套餐列表导出
|
||||
|
||||
**新增接口**:`POST /admin/packages/export`
|
||||
|
||||
支持现有套餐列表筛选条件。
|
||||
|
||||
导出字段(按需求文档 24 个字段):
|
||||
|
||||
```go
|
||||
type PackageExportRow struct {
|
||||
PackageCode string `xlsx:"套餐编码"`
|
||||
PackageName string `xlsx:"套餐名称"`
|
||||
SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series
|
||||
PackageType string `xlsx:"套餐类型"` // formal/addon 中文化
|
||||
DurationMonths int `xlsx:"套餐时长(月)"`
|
||||
DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明
|
||||
CalendarType string `xlsx:"套餐周期类型"`
|
||||
DurationDays int `xlsx:"套餐天数"`
|
||||
RealDataMB int64 `xlsx:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `xlsx:"虚流量额度(MB)"`
|
||||
EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否
|
||||
VirtualRatio float64 `xlsx:"虚流量比例"`
|
||||
DataResetCycle string `xlsx:"流量重置周期"`
|
||||
ExpiryBase string `xlsx:"到期时间基准"`
|
||||
CostPrice string `xlsx:"成本价(元)"` // 分→元
|
||||
SuggestedRetailPrice string `xlsx:"建议售价(元)"`
|
||||
PriceConfigStatus string `xlsx:"价格配置状态"`
|
||||
Status string `xlsx:"状态"`
|
||||
ShelfStatus string `xlsx:"上架状态"`
|
||||
IsGift string `xlsx:"是否赠送套餐"`
|
||||
CreatorID uint `xlsx:"创建人ID"`
|
||||
UpdaterID uint `xlsx:"更新人ID"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
UpdatedAt string `xlsx:"更新时间"`
|
||||
DeletedAt string `xlsx:"删除时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.4:退款管理退款列表导出
|
||||
|
||||
**新增接口**:`POST /admin/refund-orders/export`
|
||||
|
||||
导出字段(去掉"退款到账方式",保留其余字段,部门领导/财务审批人依赖审批流):
|
||||
|
||||
```go
|
||||
type RefundExportRow struct {
|
||||
RefundNo string `xlsx:"退款单号"`
|
||||
ShopName string `xlsx:"代理店铺名称"`
|
||||
PaymentOrderNo string `xlsx:"关联的支付订单号"`
|
||||
AssetType string `xlsx:"资产类型"`
|
||||
AssetIdentifier string `xlsx:"资产标识"`
|
||||
PackageName string `xlsx:"套餐名称"`
|
||||
OriginalAmount string `xlsx:"原订单金额"`
|
||||
ActualAmount string `xlsx:"实收金额"`
|
||||
RefundableAmount string `xlsx:"可退金额"`
|
||||
AppliedAmount string `xlsx:"申请退款金额"`
|
||||
ActualRefundAmount string `xlsx:"实际退款金额"`
|
||||
Status string `xlsx:"状态"`
|
||||
RefundReason string `xlsx:"退款原因"`
|
||||
Remark string `xlsx:"备注"`
|
||||
ApprovalRemark string `xlsx:"审批备注"`
|
||||
AppliedAt string `xlsx:"退款申请时间"`
|
||||
CompletedAt string `xlsx:"退款完成时间"`
|
||||
SubmitterName string `xlsx:"提交人"`
|
||||
DeptLeaderName string `xlsx:"部门领导审批人"`
|
||||
FinanceName string `xlsx:"财务审批人"`
|
||||
VoucherURLs string `xlsx:"退款凭证"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.5:换货管理导出
|
||||
|
||||
**新增接口**:`POST /admin/exchange-orders/export`
|
||||
|
||||
```go
|
||||
type ExchangeExportRow struct {
|
||||
ExchangeNo string `xlsx:"换货单号"`
|
||||
ExchangeType string `xlsx:"换货类型"`
|
||||
ExchangeReason string `xlsx:"换货原因"`
|
||||
ProblemDesc string `xlsx:"问题描述"`
|
||||
OldAssetType string `xlsx:"旧资产类型"`
|
||||
OldAssetIdentifier string `xlsx:"旧资产标识符"`
|
||||
NewAssetIdentifier string `xlsx:"新资产标识符"`
|
||||
ReceiverName string `xlsx:"收货人姓名"`
|
||||
ReceiverPhone string `xlsx:"收货人电话"`
|
||||
ReceiverAddress string `xlsx:"收货地址"`
|
||||
ExpressCompany string `xlsx:"快递公司"`
|
||||
TrackingNo string `xlsx:"快递单号"`
|
||||
Status string `xlsx:"状态"`
|
||||
CreatorName string `xlsx:"创建人"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.6:代理充值导出
|
||||
|
||||
**新增接口**:`POST /admin/agent-recharge-orders/export`
|
||||
|
||||
去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:
|
||||
|
||||
```go
|
||||
type AgentRechargeExportRow struct {
|
||||
RechargeNo string `xlsx:"充值单号"`
|
||||
ShopName string `xlsx:"店铺名称"`
|
||||
RechargeType string `xlsx:"充值类型"`
|
||||
RechargeAmount string `xlsx:"充值金额"`
|
||||
ActualAmount string `xlsx:"实付金额"`
|
||||
BalanceBefore string `xlsx:"充值前余额"`
|
||||
BalanceAfter string `xlsx:"充值后余额"`
|
||||
Status string `xlsx:"状态"`
|
||||
PaymentMethod string `xlsx:"支付方式"`
|
||||
// 去掉支付通道
|
||||
OperationRemark string `xlsx:"运营备注"`
|
||||
RejectReason string `xlsx:"驳回原因"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
PaidAt string `xlsx:"支付时间"`
|
||||
CompletedAt string `xlsx:"完成时间"`
|
||||
SubmitterName string `xlsx:"提交人"`
|
||||
DeptLeaderName string `xlsx:"部门领导审批人"`
|
||||
FinanceName string `xlsx:"财务审批人"`
|
||||
VoucherURLs string `xlsx:"支付凭证"`
|
||||
Remark string `xlsx:"备注"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接(通用模式)
|
||||
|
||||
各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。
|
||||
|
||||
调用流程:
|
||||
1. 点击"导出" → 携带当前筛选条件 → `POST /admin/{module}/export`
|
||||
2. 返回 `export_task_id`
|
||||
3. 前端轮询 `GET /admin/export-tasks/{id}` 直到 `status=completed`
|
||||
4. 下载 `download_url`
|
||||
|
||||
(与现有导出体系完全一致,复用现有前端导出 Hook)
|
||||
|
||||
---
|
||||
|
||||
## 权限导出配置(预留)
|
||||
|
||||
需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。
|
||||
|
||||
**本次迭代**:统一导出全量字段,不做权限差异化(复杂度高,单独排期)。
|
||||
|
||||
**预留方案**:在导出 Handler 里预留 `filterFieldsByRole(userRoles, rows)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。
|
||||
@@ -1,348 +0,0 @@
|
||||
# 需求15/16/18/19/20/21 技术方案
|
||||
|
||||
---
|
||||
|
||||
## 需求15:套餐下架后允许续费
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 下架套餐(`shelf_status=2`)不可被**新购**
|
||||
- 下架套餐**可以续费**(已在使用该套餐的客户)
|
||||
- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户)
|
||||
|
||||
### 后端
|
||||
|
||||
**当前逻辑**:下架套餐在购买时被拦截。
|
||||
|
||||
修改:在订单创建校验中,区分"新购"和"续费"场景:
|
||||
|
||||
```go
|
||||
// internal/service/order/service.go 或 client_order/service.go
|
||||
func (s *Service) validatePackageAvailability(
|
||||
ctx context.Context,
|
||||
pkg *model.Package,
|
||||
assetID uint,
|
||||
isRenewal bool,
|
||||
) 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, "套餐已下架,不可新购")
|
||||
}
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
C端续费页面:下架套餐不在"新购"列表中展示,但在"续费"入口中仍可展示。
|
||||
|
||||
后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。
|
||||
|
||||
---
|
||||
|
||||
## 需求16:代理分销码与佣金提现
|
||||
|
||||
### 业务规则
|
||||
|
||||
**DST-001**:新建代理时自动建立分销归属关系(指定发展人)
|
||||
**DST-002**:员工可作为代理发展人进行标识
|
||||
**二维码**:代理或员工生成推广二维码 → 扫码进入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 '发展人ID(shop_id 或 tb_account.id)',
|
||||
ADD COLUMN referrer_name VARCHAR(50) DEFAULT NULL
|
||||
COMMENT '发展人姓名快照';
|
||||
|
||||
-- 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 列表)。
|
||||
|
||||
---
|
||||
|
||||
## 需求18:多人审批(APR-001~009)
|
||||
|
||||
### 依赖
|
||||
|
||||
基于 [审批流基础设施](./基础设施/审批流.md) 和 [站内消息](./基础设施/站内消息.md)。
|
||||
|
||||
APR-009(企微审批对接)= Phase 2。
|
||||
|
||||
### 实现要点
|
||||
|
||||
| 编号 | 需求 | 实现 |
|
||||
|------|------|------|
|
||||
| APR-001~003 | 充值/退款多级审核 | 见需求20/21 |
|
||||
| APR-004 | 审核环节:部门领导→财务 | 审批流固定两步:step=1 部门领导,step=2 财务 |
|
||||
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
|
||||
| APR-006 | 上一级完成后才提示下一级 | `ApprovalFlowAggregate.Approve()` 触发 `ApprovalStepAdvancedEvent` |
|
||||
| APR-007 | 通过后通知申请人 | `ApprovalCompletedEvent` handler |
|
||||
| APR-008 | 驳回/退回后通知申请人含原因 | `ApprovalRejectedEvent` / `ApprovalReturnedEvent` handler |
|
||||
| APR-009 | 对接企微 | **Phase 2** |
|
||||
|
||||
### 业务表与审批流的关联
|
||||
|
||||
充值单、退款单接入审批流,各自新增 `approval_flow_id` 字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_refund_request ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
|
||||
COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)';
|
||||
|
||||
ALTER TABLE tb_agent_recharge_record ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
|
||||
COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)';
|
||||
```
|
||||
|
||||
接入协议详见 [审批流文档 - 接入协议](./基础设施/审批流.md#六接入协议业务层如何接入审批流)。
|
||||
|
||||
---
|
||||
|
||||
## 需求19:批量订购套餐(BPO-001~008)
|
||||
|
||||
### 业务流程
|
||||
|
||||
1. 员工进入批量订购页面
|
||||
2. 选择代理 + 上传 Excel(字段:资产类型/资产标识/套餐编码/套餐名称/支付方式)
|
||||
3. 系统解析并校验每一行
|
||||
4. 支付方式:`offline=线下支付(默认成功)` / `agent_wallet=代理钱包支付`
|
||||
5. 代理钱包支付时从代理余额扣款(含信用额度)
|
||||
6. 线下支付时标记为已支付(不走钱包)
|
||||
7. 校验失败的行展示在页面,带失败原因
|
||||
8. **线下支付需上传整批次的支付凭证**
|
||||
|
||||
### 数据库变更
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_bulk_purchase_task (
|
||||
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,
|
||||
task_no VARCHAR(30) NOT NULL,
|
||||
shop_id BIGINT NOT NULL,
|
||||
operator_id BIGINT NOT NULL,
|
||||
payment_method VARCHAR(20) NOT NULL, -- offline | agent_wallet
|
||||
voucher_keys JSONB, -- 线下支付凭证列表
|
||||
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 -- 失败明细
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_bulk_purchase_task_no ON tb_bulk_purchase_task(task_no) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
> 失败明细存 JSONB(`failed_items`),与现有 DeviceImportTask 模式一致,无需单独明细表。
|
||||
|
||||
### API 设计
|
||||
|
||||
**下载 Excel 模板**:
|
||||
```
|
||||
GET /admin/bulk-purchases/template
|
||||
```
|
||||
|
||||
**上传并提交**:
|
||||
```
|
||||
POST /admin/bulk-purchases
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
字段:
|
||||
shop_id: 123
|
||||
payment_method: offline | agent_wallet
|
||||
voucher_keys: ["key1","key2"] (offline 时必填)
|
||||
file: <Excel文件>
|
||||
```
|
||||
|
||||
**查询任务状态**:
|
||||
```
|
||||
GET /admin/bulk-purchases/{task_id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 需求20:退款审批
|
||||
|
||||
### 依赖
|
||||
|
||||
基于 [审批流基础设施](./基础设施/审批流.md)。
|
||||
|
||||
### 退款单现有状态(不变)
|
||||
|
||||
```
|
||||
1=待审批 2=已通过 3=已拒绝 4=已退回
|
||||
```
|
||||
|
||||
退款单本身的状态含义不变,审批进度由 `tb_approval_flow` 管理,两者通过 `approval_flow_id` 关联。
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
提交退款申请(POST /admin/refund-requests)
|
||||
→ 创建 RefundRequest(status=1 待审批)
|
||||
→ 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=refund)
|
||||
→ 把 approval_flow_id 写入 RefundRequest
|
||||
|
||||
审批人操作(POST /admin/approvals/{flow_id}/approve|reject|return)
|
||||
→ ApprovalCompletedEvent → RefundRequest.status=2(已通过)→ 触发实际退款
|
||||
→ ApprovalRejectedEvent → RefundRequest.status=3(已拒绝)
|
||||
→ ApprovalReturnedEvent → RefundRequest.status=4(已退回)→ 通知提交人可修改
|
||||
```
|
||||
|
||||
### 退回后重新提交
|
||||
|
||||
```
|
||||
PUT /admin/refund-requests/{id} 仅 status=4 时可编辑退款单内容
|
||||
POST /admin/refund-requests/{id}/resubmit
|
||||
→ 校验 status=4
|
||||
→ 新建 ApprovalFlow
|
||||
→ 更新 approval_flow_id,status 回到 1(待审批)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 需求21:充值审核流程
|
||||
|
||||
### 充值单现有状态
|
||||
|
||||
```
|
||||
tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
|
||||
```
|
||||
|
||||
员工线下充值走审批流,需在现有状态基础上**新增"已退回"状态**:
|
||||
|
||||
```sql
|
||||
-- status 说明更新(不改原有值语义,追加新状态)
|
||||
-- 6=已退回(审批人退回给提交人修改)
|
||||
```
|
||||
|
||||
充值业务的状态语义:
|
||||
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 approval_flow_id 判断是否在审批中)
|
||||
- `2=已支付`:支付成功(线下充值审批通过后跳到已完成,不经过此状态)
|
||||
- `3=已完成`:充值到账
|
||||
- `4=已关闭`:取消/超时
|
||||
- `5=已退款`:退款
|
||||
- `6=已退回`:审批人退回给提交人修改
|
||||
|
||||
### 流程
|
||||
|
||||
**代理自行充值(不走审批)**:
|
||||
|
||||
```
|
||||
代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包
|
||||
```
|
||||
|
||||
**员工线下代充值(走审批)**:
|
||||
|
||||
```
|
||||
POST /admin/agent-recharge-records(payment_method=offline)
|
||||
→ 创建 AgentRechargeRecord(status=1)
|
||||
→ 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=recharge)
|
||||
→ 把 approval_flow_id 写入记录
|
||||
|
||||
审批通过 → AgentRechargeRecord.status=3(已完成)→ 实际充值到钱包
|
||||
审批驳回 → AgentRechargeRecord.status=4(已关闭)
|
||||
审批退回 → AgentRechargeRecord.status=6(已退回)→ 通知提交人可修改
|
||||
```
|
||||
|
||||
### 退回后重新提交
|
||||
|
||||
```
|
||||
PUT /admin/agent-recharge-records/{id} 仅 status=6 时可编辑
|
||||
POST /admin/agent-recharge-records/{id}/resubmit
|
||||
→ 校验 status=6
|
||||
→ 新建 ApprovalFlow
|
||||
→ 更新 approval_flow_id,status 回到 1(待支付/待审批)
|
||||
```
|
||||
@@ -1,264 +0,0 @@
|
||||
# 需求17:信用额度
|
||||
|
||||
> DDD 结构:`internal/domain/wallet/`
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 两类主体
|
||||
|
||||
| 主体 | 信用额度来源 | 修改条件 |
|
||||
|------|------------|---------|
|
||||
| 代理(店铺) | 创建店铺时设置 `credit_limit` | 修改前必须先清零欠款(`balance >= 0`)|
|
||||
| 平台员工 | 基于**角色**,不是账号 | 角色权限表里配置 |
|
||||
|
||||
### 可用余额公式
|
||||
|
||||
```
|
||||
可用额度 = balance + credit_limit - frozen_balance
|
||||
```
|
||||
|
||||
- `balance` 可以为负(表示欠款)
|
||||
- `balance < 0` 时表示已在使用信用额度
|
||||
- `balance < -credit_limit` 表示超额使用(理论上不可达,系统保护)
|
||||
|
||||
### BPO-009 "是否可授权额度"开关
|
||||
|
||||
- 代理创建时,新增 `enable_credit` 开关
|
||||
- 开关关闭 = `credit_limit` 强制为 0,不允许使用信用额度
|
||||
- 平台员工账号通过角色自动拥有额度,无需开关
|
||||
|
||||
### BPO-012 负余额显示
|
||||
|
||||
- 代理前台(AgentWallet):`balance` 正常展示(可为负)
|
||||
- 前端不做"不能为负"的前端校验,由后端控制
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 1. Shop 表加信用额度字段
|
||||
|
||||
```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 '授信额度上限(分)';
|
||||
```
|
||||
|
||||
### 2. AgentWallet 表加信用额度字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_agent_wallet
|
||||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT '信用额度(分),与 shop.credit_limit 同步,余额可到 -credit_limit';
|
||||
```
|
||||
|
||||
> 钱包上冗余一份,是为了避免每次扣款都要 join shop 表。通过 Shop 修改额度时同步更新钱包。
|
||||
|
||||
### 3. 角色信用额度配置(平台员工)
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_role
|
||||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT '该角色的信用额度(分),仅对平台员工角色有效';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
type AgentWallet struct {
|
||||
// ...原有字段(Balance, FrozenBalance 已存在)...
|
||||
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:信用额度(分)" json:"credit_limit"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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 nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 应用层
|
||||
|
||||
```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)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 创建店铺(新增字段)
|
||||
|
||||
```
|
||||
POST /admin/shops
|
||||
```
|
||||
|
||||
新增参数:
|
||||
```go
|
||||
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
|
||||
CreditLimit int64 `json:"credit_limit" description:"授信额度(分),enable_credit=true 时有效"`
|
||||
```
|
||||
|
||||
### 2. 修改信用额度
|
||||
|
||||
```
|
||||
PUT /admin/shops/{id}/credit-limit
|
||||
```
|
||||
|
||||
```go
|
||||
type UpdateCreditLimitRequest struct {
|
||||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新授信额度(分),修改前需余额>=0"`
|
||||
}
|
||||
```
|
||||
|
||||
错误场景:余额为负时,返回 `CodeForbidden`,msg="存在欠款(-XX元),请先清零后再修改信用额度"
|
||||
|
||||
### 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时有值"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 页面:创建/编辑代理
|
||||
|
||||
新增"信用额度"区块:
|
||||
```
|
||||
信用额度:
|
||||
[☑ 开启信用额度]
|
||||
授信上限:[____] 元 (enable_credit=true 时展示)
|
||||
```
|
||||
|
||||
提交时:`enable_credit: true, credit_limit: 100000`(单位:分,前端转换)
|
||||
|
||||
### 页面:代理详情 > 钱包信息
|
||||
|
||||
```
|
||||
钱包余额:-¥200.00 (红色显示)
|
||||
信用额度:¥1,000.00
|
||||
可用余额:¥800.00
|
||||
```
|
||||
|
||||
当 `is_in_debt=true` 时,余额展示红色,并加"欠款"标签。
|
||||
|
||||
### 页面:修改信用额度
|
||||
|
||||
入口:代理详情 > 钱包 > "修改信用额度"按钮
|
||||
弹框:输入新额度 → `PUT /admin/shops/{id}/credit-limit`
|
||||
|
||||
如果当前有欠款(`is_in_debt=true`),禁用该按钮,展示"存在欠款,无法修改信用额度"。
|
||||
|
||||
### 页面:余额显示(全局)
|
||||
|
||||
代理管理列表的余额列:支持显示负数,负数红色标注。
|
||||
|
||||
### 平台员工余额(角色信用)
|
||||
|
||||
平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。
|
||||
在批量订购确认页展示可用额度。
|
||||
@@ -1,266 +0,0 @@
|
||||
# 需求22:套餐临期提醒
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 临期定义
|
||||
|
||||
当资产的**当前生效套餐**(`PackageUsage.status=1`)的 `expires_at` ≤ 15天后,该资产进入临期状态。
|
||||
|
||||
**例外**:资产若有**排队中(status=0)的套餐**,不属于临期,不触发提醒。
|
||||
|
||||
### 颜色规则(Version 2)
|
||||
|
||||
| 剩余天数 | 颜色 |
|
||||
|---------|------|
|
||||
| ≤ 15天 | 粉红色 |
|
||||
| ≤ 7天 | 紫色 |
|
||||
| ≤ 3天 | 黄色 |
|
||||
|
||||
### 各端提醒规则
|
||||
|
||||
| 场景 | 提醒方式 | 触发节点 |
|
||||
|------|---------|---------|
|
||||
| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 |
|
||||
| **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 |
|
||||
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
|
||||
| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 |
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
无新表。临期数据实时计算,不存储(避免数据陈旧)。
|
||||
|
||||
企业客户的**每日临期推送记录**需防重,用一个 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)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 现有接口新增临期字段
|
||||
|
||||
#### 资产列表接口(`GET /admin/iot-cards` / `GET /admin/devices`)
|
||||
|
||||
响应新增字段:
|
||||
```go
|
||||
type IotCardListItem struct {
|
||||
// ...原有字段...
|
||||
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"`
|
||||
}
|
||||
```
|
||||
|
||||
**计算方式**(在 SQL 层计算,避免 N+1):
|
||||
|
||||
```sql
|
||||
-- 列表查询时 JOIN PackageUsage 获取剩余天数
|
||||
SELECT
|
||||
ic.*,
|
||||
CASE
|
||||
WHEN pu.expires_at IS NULL 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)
|
||||
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
|
||||
```
|
||||
|
||||
#### 资产列表筛选条件新增
|
||||
|
||||
```go
|
||||
type IotCardListRequest struct {
|
||||
// ...原有字段...
|
||||
ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 资产详情接口
|
||||
|
||||
后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。
|
||||
|
||||
新增字段:
|
||||
```go
|
||||
// AssetResolveResponse 追加
|
||||
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"`
|
||||
```
|
||||
|
||||
### 2. 新增临期列表接口(独立页面)
|
||||
|
||||
```
|
||||
GET /admin/expiring-assets
|
||||
```
|
||||
|
||||
查询参数:
|
||||
```go
|
||||
type ExpiringAssetsRequest struct {
|
||||
AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"`
|
||||
AssetIdentifier string `query:"asset_identifier" description:"资产标识(ICCID/设备号)"`
|
||||
PackageName string `query:"package_name" description:"套餐名称"`
|
||||
ShopID *uint `query:"shop_id" description:"店铺ID"`
|
||||
ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"`
|
||||
ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"`
|
||||
MaxDaysUntilExpiry *int `query:"max_days_until_expiry" description:"最大剩余天数(如15)"`
|
||||
Page int `query:"page" default:"1"`
|
||||
PageSize int `query:"page_size" default:"20"`
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```go
|
||||
type ExpiringAssetItem struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetIdentifier string `json:"asset_identifier"`
|
||||
AssetStatus int `json:"asset_status"`
|
||||
ShopName string `json:"shop_name"`
|
||||
PackageName string `json:"package_name"`
|
||||
DaysUntilExpiry int `json:"days_until_expiry"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DataUsageMB int64 `json:"data_usage_mb"`
|
||||
RemainingDataMB int64 `json:"remaining_data_mb"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧)
|
||||
|
||||
```go
|
||||
// internal/task/expiry_reminder_handler.go
|
||||
|
||||
// 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 }
|
||||
|
||||
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 }
|
||||
|
||||
// 获取对应业务员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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 导出功能(临期列表)
|
||||
|
||||
```
|
||||
GET /admin/expiring-assets/export
|
||||
```
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 资产标识 | ICCID/设备号 |
|
||||
| 资产类型 | 卡/设备 |
|
||||
| 店铺名称 | |
|
||||
| 套餐名称 | |
|
||||
| 套餐到期时间 | |
|
||||
| 剩余天数 | |
|
||||
| 资产状态 | |
|
||||
| 已用流量(MB) | |
|
||||
| 剩余流量(MB) | |
|
||||
|
||||
---
|
||||
|
||||
## C端接口变更
|
||||
|
||||
### 公众号首页
|
||||
|
||||
现有接口(`GET /app/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段:
|
||||
|
||||
```go
|
||||
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||||
```
|
||||
|
||||
前端逻辑:`is_expiring=true` 时展示续费提醒模块:
|
||||
|
||||
```
|
||||
您的套餐即将到期
|
||||
|
||||
卡号/设备号:XXXX
|
||||
剩余有效期:XX 天
|
||||
|
||||
为避免到期后影响正常使用,请您提前完成续费。
|
||||
|
||||
[立即续费]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接(后台管理)
|
||||
|
||||
### 资产列表(IoT卡管理 / 设备管理)
|
||||
|
||||
1. 列表新增"剩余天数"列
|
||||
2. 根据 `days_until_expiry` 高亮行:
|
||||
- ≤ 15天:行背景粉红色
|
||||
- ≤ 7天:行背景紫色
|
||||
- ≤ 3天:行背景黄色
|
||||
3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天)
|
||||
- 选中后传 `expiring_within_days=15`
|
||||
|
||||
### 临期资产独立列表页
|
||||
|
||||
路由:`/expiring-assets`
|
||||
|
||||
```
|
||||
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
|
||||
|
||||
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
|
||||
|
||||
操作:导出按钮 → GET /admin/expiring-assets/export
|
||||
```
|
||||
|
||||
### 代理端首页
|
||||
|
||||
在首页数据接口新增字段(需要确认代理端首页接口):
|
||||
|
||||
```go
|
||||
type AgentDashboardResponse struct {
|
||||
// ...原有字段...
|
||||
ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"`
|
||||
ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"`
|
||||
}
|
||||
```
|
||||
|
||||
前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15`
|
||||
33
docs/批量购买套餐脚本/功能总结.md
Normal file
33
docs/批量购买套餐脚本/功能总结.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 批量购买套餐脚本功能总结
|
||||
|
||||
## 功能说明
|
||||
|
||||
新增 Python 运维脚本 `scripts/batch_package_purchase/batch_purchase.py`,用于读取单列 CSV,并逐资产调用后台 `POST /api/admin/orders` 接口购买同一个套餐。
|
||||
|
||||
## 输入与配置
|
||||
|
||||
- CSV 每行一个 ICCID 或虚拟号,首行表头可选。
|
||||
- `--base-url` 配置接口地址。
|
||||
- `--package-id` 指定本批次统一购买的套餐 ID。
|
||||
- 认证支持已有 Access Token,或使用后台账号自动登录获取 Token。
|
||||
- 支付方式固定为 `wallet`。
|
||||
|
||||
## 安全控制
|
||||
|
||||
- 默认仅预演,显式增加 `--execute` 后才会真实下单。
|
||||
- 下单前统一校验 CSV,重复资产会阻断整批执行。
|
||||
- 每个资产独立调用一次接口,结果逐条刷新到 CSV。
|
||||
- POST 请求不自动重试,避免响应丢失造成重复购买。
|
||||
- Token 和密码支持环境变量传入,不写入结果文件。
|
||||
|
||||
## 输出
|
||||
|
||||
结果 CSV 包含:
|
||||
|
||||
- CSV 原始行号和资产标识。
|
||||
- 成功或失败状态。
|
||||
- HTTP 状态码和业务错误码。
|
||||
- 接口消息。
|
||||
- 成功订单的订单 ID、订单号和订单金额。
|
||||
|
||||
脚本执行结束时,成功数量与输入总数一致则退出码为 `0`;存在失败或中途因认证失效停止时退出码为 `2`;参数、CSV 或登录错误时退出码为 `1`。
|
||||
182
docs/技术方案评审规范.md
Normal file
182
docs/技术方案评审规范.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 技术方案评审规范
|
||||
|
||||
> 适用范围:新功能、复杂业务改造、跨模块变更、基础设施建设和需要多人评审的技术方案。
|
||||
> 核心目标:让评审人能够判断“为什么这样做、是否能落地、失败后怎么办”,而不是仅看到表结构和代码清单。
|
||||
|
||||
---
|
||||
|
||||
## 一、方案分级
|
||||
|
||||
技术方案不要求统一篇幅,也不要求每个需求都强行画图。
|
||||
|
||||
| 等级 | 典型场景 | 最低要求 |
|
||||
|------|----------|----------|
|
||||
| 简化方案 | 单字段、单表 CRUD、局部筛选、纯展示修复 | 背景、改动范围、接口/字段、前端影响、兼容性、人工验收点 |
|
||||
| 标准方案 | 新接口、异步任务、跨表写入、配置化、第三方调用 | 简化方案全部内容 + 关键流程图 + 数据与异常处理 + 发布回滚 |
|
||||
| 完整方案 | 金额、审批、状态机、并发、跨模块事务、可靠事件、公共基础设施 | 标准方案全部内容 + 系统上下文图 + 状态图/时序图 + 前后端方案 + 风险与待决策项 |
|
||||
|
||||
以下场景即使代码量不大,也必须使用完整方案:
|
||||
|
||||
- 涉及资金、余额、信用额度、退款或充值。
|
||||
- 涉及审批、权限、审计或敏感数据导出。
|
||||
- 涉及异步任务、消息投递、最终一致性或第三方回调。
|
||||
- 修改公共基础设施或多个业务模块共同依赖的契约。
|
||||
|
||||
---
|
||||
|
||||
## 二、文档状态
|
||||
|
||||
需要进入评审的文档,开头必须标明状态:
|
||||
|
||||
```text
|
||||
状态:草稿 | 待评审 | 评审通过 | 已废弃
|
||||
负责人:姓名或角色
|
||||
评审人:后端、前端、产品、验收负责人、运维(按实际参与方)
|
||||
最后更新:YYYY-MM-DD
|
||||
关联需求:需求编号或 OpenSpec change
|
||||
```
|
||||
|
||||
状态含义:
|
||||
|
||||
- `草稿`:允许存在未收敛内容,不可直接进入开发。
|
||||
- `待评审`:方案作者认为信息已经完整,待评审人确认。
|
||||
- `评审通过`:关键决策和待办已有结论,可以进入实施。
|
||||
- `已废弃`:保留历史原因,并链接替代方案。
|
||||
|
||||
存在评审阻塞项时,禁止标记为“评审通过”。
|
||||
|
||||
---
|
||||
|
||||
## 三、必备内容
|
||||
|
||||
### 3.1 背景、目标与非目标
|
||||
|
||||
必须回答:
|
||||
|
||||
- 当前问题是什么,谁受到影响。
|
||||
- 本次要达成的业务结果和可观察结果是什么。
|
||||
- 本次明确不做什么,为什么不做。
|
||||
- 当前系统有哪些不能忽略的约束。
|
||||
|
||||
### 3.2 现状与改造范围
|
||||
|
||||
- 标出当前入口、主要调用链、现有表和已有接口。
|
||||
- 列出新增、修改、废弃的模块和接口。
|
||||
- 说明是否触发 DDD 迁移,以及选择复杂写、简单写或 Query 通道的依据。
|
||||
- 禁止只写理想结构而不说明与旧代码如何共存。
|
||||
|
||||
### 3.3 架构与关键流程
|
||||
|
||||
按问题选择图,不为凑数量画图:
|
||||
|
||||
| 图 | 适用问题 |
|
||||
|----|----------|
|
||||
| 系统上下文图 | 谁调用谁、系统边界和外部依赖是什么 |
|
||||
| 组件图 | Handler、Application、Domain、Repository、队列等如何协作 |
|
||||
| 流程图 | 条件分支、人工操作和业务步骤如何推进 |
|
||||
| 状态图 | 哪些状态允许转换,哪些是终态 |
|
||||
| 时序图 | 事务边界、异步事件、回调和失败重试的先后顺序 |
|
||||
| 数据关系图 | 新增多张表且关系仅靠文字难以理解 |
|
||||
|
||||
图使用 Mermaid,图下必须补充关键约束。图不能替代状态枚举、接口字段和异常规则。
|
||||
|
||||
### 3.4 后端技术方案
|
||||
|
||||
至少包含:
|
||||
|
||||
- 领域或模块边界,以及核心不变量归属。
|
||||
- 数据模型、索引、唯一约束、迁移和历史数据处理。
|
||||
- API 请求、响应、错误码、权限、分页和兼容策略。
|
||||
- 事务边界、并发控制、幂等策略和失败重试。
|
||||
- 异步任务的载荷、状态、重试、死信或人工补偿入口。
|
||||
- 查询性能、N+1 风险和必要的批量查询方案。
|
||||
- 审批类方案必须明确审批意见、附件、必填规则、不可修改审计和下载权限,不能只设计状态字段。
|
||||
|
||||
### 3.5 前端技术方案
|
||||
|
||||
不能只写“前端新增按钮”或“前端展示字段”。至少说明:
|
||||
|
||||
- 涉及哪些端:后台管理、代理端、企业端、C 端 H5/公众号。
|
||||
- 页面入口、建议路由、页面间跳转和权限控制。
|
||||
- API 模块、请求参数、响应状态和刷新策略。
|
||||
- 表单、列表、详情、弹框、上传和异步任务的交互状态。
|
||||
- 加载、空数据、重复提交、部分成功、失败、重试和过期状态。
|
||||
- 金额单位、时间格式、枚举显示和后端字段的唯一口径。
|
||||
- 滚动发布时说明新旧前后端兼容方式;停机发布时说明维护页、版本一致性和开放访问前检查。
|
||||
|
||||
当前仓库不包含前端源码时,方案可以保持框架无关,但必须标明需要前端仓库确认的实现映射,不能凭空指定状态库或组件库。
|
||||
|
||||
### 3.6 非功能设计
|
||||
|
||||
按实际风险覆盖:
|
||||
|
||||
- 权限和数据范围。
|
||||
- 敏感字段与导出控制。
|
||||
- 审计日志。
|
||||
- 性能目标和容量假设。
|
||||
- 日志、指标、告警和人工排查入口。
|
||||
- 外部依赖不可用时的降级策略。
|
||||
|
||||
### 3.7 发布、回滚与兼容
|
||||
|
||||
至少回答:
|
||||
|
||||
- 数据库、后端、Worker、前端和配置按什么顺序发布。
|
||||
- 是否需要先加字段、双写、回填、迁移存量进行中业务或兼容旧接口。
|
||||
- 如何关闭新入口或停用新流程。
|
||||
- 回滚时哪些数据不能删除,哪些状态需要人工处理。
|
||||
- 滚动发布时说明新旧版本并存风险;停机发布时说明如何阻止新写入、迁移存量任务并确认整套版本一致。
|
||||
|
||||
### 3.8 风险、待决策与验收
|
||||
|
||||
- 风险必须写触发条件、影响和应对方式。
|
||||
- 待决策项必须给出推荐方案、备选方案和最晚确认时间。
|
||||
- 验收使用本项目允许的人工方式:接口调用、PostgreSQL 数据核对、日志检查和页面操作。
|
||||
- 不得用“后续再看”掩盖会影响数据模型、接口契约或安全边界的问题。
|
||||
|
||||
---
|
||||
|
||||
## 四、评审检查清单
|
||||
|
||||
### 业务与范围
|
||||
|
||||
- [ ] 目标、非目标和验收结果明确。
|
||||
- [ ] 原始需求中的歧义已经决策或明确列为阻塞项。
|
||||
- [ ] Phase 1 与后续阶段边界清晰。
|
||||
|
||||
### 架构与数据
|
||||
|
||||
- [ ] 现状调用链和目标调用链都能解释。
|
||||
- [ ] DDD 使用有业务理由,没有为简单 CRUD 强行建模。
|
||||
- [ ] 状态、事务、并发、幂等和最终一致性闭环。
|
||||
- [ ] 审批意见、附件和业务凭证边界明确,历史操作不可被覆盖。
|
||||
- [ ] PostgreSQL DDL、索引和历史数据策略可执行。
|
||||
- [ ] 存量进行中业务有明确的回填、结束或人工处理方案,不会因旧入口下线而悬空。
|
||||
|
||||
### 前端与接口
|
||||
|
||||
- [ ] 前端页面、状态和异常分支完整。
|
||||
- [ ] API 路径使用项目真实前缀,字段和枚举一致。
|
||||
- [ ] 权限不依赖前端隐藏按钮。
|
||||
- [ ] 异步任务和审批完成后有明确刷新或轮询策略。
|
||||
|
||||
### 上线与运维
|
||||
|
||||
- [ ] 发布顺序、功能开关或停用方式明确。
|
||||
- [ ] 回滚不会删除历史审批、资金流水或审计记录。
|
||||
- [ ] 关键失败能够通过日志、任务状态或管理页面定位。
|
||||
|
||||
---
|
||||
|
||||
## 五、合格标准
|
||||
|
||||
“业内合格”不等于文档很长,而是评审人能基于文档回答以下问题:
|
||||
|
||||
1. 方案是否解决了正确的问题。
|
||||
2. 前后端和外部系统的边界是否一致。
|
||||
3. 正常路径、异常路径和并发路径是否闭环。
|
||||
4. 数据是否可追溯,资金和权限是否安全。
|
||||
5. 能否分阶段发布,失败后能否止损和恢复。
|
||||
6. 实施人员是否还需要自行猜测关键业务规则。
|
||||
|
||||
任一关键问题仍需实施人员自行猜测时,方案只能保持“待评审”,不能视为评审通过。
|
||||
88
scripts/batch_package_purchase/README.md
Normal file
88
scripts/batch_package_purchase/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 批量购买套餐脚本
|
||||
|
||||
该脚本读取只有一列的 CSV,逐条调用 `POST /api/admin/orders`,为一批资产购买同一个套餐。
|
||||
|
||||
脚本仅使用 Python 标准库,不需要安装依赖。默认只预演,必须增加 `--execute` 才会真实下单。
|
||||
|
||||
## CSV 格式
|
||||
|
||||
首行表头可选,每行填写一个 ICCID 或虚拟号:
|
||||
|
||||
```csv
|
||||
identifier
|
||||
89860000000000000001
|
||||
VIRTUAL000001
|
||||
```
|
||||
|
||||
脚本会在请求前检查空文件、多列和重复资产。发现重复资产时整批终止,避免同一资产被重复购买。
|
||||
|
||||
## 预演
|
||||
|
||||
```bash
|
||||
python3 scripts/batch_package_purchase/batch_purchase.py \
|
||||
--base-url https://cmp-api.example.com \
|
||||
--csv scripts/batch_package_purchase/assets.example.csv \
|
||||
--package-id 123
|
||||
```
|
||||
|
||||
预演只校验 CSV 并展示请求示例,不需要 Token,也不会调用接口。
|
||||
|
||||
## 使用已有 Token 执行
|
||||
|
||||
推荐通过环境变量传递 Token,避免进入命令历史:
|
||||
|
||||
```bash
|
||||
JUNHONG_ADMIN_TOKEN='<后台Access Token>' \
|
||||
python3 scripts/batch_package_purchase/batch_purchase.py \
|
||||
--base-url https://cmp-api.example.com \
|
||||
--csv /path/to/assets.csv \
|
||||
--package-id 123 \
|
||||
--execute
|
||||
```
|
||||
|
||||
## 使用账号自动登录后执行
|
||||
|
||||
未提供 Token 时,可以使用后台账号调用 `/api/admin/login` 自动获取 Token:
|
||||
|
||||
```bash
|
||||
JUNHONG_ADMIN_USERNAME='<后台账号>' \
|
||||
JUNHONG_ADMIN_PASSWORD='<后台密码>' \
|
||||
python3 scripts/batch_package_purchase/batch_purchase.py \
|
||||
--base-url https://cmp-api.example.com \
|
||||
--csv /path/to/assets.csv \
|
||||
--package-id 123 \
|
||||
--execute
|
||||
```
|
||||
|
||||
也可以使用 `--token`、`--username`、`--password` 参数。密码优先通过环境变量或交互输入,避免保存在 Shell 历史中。
|
||||
|
||||
## 结果文件
|
||||
|
||||
默认在输入 CSV 同目录生成:
|
||||
|
||||
```text
|
||||
原文件名_购买结果_YYYYMMDD_HHMMSS.csv
|
||||
```
|
||||
|
||||
结果包含资产标识、成功或失败状态、HTTP 状态码、业务错误码、错误消息、订单 ID、订单号和订单金额。每处理一条都会立即刷新文件,中途中断时已完成的结果不会丢失。
|
||||
|
||||
为避免覆盖历史结果,`--output` 指定的文件已经存在时脚本会直接报错。
|
||||
|
||||
可通过 `--output` 指定结果路径:
|
||||
|
||||
```bash
|
||||
python3 scripts/batch_package_purchase/batch_purchase.py \
|
||||
--base-url https://cmp-api.example.com \
|
||||
--csv /path/to/assets.csv \
|
||||
--package-id 123 \
|
||||
--output /path/to/result.csv \
|
||||
--execute
|
||||
```
|
||||
|
||||
## 其他参数
|
||||
|
||||
- `--timeout`:单次请求超时秒数,默认 `30`。
|
||||
- `--interval`:每次下单后的等待秒数,默认 `0.2`。
|
||||
- `--execute`:显式开启真实下单。
|
||||
|
||||
脚本固定使用 `wallet` 钱包支付,每个资产单独创建一个订单。脚本不会自动重试 POST 请求,避免服务端已成功但客户端未收到响应时重复下单。失败项可根据结果 CSV 人工确认后单独重跑。
|
||||
23
scripts/batch_package_purchase/assets.example.csv
Normal file
23
scripts/batch_package_purchase/assets.example.csv
Normal file
@@ -0,0 +1,23 @@
|
||||
identifier
|
||||
8986032445201075412,
|
||||
8986032445201075413,
|
||||
8986032445201075414,
|
||||
8986032445201075416,
|
||||
8986032445201075417,
|
||||
8986032445201075418,
|
||||
8986032445201075419,
|
||||
8986032445201075420,
|
||||
8986032445201075421,
|
||||
8986032445201075422,
|
||||
8986032445201075423,
|
||||
8986032445201075425,
|
||||
8986032445201075426,
|
||||
8986032445201075428,
|
||||
8986032445201075430,
|
||||
8986032445201075433,
|
||||
8986032445201075434,
|
||||
8986032445201075441,
|
||||
8986032445201075444,
|
||||
8986032445201075447,
|
||||
8986032445201075448,
|
||||
8986032445201075451
|
||||
|
492
scripts/batch_package_purchase/batch_purchase.py
Executable file
492
scripts/batch_package_purchase/batch_purchase.py
Executable file
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""批量购买套餐脚本:读取单列 CSV,逐资产调用后台订单接口购买同一套餐。
|
||||
|
||||
默认只执行预演。只有显式传入 --execute 时,才会调用 POST /api/admin/orders。
|
||||
脚本仅使用 Python 标准库,不需要安装第三方依赖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ORDER_PATH = "/api/admin/orders"
|
||||
LOGIN_PATH = "/api/admin/login"
|
||||
AUTH_ERROR_CODES = {1002, 1003, 1004}
|
||||
HEADER_NAMES = {
|
||||
"iccid",
|
||||
"virtual_no",
|
||||
"identifier",
|
||||
"资产标识",
|
||||
"虚拟号",
|
||||
"iccid/虚拟号",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetInput:
|
||||
"""保存 CSV 中的资产标识及原始行号。"""
|
||||
|
||||
line_no: int
|
||||
identifier: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HTTPResult:
|
||||
"""保存一次 HTTP 请求的响应信息。"""
|
||||
|
||||
status: int
|
||||
body: dict[str, Any] | None
|
||||
raw_body: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurchaseOutcome:
|
||||
"""保存单个资产的下单结果及结果文件行。"""
|
||||
|
||||
row: dict[str, object]
|
||||
success: bool
|
||||
http_status: int | None
|
||||
code: int | str | None
|
||||
message: str
|
||||
order_no: str
|
||||
|
||||
|
||||
class RequestFailedError(Exception):
|
||||
"""表示请求尚未获得可解析的 HTTP 响应。"""
|
||||
|
||||
|
||||
class AdminAPIClient:
|
||||
"""调用后台认证和订单接口的轻量客户端。"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
def login(self, username: str, password: str) -> str:
|
||||
"""使用后台账号登录并返回 Access Token。"""
|
||||
result = self._post_json(
|
||||
LOGIN_PATH,
|
||||
{"username": username, "password": password, "device": "web"},
|
||||
token=None,
|
||||
)
|
||||
code = response_code(result.body)
|
||||
if not is_success(result.status, code):
|
||||
raise RequestFailedError(
|
||||
f"登录失败:HTTP {result.status},code={display_value(code)},"
|
||||
f"msg={response_message(result.body, result.raw_body)}"
|
||||
)
|
||||
|
||||
data = result.body.get("data") if result.body else None
|
||||
token = data.get("access_token") if isinstance(data, dict) else None
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise RequestFailedError("登录响应中缺少 data.access_token")
|
||||
return token.strip()
|
||||
|
||||
def create_order(self, token: str, identifier: str, package_id: int) -> HTTPResult:
|
||||
"""为单个资产创建钱包套餐订单。"""
|
||||
return self._post_json(
|
||||
ORDER_PATH,
|
||||
{
|
||||
"identifier": identifier,
|
||||
"package_ids": [package_id],
|
||||
"payment_method": "wallet",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
def _post_json(self, path: str, payload: dict[str, Any], token: str | None) -> HTTPResult:
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "junhong-batch-package-purchase/1.0",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
request = Request(
|
||||
url=self.base_url + path,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
raw_body = response.read().decode("utf-8", errors="replace")
|
||||
return HTTPResult(
|
||||
status=response.status,
|
||||
body=parse_json_object(raw_body),
|
||||
raw_body=raw_body,
|
||||
)
|
||||
except HTTPError as exc:
|
||||
raw_body = exc.read().decode("utf-8", errors="replace")
|
||||
return HTTPResult(
|
||||
status=exc.code,
|
||||
body=parse_json_object(raw_body),
|
||||
raw_body=raw_body,
|
||||
)
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
raise RequestFailedError(f"请求失败:{exc}") from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""解析命令行参数。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="读取单列 CSV,逐资产调用 /api/admin/orders 购买同一套餐",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""),
|
||||
help="接口 Base URL,例如 https://cmp-api.example.com;也可使用 JUNHONG_ADMIN_BASE_URL",
|
||||
)
|
||||
parser.add_argument("--csv", required=True, help="单列资产 CSV 文件路径,首行可有表头")
|
||||
parser.add_argument("--package-id", required=True, type=int, help="本批资产统一购买的套餐 ID")
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.getenv("JUNHONG_ADMIN_TOKEN", ""),
|
||||
help="后台 Access Token;也可使用 JUNHONG_ADMIN_TOKEN",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
default=os.getenv("JUNHONG_ADMIN_USERNAME", ""),
|
||||
help="未提供 Token 时用于自动登录;也可使用 JUNHONG_ADMIN_USERNAME",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""),
|
||||
help="后台登录密码;建议使用 JUNHONG_ADMIN_PASSWORD,避免进入命令历史",
|
||||
)
|
||||
parser.add_argument("--output", default="", help="结果 CSV 路径;默认输出到输入文件同目录")
|
||||
parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时秒数(默认 30)")
|
||||
parser.add_argument("--interval", type=float, default=0.2, help="每次下单后的间隔秒数(默认 0.2)")
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="真实调用接口下单;不传时只校验 CSV 并预览请求",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_assets(csv_path: Path) -> list[AssetInput]:
|
||||
"""读取单列 CSV,识别可选表头,并在下单前拦截重复资产。"""
|
||||
if not csv_path.exists():
|
||||
raise ValueError(f"找不到 CSV 文件:{csv_path}")
|
||||
if not csv_path.is_file():
|
||||
raise ValueError(f"CSV 路径不是文件:{csv_path}")
|
||||
|
||||
assets: list[AssetInput] = []
|
||||
first_line_by_identifier: dict[str, int] = {}
|
||||
errors: list[str] = []
|
||||
first_nonempty_seen = False
|
||||
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
|
||||
reader = csv.reader(file)
|
||||
for line_no, row in enumerate(reader, start=1):
|
||||
values = [value.strip() for value in row if value.strip()]
|
||||
if not values:
|
||||
continue
|
||||
if len(values) != 1:
|
||||
errors.append(f"第 {line_no} 行必须只有一列,实际读取到 {len(values)} 个非空值")
|
||||
continue
|
||||
|
||||
identifier = values[0]
|
||||
if not first_nonempty_seen:
|
||||
first_nonempty_seen = True
|
||||
if identifier.lower() in HEADER_NAMES:
|
||||
continue
|
||||
|
||||
if identifier in first_line_by_identifier:
|
||||
errors.append(
|
||||
f"第 {line_no} 行与第 {first_line_by_identifier[identifier]} 行重复:{identifier}"
|
||||
)
|
||||
continue
|
||||
|
||||
first_line_by_identifier[identifier] = line_no
|
||||
assets.append(AssetInput(line_no=line_no, identifier=identifier))
|
||||
|
||||
if errors:
|
||||
preview = "\n".join(f" - {error}" for error in errors[:20])
|
||||
if len(errors) > 20:
|
||||
preview += f"\n - 其余 {len(errors) - 20} 个错误已省略"
|
||||
raise ValueError(f"CSV 校验失败,请修正后重试:\n{preview}")
|
||||
if not assets:
|
||||
raise ValueError("CSV 中没有有效的 ICCID 或虚拟号")
|
||||
return assets
|
||||
|
||||
|
||||
def parse_json_object(raw_body: str) -> dict[str, Any] | None:
|
||||
"""尝试把响应正文解析为 JSON 对象。"""
|
||||
if not raw_body.strip():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def response_code(body: dict[str, Any] | None) -> int | str | None:
|
||||
"""读取统一响应中的业务错误码。"""
|
||||
if not body:
|
||||
return None
|
||||
code = body.get("code")
|
||||
if isinstance(code, bool):
|
||||
return int(code)
|
||||
if isinstance(code, int):
|
||||
return code
|
||||
if isinstance(code, str):
|
||||
stripped = code.strip()
|
||||
return int(stripped) if stripped.isdigit() else stripped
|
||||
return None
|
||||
|
||||
|
||||
def response_message(body: dict[str, Any] | None, raw_body: str) -> str:
|
||||
"""读取统一响应消息,非 JSON 响应则保留截断后的正文。"""
|
||||
if body:
|
||||
message = body.get("msg", body.get("message", ""))
|
||||
if message is not None and str(message).strip():
|
||||
return str(message).strip()
|
||||
text = raw_body.strip().replace("\r", " ").replace("\n", " ")
|
||||
return text[:500] if text else "接口未返回错误信息"
|
||||
|
||||
|
||||
def is_success(http_status: int, code: int | str | None) -> bool:
|
||||
"""同时校验 HTTP 状态码和业务响应码。"""
|
||||
return 200 <= http_status < 300 and str(code) == "0"
|
||||
|
||||
|
||||
def display_value(value: object) -> str:
|
||||
"""把可能为空的字段转为适合日志和 CSV 的文本。"""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def resolve_output_path(input_path: Path, output_arg: str) -> Path:
|
||||
"""生成本批次的结果文件路径。"""
|
||||
if output_arg.strip():
|
||||
return Path(output_arg).expanduser().resolve()
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return input_path.with_name(f"{input_path.stem}_购买结果_{timestamp}.csv")
|
||||
|
||||
|
||||
def resolve_token(args: argparse.Namespace, client: AdminAPIClient) -> str:
|
||||
"""优先使用现有 Token,否则使用后台账号自动登录。"""
|
||||
token = args.token.strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
username = args.username.strip()
|
||||
if not username:
|
||||
raise ValueError(
|
||||
"真实执行需要 --token,或同时提供 --username/--password;"
|
||||
"也可通过 JUNHONG_ADMIN_TOKEN 等环境变量配置"
|
||||
)
|
||||
|
||||
password = args.password
|
||||
if not password and sys.stdin.isatty():
|
||||
password = getpass("请输入后台登录密码:")
|
||||
if not password:
|
||||
raise ValueError("使用账号登录时必须提供密码")
|
||||
|
||||
print(f"正在使用后台账号 {username!r} 获取 Access Token...")
|
||||
return client.login(username, password)
|
||||
|
||||
|
||||
def preview(assets: list[AssetInput], base_url: str, package_id: int) -> int:
|
||||
"""输出预演信息,不发送任何 HTTP 请求。"""
|
||||
print("预演完成:未发送任何 HTTP 请求。")
|
||||
print(f"接口地址:{base_url.rstrip('/')}{ORDER_PATH}")
|
||||
print(f"资产数量:{len(assets)}")
|
||||
print(f"套餐 ID:{package_id}")
|
||||
print("支付方式:wallet")
|
||||
print("请求示例:")
|
||||
for asset in assets[:5]:
|
||||
payload = {
|
||||
"identifier": asset.identifier,
|
||||
"package_ids": [package_id],
|
||||
"payment_method": "wallet",
|
||||
}
|
||||
print(f" 第 {asset.line_no} 行:{json.dumps(payload, ensure_ascii=False)}")
|
||||
if len(assets) > 5:
|
||||
print(f" 其余 {len(assets) - 5} 条已省略")
|
||||
print("确认参数无误后,增加 --execute 才会真实购买。")
|
||||
return 0
|
||||
|
||||
|
||||
def purchase_asset(
|
||||
client: AdminAPIClient,
|
||||
token: str,
|
||||
asset: AssetInput,
|
||||
package_id: int,
|
||||
) -> PurchaseOutcome:
|
||||
"""调用一次订单接口,并转换为统一的结果记录。"""
|
||||
try:
|
||||
result = client.create_order(token, asset.identifier, package_id)
|
||||
code = response_code(result.body)
|
||||
message = response_message(result.body, result.raw_body)
|
||||
data = result.body.get("data") if result.body else None
|
||||
order_data = data if isinstance(data, dict) else {}
|
||||
success = is_success(result.status, code)
|
||||
order_no = display_value(order_data.get("order_no"))
|
||||
return PurchaseOutcome(
|
||||
row={
|
||||
"line_no": asset.line_no,
|
||||
"identifier": asset.identifier,
|
||||
"status": "成功" if success else "失败",
|
||||
"http_status": result.status,
|
||||
"code": display_value(code),
|
||||
"msg": message,
|
||||
"order_id": display_value(order_data.get("id")),
|
||||
"order_no": order_no,
|
||||
"total_amount": display_value(order_data.get("total_amount")),
|
||||
},
|
||||
success=success,
|
||||
http_status=result.status,
|
||||
code=code,
|
||||
message=message,
|
||||
order_no=order_no,
|
||||
)
|
||||
except RequestFailedError as exc:
|
||||
message = str(exc)
|
||||
return PurchaseOutcome(
|
||||
row={
|
||||
"line_no": asset.line_no,
|
||||
"identifier": asset.identifier,
|
||||
"status": "失败",
|
||||
"http_status": "",
|
||||
"code": "",
|
||||
"msg": message,
|
||||
"order_id": "",
|
||||
"order_no": "",
|
||||
"total_amount": "",
|
||||
},
|
||||
success=False,
|
||||
http_status=None,
|
||||
code=None,
|
||||
message=message,
|
||||
order_no="",
|
||||
)
|
||||
|
||||
|
||||
def execute(
|
||||
assets: list[AssetInput],
|
||||
client: AdminAPIClient,
|
||||
token: str,
|
||||
package_id: int,
|
||||
output_path: Path,
|
||||
interval: float,
|
||||
) -> int:
|
||||
"""顺序执行下单,并把每条结果立即写入 CSV。"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
total = len(assets)
|
||||
|
||||
with output_path.open("w", encoding="utf-8-sig", newline="") as file:
|
||||
writer = csv.DictWriter(
|
||||
file,
|
||||
fieldnames=[
|
||||
"line_no",
|
||||
"identifier",
|
||||
"status",
|
||||
"http_status",
|
||||
"code",
|
||||
"msg",
|
||||
"order_id",
|
||||
"order_no",
|
||||
"total_amount",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
file.flush()
|
||||
|
||||
for index, asset in enumerate(assets, start=1):
|
||||
outcome = purchase_asset(client, token, asset, package_id)
|
||||
writer.writerow(outcome.row)
|
||||
if outcome.success:
|
||||
success_count += 1
|
||||
print(f"[{index}/{total}] 成功:{asset.identifier},订单号={outcome.order_no}")
|
||||
else:
|
||||
failed_count += 1
|
||||
print(
|
||||
f"[{index}/{total}] 失败:{asset.identifier},"
|
||||
f"HTTP={display_value(outcome.http_status)},"
|
||||
f"code={display_value(outcome.code)},msg={outcome.message}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
file.flush()
|
||||
|
||||
if outcome.http_status == 401 or outcome.code in AUTH_ERROR_CODES:
|
||||
print("认证已失效,停止后续下单;已处理结果已保存。", file=sys.stderr)
|
||||
break
|
||||
if interval > 0 and index < total:
|
||||
time.sleep(interval)
|
||||
|
||||
print()
|
||||
print(f"执行结束:成功 {success_count} 条,失败 {failed_count} 条。")
|
||||
print(f"结果文件:{output_path}")
|
||||
return 0 if failed_count == 0 and success_count == total else 2
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""校验参数,执行预演或真实批量下单。"""
|
||||
args = parse_args()
|
||||
try:
|
||||
base_url = args.base_url.strip()
|
||||
if not base_url:
|
||||
raise ValueError("必须通过 --base-url 或 JUNHONG_ADMIN_BASE_URL 配置接口地址")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
raise ValueError("base-url 必须以 http:// 或 https:// 开头")
|
||||
if args.package_id <= 0:
|
||||
raise ValueError("package-id 必须大于 0")
|
||||
if args.timeout <= 0:
|
||||
raise ValueError("timeout 必须大于 0")
|
||||
if args.interval < 0:
|
||||
raise ValueError("interval 不能小于 0")
|
||||
|
||||
input_path = Path(args.csv).expanduser().resolve()
|
||||
assets = load_assets(input_path)
|
||||
if not args.execute:
|
||||
return preview(assets, base_url, args.package_id)
|
||||
|
||||
output_path = resolve_output_path(input_path, args.output)
|
||||
if output_path == input_path:
|
||||
raise ValueError("结果文件不能与输入 CSV 使用同一路径")
|
||||
if output_path.exists():
|
||||
raise ValueError(f"结果文件已存在,请更换 --output 路径:{output_path}")
|
||||
client = AdminAPIClient(base_url, args.timeout)
|
||||
token = resolve_token(args, client)
|
||||
|
||||
print(f"即将真实下单:资产 {len(assets)} 个,套餐 ID={args.package_id},支付方式=wallet")
|
||||
print(f"接口地址:{base_url.rstrip('/')}{ORDER_PATH}")
|
||||
print(f"结果文件:{output_path}")
|
||||
return execute(
|
||||
assets=assets,
|
||||
client=client,
|
||||
token=token,
|
||||
package_id=args.package_id,
|
||||
output_path=output_path,
|
||||
interval=args.interval,
|
||||
)
|
||||
except (ValueError, RequestFailedError) as exc:
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户中断执行;已写入的结果会保留。", file=sys.stderr)
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user