diff --git a/.gitignore b/.gitignore index 3b636ac..fcdf89b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 03b045f..1dfbad6 100644 --- a/AGENTS.md +++ b/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/`;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 日志 diff --git a/CLAUDE.md b/CLAUDE.md index 82605c3..4c586e9 100644 --- a/CLAUDE.md +++ b/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// ### Domain docs -单 Context 布局——根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有的 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`。 +单 Context 布局:根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`。 diff --git a/README.md b/README.md index 2d19ddb..87800ba 100644 --- a/README.md +++ b/README.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) diff --git a/docs/7月迭代/00-总览.md b/docs/7月迭代/00-总览.md index 2cfb988..de6763b 100644 --- a/docs/7月迭代/00-总览.md +++ b/docs/7月迭代/00-总览.md @@ -1,91 +1,254 @@ # 7月迭代技术方案总览 -> 讨论日期:2026-07-11 -> 分支:Iteration/7-11 -> 系统:junhong_cmp_fiber(已上线,迭代阶段) +> 状态:待评审 +> 负责人:待指定 +> 评审人:后端、前端、产品、验收负责人、运维待指定 +> 讨论日期:2026-07-11 +> 最后更新:2026-07-14 +> 分支:Iteration/7-11 +> 系统:junhong_cmp_fiber(已上线,渐进迭代) --- -## 阶段划分 +## 一、评审结论 + +当前方案的技术方向没有跑偏:渐进式 DDD、通用审批流、Outbox、异步任务复用和查询侧独立都符合当前系统约束。 + +此前版本缺少流程图、前端方案、发布方案和若干关键业务决策。本轮已经补齐这些评审材料,并确认以下口径: + +- 平台员工不建立信用额度;信用额度只属于代理主钱包。 +- 批量订购的支付方式按整批统一,Excel 不携带支付方式。 +- 角色级导出字段配置属于本期必做能力。 +- Gateway 限速只面向卡号,设备场景先解析当前卡再调用 `cardNo`;本期仅人工设置/取消,不做套餐自动限速。 +- 需求 16“代理分销码与佣金提现”已移出 7 月迭代,后续独立立项。 +- 审批结论与退款、充值等业务处理结果分别建模和展示。 +- 审批详情必须展示业务快照、业务资料、历史意见和审批附件;业务资料与审批附件分开存储和展示。 +- 非代理钱包退款由财务人工处理;代理钱包退款仅回退原扣款代理主钱包。 +- 本次采用停机发布,不保留旧审批接口兼容窗口。 + +本目录按 [技术方案评审规范](../技术方案评审规范.md) 整理。当前范围为 21 项实施需求,需求 16 仅保留移出记录。评审建议按“基础设施 → 资金与审批 → 批量任务 → 展示类需求”分组。 + +--- + +## 二、评审材料导航 + +| 材料 | 作用 | +|------|------| +| [DDD 规范](./DDD规范.md) | 判断复杂写、简单写和 Query 通道,以及旧模块如何渐进迁移 | +| [前端技术方案](./前端技术方案.md) | 跨需求的页面、状态、轮询、权限和停机发布约定 | +| [审批流](./基础设施/审批流.md) | 流程定义、实例、任务、状态机、并发、事件和业务接入 | +| [站内消息](./基础设施/站内消息.md) | 审批和临期提醒的站内通知基础设施 | +| [系统配置](./基础设施/系统配置.md) | C 端支付方式的受控动态配置 | +| 各需求专项文档 | 业务规则、接口、数据改动和专项前端差异 | + +--- + +## 三、系统上下文 + +```mermaid +flowchart LR + Admin[后台管理端] --> API[Junhong API] + Client[C端 H5/公众号] --> API + API --> PG[(PostgreSQL)] + API --> Redis[(Redis)] + API --> Gateway[Gateway] + API --> Outbox[(Outbox)] + Relay[Outbox Relay] --> Outbox + Relay --> Asynq[Asynq] + Asynq --> Worker[Worker] + Worker --> Notification[站内消息] + Worker --> Business[退款/充值/临期等业务处理器] + Worker -. Phase 2 .-> WeCom[企业微信] +``` + +关键边界: + +- API 负责同步校验、事务提交和返回可追踪的业务状态。 +- Worker 负责通知、导出、批量任务和审批后的业务处理。 +- Gateway 是本迭代唯一已确认可以直接接入的外部业务系统。 +- 企业微信相关能力属于 Phase 2,不得阻塞 Phase 1 的站内审批闭环。 + +--- + +## 四、依赖关系 + +```mermaid +flowchart TD + DDD[渐进式 DDD 规范] + Config[系统配置] + Notice[站内消息] + Approval[通用审批流] + Export[统一导出任务] + + DDD --> Approval + Notice --> Approval + Approval --> R18[需求18 多人审批] + Approval --> R20[需求20 退款审批] + Approval --> R21[需求21 充值审批] + Config --> R09[需求09 支付限制] + Notice --> R22[需求22 临期提醒] + Export --> R14[需求14 导出] + Approval --> R14 +``` + +--- + +## 五、阶段划分 | 阶段 | 范围 | 说明 | |------|------|------| -| **Phase 1(本迭代)** | 需求 1-22(除企微对接) | Gateway 接口已有,全部可做 | -| **Phase 2(下次迭代)** | 需求 18(APR-009)/22(EXP-003)的企微对接部分 | 仅依赖企业微信审批/推送 API | +| Phase 1 | 需求 1-22 中除需求16外的本系统和 Gateway 能力 | 必须形成站内闭环、人工可追踪和可回滚的后台流程 | +| Phase 2 | 需求 18 APR-009、需求 22 EXP-003 | 企业微信审批/推送与状态同步,单独评审外部 API、安全和补偿策略 | + +Phase 1 不建设 BPMN 全规范、拖拽流程设计器、任意回退、子流程和复杂规则引擎。 --- -## 需求总览 +## 六、需求总览 -### 简单改动(无新表,影响范围小) +### 6.1 简单改动 | # | 需求 | 文档 | 实现要点 | -|---|------|------|---------| -| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | `ManualStartCard` 换用 `isRealnameOK()` | -| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-店铺搜索电话.md) | Shop Store 加 contact_phone 过滤 | -| 4 | 退款中资产禁止换货 | [需求04](./需求04-退款拦截换货.md) | 换货前检查活跃退款单 | -| 7 | IoT卡/设备加实名筛选 | [需求07](./需求07-实名筛选.md) | 列表接口加 real_name_status filter | -| 11 | 资产详情-套餐到期时间高亮 | [需求11](./需求11-套餐到期字段高亮.md) | 接口新增字段 + 前端高亮 | -| 12 | 换货管理显示问题修复 | [需求12](./需求12-换货管理修复.md) | EXC-001~004,字段显示对齐 | -| 13 | 列表字段新增 | [需求13](./需求13-列表字段新增.md) | 退款/代理充值/换号管理加提交人审批人 | +|---|------|------|----------| +| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | 复机规则修正,无新状态 | +| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-07-11-12-13-简单改动.md#需求03店铺列表搜索新增联系电话) | Query 增加过滤条件 | +| 4 | 退款中资产禁止换货 | [需求04](./需求04-06-退款拦截与最后到期时间.md#需求04退款中禁止换货) | 创建换货前校验活跃退款 | +| 7 | IoT卡/设备加实名筛选 | [需求07](./需求03-07-11-12-13-简单改动.md#需求07iot卡设备管理新增已实名未实名筛选) | 列表筛选和索引评估 | +| 11 | 当前套餐到期高亮 | [需求11](./需求03-07-11-12-13-简单改动.md#需求11资产详情-套餐到期时间字段--15天高亮) | 复用后端日期字段,前端统一颜色规则 | +| 12 | 换货显示修复 | [需求12](./需求03-07-11-12-13-简单改动.md#需求12换货管理显示修复) | 字段和检索口径对齐 | +| 13 | 列表字段新增 | [需求13](./需求03-07-11-12-13-简单改动.md#需求13列表字段新增) | 审批人改为动态摘要 | -### 中等复杂(有新字段或配置机制) +简单改动不要求单独绘制系统图;涉及条件分支时用短流程图或规则表即可。 -| # | 需求 | 文档 | 实现要点 | -|---|------|------|---------| -| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 依赖[系统配置](./基础设施/系统配置.md) | -| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | ShopPackageAllocation 加 expiry_base_override | -| 6 | 资产最后到期时间 | [需求06](./需求06-资产最后到期时间.md) | 查 PackageUsage 计算 max(expires_at) | -| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 参考现有 Excel 导入体系 | -| 9 | C端支付方式限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 恢复已注释代码 + 系统配置 | -| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | Gateway `SetSpeedLimit` 已有,套餐加限速字段 | -| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 6 个模块导出,复用 ExportTask 体系 | -| 15 | 下架套餐允许续费 | [需求15](./需求15-套餐下架续费.md) | ShelfStatus 判断 + 续费入口 | +### 6.2 标准方案 -### 复杂新模块(DDD 结构) +| # | 需求 | 文档 | 关键评审点 | +|---|------|------|------------| +| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 资产视角、配置优先级、C端跳转 | +| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | 覆盖值到使用记录的快照链 | +| 6 | 资产最后到期时间 | [需求06](./需求04-06-退款拦截与最后到期时间.md#需求06资产详情-所有套餐的最后到期时间) | Query 聚合与排队套餐口径 | +| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 异步任务、部分成功、失败明细 | +| 9 | C端支付限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 前端隐藏与后端强校验一致 | +| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | 手动卡限速、`kbps` 单位和 Gateway 审计 | +| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 复用 ExportTask、动态字段权限 | +| 15 | 下架套餐允许续费 | [需求15](./需求15-16-18-19-20-21-复杂需求.md#需求15套餐下架后允许续费) | 续费身份和历史使用判断 | +| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 15/7/3 天规则、去重、三端展示 | -| # | 需求 | 文档 | 实现要点 | -|---|------|------|---------| -| 16 | 代理分销码与佣金提现 | [需求16](./需求16-代理分销码佣金提现.md) | 二维码注册 + 发展人体系 | -| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | Shop/角色信用字段 + 负余额支持 | -| 18 | 多人审批 | [需求18](./需求18-多人审批.md) | 依赖[审批流](./基础设施/审批流.md) + [站内消息](./基础设施/站内消息.md) | -| 19 | 批量订购套餐 | [需求19](./需求19-批量订购.md) | Excel 导入 + 代理余额扣款 | -| 20 | 退款审批 | [需求20](./需求20-退款审批.md) | 依赖审批流 | -| 21 | 充值审核流程 | [需求21](./需求21-充值审核流程.md) | 依赖审批流 | -| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 定时任务 + 站内消息 | +### 6.3 完整方案 + +| # | 需求 | 文档 | 关键评审点 | +|---|------|------|------------| +| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | 钱包不变量、主体模型、角色权限 | +| 18 | 多人审批 | [需求18](./需求15-16-18-19-20-21-复杂需求.md#需求18多人审批apr-001009) | 动态审批人、串行节点、通知 | +| 19 | 批量订购套餐 | [需求19](./需求15-16-18-19-20-21-复杂需求.md#需求19批量订购套餐bpo-001008) | 逐行审计、钱包幂等、部分成功 | +| 20 | 退款审批 | [需求20](./需求15-16-18-19-20-21-复杂需求.md#需求20退款审批) | 审批与实际退款的最终一致性 | +| 21 | 充值审批 | [需求21](./需求15-16-18-19-20-21-复杂需求.md#需求21充值审核流程) | 审批与钱包入账分离、停机切换旧接口 | + +### 6.4 已移出本期 + +| # | 需求 | 状态 | 后续处理 | +|---|------|------|----------| +| 16 | 代理分销码与佣金提现 | 2026-07-14 移出 7 月迭代 | 独立立项,重新评审范围、技术方案和工时 | --- -## 基础设施文档 +## 七、跨需求技术决策 -| 文档 | 说明 | 被哪些需求依赖 | -|------|------|--------------| -| [DDD规范](./DDD规范.md) | 领域模型设计规范、目录结构、代码示例 | 复杂新模块全部 | -| [系统配置](./基础设施/系统配置.md) | tb_system_config + 管理接口 | 需求 2、9 | -| [站内消息](./基础设施/站内消息.md) | tb_notification + Asynq 异步分发 | 需求 18、20、21、22 | -| [审批流](./基础设施/审批流.md) | 通用多级审批引擎 | 需求 18、20、21 | +- 架构迁移按完整用例触碰式进行,禁止一次性重构旧模块。 +- 管理端 API 使用 `/api/admin/*`,C 端 API 使用 `/api/c/v1/*`。 +- 复杂查询进入 Query 模块,不通过聚合根拼装报表或导出。 +- 审批人只支持角色或指定账号,当前系统不引入部门模型。 +- 审批完成事件使用 Outbox + Asynq,通知和业务处理均按至少一次投递设计。 +- 导入、批量购买和导出必须有服务端任务状态,前端轮询只是展示手段。 +- 资金、审批、导出和敏感配置必须记录操作人和审计信息。 +- 信用额度只属于代理主钱包;平台员工是操作主体,不是结算和负债主体。 +- 批量订购支付方式整批统一;线下支付按整批上传凭证,代理钱包按整批选择代理钱包。 +- 角色管理必须提供导出字段配置,导出请求字段与角色授权字段取交集。 +- 限速最终目标始终是卡,Gateway 请求只传 `cardNo`;设备入口必须解析当前绑定卡,本期仅手动设置或取消。 +- 退款和员工线下充值统一通过任务级审批接口处理。 +- 审批状态与实际退款、钱包入账等处理状态分别返回;审批详情返回业务快照、业务资料和审批时间线。 +- 本次停机发布并同时切换 API、Worker 和前端,不建设旧审批接口兼容门面。 --- -## 执行顺序建议 +## 八、已确认技术决策 +| 编号 | 已确认结论 | 影响需求 | +|------|------------|----------| +| D-01 | 不实现平台员工信用额度,只保留代理主钱包授信 | 17、19 | +| D-02 | 角色级导出字段配置本期落地,服务端执行字段授权 | 14 | +| D-03 | 批量订购支付方式整批统一,不允许 Excel 行级混合支付 | 19 | +| D-04 | Gateway 限速统一按 `cardNo`;设备套餐取 `tb_device_sim_binding.is_current=true` 对应卡 | 10 | +| D-05 | 审批结论和实际业务处理状态分离 | 20、21 | +| D-06 | 采用停机发布,旧退款审批和线下充值直接入账/驳回接口同步下线 | 20、21 | +| D-07 | 需求16移出本期,不创建相关表、接口、流程和前端页面 | 16 | +| D-08 | 第三方退款本期由财务人工完成;代理钱包仅回退原扣款代理主钱包,客户资产钱包不走自动回退 | 20 | +| D-09 | 设备批量分配代理与套餐系列拆为两个命令和两个前端入口 | 8 | +| D-10 | 当前套餐与排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只做通知 | 06、22 | + +前端源码不在当前仓库。接口评审后仍需在前端仓库完成路由、权限指令、上传组件、任务轮询组件和动态表格能力的实现映射,但这不再是业务方案决策项。 + +--- + +## 九、发布与回滚总策略 + +```mermaid +flowchart LR + S[进入维护模式并停止写入] --> M[执行数据库迁移] + M --> D[同时发布 API、Worker 和前端] + D --> C[初始化流程定义和业务绑定] + C --> L[回填存量待审批单] + L --> V[人工验证核心链路] + V --> O[开放系统访问] ``` -第一周:基础设施 - 1. tb_system_config + 管理接口 - 2. tb_notification + 任务 handler - 3. 通用审批流引擎 -第二周:简单改动批量完成 - 4. 需求 1/3/4/7/11/12/13(均可并行) +发布原则: -第三周:中等复杂 - 5. 需求 2/5/6/8/9(依赖基础设施) - 6. 需求 14/15 +1. 发布前进入维护模式,停止创建订单、退款、充值和审批操作。 +2. 维护窗口内执行数据库迁移,并同时发布 API、Worker/Relay 和前端静态资源。 +3. 初始化并发布 `refund_approval`、`recharge_approval`,完成业务绑定后再验证。 +4. 使用一次性 Application 命令为存量待审批退款和员工线下充值创建流程实例;历史终态记录保持只读,不伪造审批时间线。 +5. 旧退款业务单审批接口和线下充值 `offline-pay/reject` 在新版本中不再注册,不保留兼容门面。 +6. 人工验证发起审批、存量审批、任务处理、业务回调、通知和查询后再开放访问。 +7. 开放前验证失败可回滚应用版本和可逆迁移;已经形成的审批历史、资金流水、Outbox 和审计日志不得通过清表回滚。 +8. Worker 暂停时保留 Outbox 事件,恢复后继续消费。 -第四周:复杂新模块 - 7. 需求 17(信用额度) - 8. 需求 16(分销码) - 9. 需求 18/20/21(审批流对接) - 10. 需求 19(批量订购) - 11. 需求 22(临期提醒) +--- + +## 十、可观测性与人工验收 + +关键日志和查询维度: + +- `request_id`、`event_id`、`task_id`、`process_instance_id`、`biz_type`、`biz_id`。 +- Outbox 待投递数量、失败次数和最早积压时间。 +- 审批业务处理失败、批量任务失败和导出任务失败。 +- 钱包扣款前后余额、版本冲突和业务幂等键。 + +人工验收至少覆盖: + +- 正常路径、无权限、重复点击、并发审批、退回重提。 +- Worker 暂停后恢复,事件和任务可继续处理。 +- 维护模式下不能产生新写入;开放前确认 API、Worker 和前端版本一致。 +- 所有仍需审批的存量退款和员工线下充值均已生成流程实例,历史终态记录只读可查。 +- 旧退款审批和线下充值 `offline-pay/reject` 路由不可访问。 +- PostgreSQL 中业务状态、审批状态、任务状态、操作日志和资金流水一致。 + +--- + +## 十一、执行顺序建议 + +```text +评审批次 A:DDD 边界、系统配置、站内消息、审批流 +评审批次 B:退款、充值、信用额度、批量订购、导出权限 +评审批次 C:H5 流程、限速、临期提醒、批量分配 +评审批次 D:简单字段、筛选和显示修复 + +实施顺序: +1. 增量迁移、TxManager/Outbox、系统配置和站内消息 +2. 审批定义、实例、任务和前端待办 +3. 退款和充值接入通用审批 +4. 批量任务、代理信用额度和导出权限 +5. 其他中小需求 ``` + +每个评审批次独立形成结论,未通过的复杂需求不阻塞已经闭环的简单需求实施。 diff --git a/docs/7月迭代/DDD规范.md b/docs/7月迭代/DDD规范.md index a7b1b15..71c5061 100644 --- a/docs/7月迭代/DDD规范.md +++ b/docs/7月迭代/DDD规范.md @@ -1,6 +1,7 @@ # 项目 DDD 设计规范 -> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。 +> 状态:待评审 +> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。 > 基准文档:`docs/改造方向.md`(绞杀者模式) --- @@ -17,17 +18,71 @@ 不是重写,是**渐进替换**: -1. **新功能按新结构写**,不往旧 Service 加代码 -2. **旧功能迁移**:每次迁移一个用例,跑通后再下一个 -3. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置 +1. **复杂写功能**:进入 Application + Domain,不继续堆入旧 Service +2. **简单写功能**:使用 Application 事务脚本,不强行创建聚合 +3. **读取功能**:进入 Query 读取模型,不通过聚合根 +4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写 +5. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置 --- -## 二、目录结构 +## 二、什么时候进入 DDD + +DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。 + +### 2.1 优先使用 DDD 的场景 + +当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块: + +1. 有明确生命周期、状态机、状态转换限制或并发不变量。 +2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。 +3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。 +4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。 +5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。 + +审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。 + +### 2.2 通常不迁移的场景 + +以下需求通常保持原有 `Handler → Service → Store → Model`: + +1. 单表 CRUD、字段增删、简单列表筛选和格式转换。 +2. 局部缺陷修复,且不改变业务边界或核心规则。 +3. 没有独立领域语言、生命周期或跨模块不变量。 +4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。 + +### 2.3 触碰式迁移 + +- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。 +- 迁移单位是**完整用例**,不是整个模块、文件或数据表。 +- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。 +- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。 +- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。 +- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase;调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。 +- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。 + +### 2.4 查询侧规则 + +复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型: + +```text +Handler → Query → GORM/DTO +``` + +- 列表、详情、联表、统计、报表和导出属于 Query。 +- Query 可以直接使用 GORM、CTE、子查询和批量查询,并负责读取权限与分页。 +- Query 返回专用 DTO/Projection,禁止返回后用于业务写入。 +- 只有查询结果参与写操作判定时,Domain 必须重新校验,不能信任读取快照。 +- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/`。 +- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。 + +--- + +## 三、目录结构 ``` internal/ -├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM) +├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM) │ ├── wallet/ │ │ ├── wallet.go ← 聚合根(含业务方法) │ │ ├── events.go ← 领域事件定义 @@ -35,7 +90,7 @@ internal/ │ │ └── vo.go ← 值对象(Money, CreditLimit) │ ├── approval/ ← 新:审批流领域 │ ├── notification/ ← 新:通知领域 -│ ├── distribution/ ← 新:分销领域 +│ ├── distribution/ ← 后续预留:需求16独立立项后再创建 │ └── package/ ← 套餐领域(迁移中) │ ├── application/ ← 应用层(用例,只做编排,不含业务判断) @@ -50,8 +105,17 @@ internal/ │ └── notification/ │ └── send_notification.go │ +├── query/ ← 读取侧(可直接使用 GORM,返回 DTO/Projection) +│ ├── order/ +│ ├── refund/ +│ ├── approval/ +│ ├── dashboard/ +│ └── report/ +│ ├── infrastructure/ ← 基础设施层(实现 domain 里的 interface) -│ └── persistence/ ← 原来的 store/postgres/(保持不动) +│ ├── persistence/ ← GORM Repository,可委托现有 Store +│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费 +│ └── adapter/ ← 外部系统和旧模块防腐层 │ ├── handler/ ← 原有 handler(保持不动) ├── service/ ← 原有 service(保持不动,新模块不在这里) @@ -60,12 +124,15 @@ internal/ **过渡期规则**: - 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx` -- 新模块:`internal/domain/xxx` + `internal/application/xxx` -- Handler 层统一调 Application 层(新)或 Service 层(旧) +- 复杂写用例:`internal/domain/xxx` + `internal/application/xxx` +- 简单写用例:`internal/application/xxx` +- 读取用例:`internal/query/xxx` +- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service +- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model,但不得让 Fiber、Redis 或 GORM 查询进入业务方法 --- -## 三、领域模块划分 +## 四、领域模块划分 | 领域 | 聚合根 | 核心业务规则 | 状态 | |------|--------|------------|------| @@ -74,15 +141,15 @@ internal/ | **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 | | **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** | | **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 | -| **Approval(审批)** | `ApprovalFlow` | 多级审批、推进、驳回 | **全新 DDD** | +| **Approval(审批)** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** | | **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** | -| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | **全新 DDD** | +| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 | --- -## 四、聚合根设计原则(富模型) +## 五、聚合根设计原则(富模型) -### 4.1 核心原则 +### 5.1 核心原则 业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。 @@ -113,24 +180,22 @@ func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) err wallet, err := uc.repo.GetByID(ctx, cmd.WalletID) if err != nil { return err } if err := wallet.Debit(cmd.Amount); err != nil { return err } + // 完整事务和 Outbox 写入方式见“应用服务(用例)”章节 return uc.repo.Save(ctx, wallet) } ``` -### 4.2 聚合根必须包含 +### 5.2 聚合根必须包含 ```go type AggregateRoot struct { - // 内嵌 GORM model(过渡期) - gorm.Model - // 业务字段... - - // 未发布领域事件(内存中,Save 后由框架分发) - domainEvents []DomainEvent `gorm:"-" json:"-"` + + // 未发布领域事件,由 Application 在事务内写入 Outbox + domainEvents []DomainEvent } -// 获取并清空领域事件(由 Repository.Save 调用) +// PopEvents 获取并清空领域事件 func (a *AggregateRoot) PopEvents() []DomainEvent { events := a.domainEvents a.domainEvents = nil @@ -142,17 +207,17 @@ func (a *AggregateRoot) recordEvent(e DomainEvent) { } ``` -### 4.3 与现有 GORM 的共存 +### 5.3 与现有 GORM 的共存 -过渡期,聚合根 **就是** 现有的 GORM Model(加业务方法),不需要单独建领域对象: +迁移旧模块时,聚合根可以临时包装现有 GORM Model,减少一次性重构成本: ```go // internal/domain/wallet/wallet.go // AgentWallet 钱包聚合根 // 直接复用并扩展现有 model.AgentWallet type AgentWallet struct { - model.AgentWallet // 内嵌原有 GORM 模型 - domainEvents []DomainEvent `gorm:"-"` + model.AgentWallet // 迁移期复用持久化字段 + domainEvents []DomainEvent } // Debit 从钱包扣款(含信用额度) @@ -167,9 +232,11 @@ func (w *AgentWallet) Debit(amount Money) error { } ``` +这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。 + --- -## 五、值对象设计 +## 六、值对象设计 值对象:没有 ID,靠值来判断相等,不可变。 @@ -201,9 +268,9 @@ type CreditLimit struct { --- -## 六、领域事件(用 Asynq 实现) +## 七、领域事件与可靠投递 -领域事件不用消息队列,直接复用现有 Asynq 体系。 +领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox,再由 Relay 异步投递到 Asynq。 ```go // internal/domain/wallet/events.go @@ -230,29 +297,45 @@ func (e WalletDebitedEvent) EventType() string { return "wallet.debited" } func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt } ``` -**Repository.Save 发布事件**: +**事务边界**: + +```text +数据库事务 + ├── 保存聚合 + ├── 保存操作日志 + └── 保存 Outbox 事件 +提交事务 + ↓ +Outbox Relay → Asynq → 事件处理器 +``` + +**Application UseCase 保存事件**: ```go -// internal/infrastructure/persistence/wallet_repo.go -func (r *WalletRepository) Save(ctx context.Context, wallet *domainWallet.AgentWallet) error { - err := r.db.WithContext(ctx).Save(&wallet.AgentWallet).Error - if err != nil { - return err - } - // 发布领域事件 - for _, evt := range wallet.PopEvents() { - if err := r.publishEvent(ctx, evt); err != nil { - r.logger.Error("领域事件发布失败", zap.Error(err), zap.String("type", evt.EventType())) - // 事件发布失败不回滚业务(异步,可补偿) +func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { + return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error { + wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID) + if err != nil { + return err } - } - return nil + if err := wallet.Debit(cmd.Amount); err != nil { + return err + } + if err := uc.walletRepo.Save(txCtx, wallet); err != nil { + return err + } + return uc.outboxRepo.Append(txCtx, wallet.PopEvents()) + }) } ``` +- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。 +- Asynq 暂时不可用:不回滚已提交业务,Relay 后续重试。 +- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。 + --- -## 七、仓储接口 +## 八、仓储接口 ```go // internal/domain/wallet/repository.go @@ -262,17 +345,20 @@ type WalletRepository interface { GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error) GetByID(ctx context.Context, id uint) (*AgentWallet, error) Save(ctx context.Context, wallet *AgentWallet) error - SaveInTx(ctx context.Context, tx *gorm.DB, wallet *AgentWallet) error } ``` -实现在 `internal/infrastructure/persistence/wallet_repo.go`,复用现有 Store 逻辑。 +实现在 `internal/infrastructure/persistence/wallet_repo.go`,可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理,Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`。 --- -## 八、应用服务(用例) +## 九、应用服务(用例) -一个文件 = 一个用例。用例只做编排,不含业务判断。 +一个文件 = 一个用例。 + +- 复杂写用例:Application 只做事务和跨聚合编排,业务不变量位于 Domain。 +- 简单写用例:Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。 +- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。 ```go // internal/application/wallet/debit_wallet.go @@ -289,29 +375,36 @@ type DebitCommand struct { // DebitWalletUseCase 扣款用例 type DebitWalletUseCase struct { walletRepo domainWallet.WalletRepository + outboxRepo OutboxRepository txManager TxManager } // Execute 执行扣款 func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { - return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error { - wallet, err := uc.walletRepo.GetByID(ctx, cmd.WalletID) + return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error { + wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID) if err != nil { return err } if err := wallet.Debit(cmd.Amount); err != nil { return err } - return uc.walletRepo.SaveInTx(ctx, tx, wallet) + if err := uc.walletRepo.Save(txCtx, wallet); err != nil { + return err + } + return uc.outboxRepo.Append(txCtx, wallet.PopEvents()) }) } ``` --- -## 九、Handler 层调用方式 +## 十、Handler 层调用方式 -新模块:Handler → Application UseCase(不经过 Service 层) +- 复杂写、简单写:Handler → Application UseCase。 +- 读取:Handler → Query。 +- 尚未迁移的旧用例:Handler → Service。 +- Handler 不直接访问 GORM,也不承载业务规则或复杂 DTO 拼装。 ```go // internal/handler/admin/wallet_handler.go @@ -333,25 +426,31 @@ func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error { --- -## 十、禁止事项 +## 十一、禁止事项 | 禁止 | 原因 | |------|------| -| 在 Application 层做业务判断 | 业务规则必须在领域对象里 | +| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 | | 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 | | 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 | | 在一个用例文件里实现多个业务流程 | 一文件一用例 | -| 领域事件发布失败时回滚业务 | 事件是异步补偿,不是事务一部分 | +| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox | +| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 | | 在聚合根里调用 Repository | 聚合根不依赖仓储 | +| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application | +| 使用 Query 快照替代写侧校验 | 查询结果可能已过期,Domain 必须重新验证不变量 | +| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query | +| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 | --- -## 十一、本次迭代 DDD 落地计划 +## 十二、本次迭代 DDD 落地计划 | 新模块 | 领域路径 | 用例路径 | |--------|---------|---------| | 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` | -| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/` | +| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox | | 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` | -| 分销码(需求16) | `internal/domain/distribution/` | `internal/application/distribution/` | | 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` | + +需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。 diff --git a/docs/7月迭代/业务需求.md b/docs/7月迭代/业务需求.md index 643f66f..d4b5328 100644 --- a/docs/7月迭代/业务需求.md +++ b/docs/7月迭代/业务需求.md @@ -1,3 +1,5 @@ +> 本文保留原始业务需求措辞。涉及架构、字段和接口的最终技术口径,以同目录的专项技术方案和 `基础设施/` 文档为准。 + 其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段 @@ -10,8 +12,13 @@ 6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间 7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件 8.设备批量分配代理和套餐系列:因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号 + +> 评审结论:拆成“批量分配代理”和“批量分配套餐系列”两个独立命令、两个前端入口和两个任务类型;可复用 Excel 解析与任务基础设施,但单个任务不得同时修改两个字段。 9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝 10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供 + +> 技术口径说明:Gateway 只支持按 `cardNo` 限速,不存在设备级限速。单卡直接使用 ICCID;设备场景先查 `tb_device_sim_binding.is_current=true` 的当前卡,再使用该卡 ICCID。取消限速仍重复调用同一个限速接口,只是发送取消参数。本期仅提供后台手动设置/取消,内部单位为 `kbps`;不做套餐字段和自动限速规则。 + 11. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。 12. 换货管理: | 编号 | 需求 | @@ -27,9 +34,9 @@ | COL-002 | 代理充值列表 | 提交人、审批人 | | COL-003 | 换号管理列表 | 提交人 | 14. 导出功能: -#### 6.8.1 lot 卡导出 +## 6.8.1 lot 卡导出 -##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 +### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 | 编号 | 需求 | | -------- | ---------------------------- | @@ -37,7 +44,7 @@ | EXPD-002 | lot 卡导出字段新增使用流量。 | | EXPD-003 | lot 卡导出字段新增剩余流量。 | -#### 6.8.2 代理资金概况-预充值钱包流水导出 +## 6.8.2 代理资金概况-预充值钱包流水导出 导出字段: @@ -58,7 +65,7 @@ | 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 | | 交易渠道 / 支付方式 | 明确交易来源:余额支付等 | -#### 6.8.3 套餐列表导出 +## 6.8.3 套餐列表导出 导出字段: @@ -90,7 +97,7 @@ | 更新时间 | | 删除时间 | -#### 6.8.4 退款管理退款列表导出 +## 6.8.4 退款管理退款列表导出 导出字段: @@ -119,9 +126,9 @@ | 财务审批人 | | 退款凭证 | -#### 6.8.5 换货管理导出 +## 6.8.5 换货管理导出 -##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。 +### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。 导出字段: @@ -143,7 +150,7 @@ | 创建人 | | 创建时间 | -#### 6.8.6 代理充值导出 +## 6.8.6 代理充值导出 导出字段: @@ -176,7 +183,7 @@ 这个好像没有 : -##### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款? +### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款? | 部门领导审批人 | @@ -184,22 +191,27 @@ 这两个好像也没有 -##### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增 +### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增 | 支付通道 | 这是啥玩意 -##### 去掉 +### 去掉 "导出功能可以根据不同权限显示的字段不同且可以自己选择需要导出的字段。像现在这样全量导出,大家都可以看到虚流量等不想让所有人都看到的字段。" 什么叫不同权限,这个不同权限显示字段不同是固定的吗,平台就是固定的,代理就是固定的等等,如果是这样的话需要标记对应的导出字段的权限划分 -##### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置 +### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置 + +> 评审结论:角色级导出字段配置属于本期范围。后端返回当前角色允许导出的字段,最终导出字段取“角色授权字段”与“用户本次选择字段”的交集,前端不能绕过服务端授权。 -15. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。 +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,7 @@ excel表导入字段修改为以下: 代理 15,7,3天 C端公众号 小于等于15天 -颜色规则 +颜色规则 临期天数小于等于15天时,显示粉红色; 临期天数小于等于7天时,显示紫色; 临期天数小于等于3天时,显示黄色; diff --git a/docs/7月迭代/基础设施/审批流.md b/docs/7月迭代/基础设施/审批流.md index 0d751ed..7ca9403 100644 --- a/docs/7月迭代/基础设施/审批流.md +++ b/docs/7月迭代/基础设施/审批流.md @@ -1,414 +1,1173 @@ -# 基础设施:通用审批流引擎 +# 基础设施:通用审批流领域 -> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核) -> 设计:DDD 结构,`internal/domain/approval/` +> 状态:待评审 +> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核) +> 范围变更:需求16已于2026-07-14移出本期,代理申请不在第一版接入范围 +> 架构:DDD,`internal/domain/approval/` + `internal/application/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); +```mermaid +flowchart LR + BizUI[退款/充值/待办前端] --> Handler[Approval Handler] + Handler --> App[Application UseCase] + App --> Definition[ProcessDefinition 聚合] + App --> Instance[ProcessInstance 聚合] + App --> Resolver[审批人解析器] + App --> Attachment[审批附件管理端口] + App --> Repo[(GORM Repository)] + App --> Outbox[(Outbox)] + Relay[Outbox Relay] --> Outbox + Relay --> Asynq[Asynq] + Asynq --> Notice[站内消息消费者] + Asynq --> Adapter[退款/充值业务处理器] + Attachment --> Storage[(私有对象存储)] ``` +审批事务只负责形成可靠的审批事实。通知和审批后的业务动作异步执行,并依靠稳定事件 ID 和业务幂等键承受重复投递。 + +### 第一版支持 + +- 流程定义草稿、发布、停用和版本管理 +- 串行审批节点 +- 按角色选择审批人 +- 直接指定一个或多个审批账号 +- 或签:任意一人通过,当前节点完成 +- 会签:所有人通过,当前节点完成 +- 通过、驳回、退回修改 +- 每次审批动作的审批意见和审批附件 +- 流程定义快照、审批人快照和操作日志 +- 并发控制、请求幂等 +- Outbox + Asynq 可靠事件投递 +- 退款、充值业务通过适配器接入 +- 受控的审批动作业务字段;第一版用于退款金额确认和线下充值操作密码校验 + +### 第一版不支持 + +- 条件表达式和复杂分支 +- 循环、子流程和 BPMN 全规范 +- 任意节点回退、动态跳转和加签 +- 超时自动通过或自动拒绝 +- 运行中修改流程定义或审批人 + +数据结构保留 `nodes + transitions`,但第一版发布校验只允许 `start → approval... → end` 的无环串行流程。 + --- -## 三、Model +## 二、DDD 边界 + +```mermaid +flowchart TB + H[Handler] --> A[Application UseCase] + A --> PD[ProcessDefinition 聚合] + A --> PI[ProcessInstance 聚合] + PI --> T[ApprovalTask 实体] + A --> AR[ApproverResolver 领域端口] + A --> AT[ApprovalAttachmentStore 应用端口] + A --> PR[Repository 端口] + A --> OR[OutboxRepository 端口] + Infra[Infrastructure] --> PR + Infra --> OR + Infra --> AR + Infra --> AT + Infra --> BA[BusinessApprovalHandler 应用端口] + Infra --> GORM[GORM Repository] + Infra --> Queue[Outbox Relay + Asynq] +``` + +### 2.1 ProcessDefinition 聚合 + +负责流程定义的合法性和版本规则: + +- 草稿可以修改。 +- 发布时校验节点、连线、审批人配置和无环约束。 +- 已发布版本不可修改。 +- 修改流程必须基于旧版本创建新版本。 +- 停用只影响新流程发起,不影响已运行实例。 + +```mermaid +stateDiagram-v2 + [*] --> Draft: 新建定义 + Draft --> Draft: 修改节点/审批人 + Draft --> Published: 发布校验通过 + Published --> Disabled: 停用 + Published --> Draft: 基于当前版本复制新草稿 + Disabled --> Draft: 基于历史版本复制新草稿 + Published --> [*] + Disabled --> [*] +``` + +`Published → Draft` 表示创建一个新版本草稿,不是把已发布记录原地改回草稿。 + +### 2.2 ProcessInstance 聚合 + +负责运行期业务不变量: + +- 只有运行中的流程可以审批。 +- 只有待审批任务的候选审批人可以操作。 +- 同一个任务只能完成一次。 +- 或签任意一人通过后,其他候选人的待处理资格取消。 +- 会签全部通过后才推进下一节点。 +- 任意有效审批人驳回或退回时,流程终止,并取消当前节点其他待处理任务。 +- 流程推进必须使用实例保存的定义快照,不能读取后来发布的新版本。 +- 每个审批人的意见属于其唯一审批动作,提交后不可修改;会签和或签都不能用后续意见覆盖先前记录。 +- 审批附件必须与对应操作日志同时落库,不能出现任务已完成但附件关联缺失的半成品。 +- 发起时固化的业务快照是审批详情的权威展示数据;实时业务详情仅作为受原业务数据范围保护的补充。 + +### 2.3 Application UseCase + +Application 只负责: + +- 加载流程定义、实例和账号数据。 +- 开启事务并调用聚合方法。 +- 调用审批人解析器并激活下一节点。 +- 在审批事务内调用受控的业务动作适配器,校验退款金额或充值操作密码等业务字段。 +- 保存聚合、审批日志和 Outbox 事件。 +- 编排退款、充值等业务与审批领域的接入。 + +状态是否允许转换、节点是否完成等判断必须位于 Domain。 + +--- + +## 三、流程定义 + +### 3.1 定义示例 + +```json +{ + "code": "refund_approval", + "version": 2, + "start_node_id": "start", + "nodes": [ + { + "id": "start", + "type": "start", + "name": "开始" + }, + { + "id": "business_review", + "type": "approval", + "name": "业务审核", + "approver": { + "type": "role", + "role_id": 12, + "approval_mode": "any" + } + }, + { + "id": "finance_review", + "type": "approval", + "name": "财务审核", + "approver": { + "type": "user", + "user_ids": [101, 102], + "approval_mode": "all" + }, + "action_config": { + "require_operation_password": true + } + }, + { + "id": "end", + "type": "end", + "name": "结束" + } + ], + "transitions": [ + {"from": "start", "to": "business_review"}, + {"from": "business_review", "to": "finance_review"}, + {"from": "finance_review", "to": "end"} + ] +} +``` + +### 3.2 审批人配置 + +第一版支持两种审批人来源: ```go -// internal/model/approval.go - +// ApproverType 审批人来源类型 const ( - ApprovalBizTypeRecharge = "recharge" - ApprovalBizTypeRefund = "refund" + ApproverTypeRole = "role" // 按角色解析 + ApproverTypeUser = "user" // 直接指定账号 +) - ApprovalStatusPending = 1 // 审批中 - ApprovalStatusApproved = 2 // 已通过 - ApprovalStatusRejected = 3 // 已驳回(永久拒绝,流程终止) - ApprovalStatusReturned = 4 // 已退回(退回给提交人修改,可重新提交) - - ApprovalStepResultPending = 0 // 待审批 - ApprovalStepResultApproved = 1 // 通过 - ApprovalStepResultRejected = 2 // 驳回 - ApprovalStepResultReturned = 3 // 退回 +// ApprovalMode 节点完成方式 +const ( + ApprovalModeAny = "any" // 或签,任意一人通过 + ApprovalModeAll = "all" // 会签,全部人员通过 ) ``` ---- +所有常量实际定义在 `pkg/constants/approval.go`,状态值使用 `int`,类型和方式使用 `string`。 -## 四、领域层(DDD) +角色审批配置存储稳定的 `role_id`,同时在定义和任务中保存角色名称快照。禁止依赖可修改的角色名称执行权限判断。 -```go -// internal/domain/approval/approval_flow.go +`action_config` 是节点定义快照的一部分。第一版只支持 `require_operation_password`:配置后,仅触发节点完成的审批人输入密码;或签由实际通过者输入,会签由最后一位完成会签者输入。密码只参与本次内存校验,不进入业务表、审批日志、Outbox、访问日志或错误日志。 -type ApprovalFlowAggregate struct { - model.ApprovalFlow - Steps []*model.ApprovalStepRecord - domainEvents []DomainEvent `gorm:"-"` -} +### 3.3 审批人解析时机 -// 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 +1. `role`:查询该角色下当前启用的账号。 +2. `user`:校验配置中的账号存在且启用。 +3. 将解析结果写入 `tb_approval_task_assignee`,形成运行期快照。 +4. 没有可用审批人时,节点激活失败,当前事务回滚。 - 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 -} +### 3.4 业务绑定 -// 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 -} +```text +refund → refund_approval 的当前已发布版本 +recharge → recharge_approval 的当前已发布版本 ``` ---- +发起流程时解析当前已发布版本,并将完整 `definition_json` 保存到流程实例快照。 -## 五、领域事件 +### 3.5 运行时节点推进 -```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 -} +```mermaid +flowchart TD + Start[读取实例中的定义快照] --> Node{当前节点类型} + Node -->|start| Next[查唯一下一节点] + Node -->|approval| Resolve[解析角色或指定账号] + Resolve --> Empty{存在可用审批人?} + Empty -->|否| Fail[事务失败并返回配置错误] + Empty -->|是| Task[创建任务和审批人快照] + Task --> Wait[等待审批] + Wait --> Decision{节点审批结果} + Decision -->|等待| Wait + Decision -->|通过| Next + Decision -->|驳回| Rejected[流程已驳回] + Decision -->|退回| Returned[流程已退回] + Next --> EndCheck{下一节点是否 end?} + EndCheck -->|否| Node + EndCheck -->|是| Approved[流程已通过] ``` +第一版发布校验保证每个非结束节点只有一个后继节点,因此运行期不执行条件表达式,也不会出现多分支选择。 + --- -## 六、接入协议(业务层如何接入审批流) +## 四、领域状态机 -任何业务接入审批流需做以下四件事: +### 4.1 流程实例状态 -### 6.1 业务表新增字段 +```text +1=审批中 +2=已通过 +3=已驳回 +4=已退回 +``` + +允许的状态转换: + +```mermaid +stateDiagram-v2 + [*] --> Running: 发起成功 + Running --> Approved: 到达结束节点 + Running --> Rejected: 审批人驳回 + Running --> Returned: 审批人退回修改 + Approved --> [*] + Rejected --> [*] + Returned --> [*] +``` + +已通过、已驳回、已退回都是终态。退回后重新提交必须创建新的流程实例,旧实例永久保留。 + +### 4.2 审批任务状态 + +```text +1=待审批 +2=已通过 +3=已驳回 +4=已退回 +5=已取消 +``` + +任务只在节点激活时创建,不预先创建后续节点的“待审批”任务。 + +### 4.3 审批人处理状态 + +```text +1=待处理 +2=已通过 +3=已驳回 +4=已退回 +5=已取消 +``` + +- 或签:任意审批人通过后,任务通过,其余待处理记录变为已取消。 +- 会签:全部审批人通过后,任务通过。 +- 驳回或退回:任务和流程立即进入对应终态,其余待处理记录变为已取消。 + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Approved: 节点规则满足 + Pending --> Rejected: 任一有效审批人驳回 + Pending --> Returned: 任一有效审批人退回 + Pending --> Cancelled: 同节点已由他人完成/流程终止 + Approved --> [*] + Rejected --> [*] + Returned --> [*] + Cancelled --> [*] +``` + +### 4.4 审批意见与附件 + +“审批建议”“审批备注”统一建模为 `ApprovalOpinion`,它属于某个审批人执行的一次通过、驳回或退回动作,不属于流程实例的可修改公共备注: + +- 通过:审批意见可选,最多 1000 字。 +- 驳回、退回:审批意见必填,去除首尾空白后至少 1 字,最多 1000 字。 +- 意见按纯文本保存和展示,不接受 HTML、Markdown 或富文本。 +- 审批动作提交成功后,意见和附件不可编辑、覆盖或删除;需要纠正时只能由后续审批动作形成新的审计记录。 +- 第一版不建设独立评论区或聊天式追加备注,避免绕开审批动作权限和审计语义。 + +审批附件属于本次审批意见的补充材料: + +- 每次动作可选上传 0~5 个附件。 +- 附件与审批意见、任务状态、操作日志在同一数据库事务内建立关联。 +- 退款凭证、充值凭证等仍属于业务单资料;审批附件不能替代业务必填凭证,也不能反向修改业务资料。 +- 或签、会签场景下,每个审批人拥有各自独立的意见和附件,不能把多人意见合并覆盖到任务级字段。 + +--- + +## 五、数据库设计 + +禁止建立数据库外键,关联通过 ID 维护。 + +### 5.1 流程定义表 ```sql -ALTER TABLE tb_xxx ADD COLUMN approval_flow_id BIGINT DEFAULT NULL; +CREATE TABLE tb_approval_process_definition ( + id BIGSERIAL PRIMARY KEY, + process_code VARCHAR(50) NOT NULL, + process_name VARCHAR(100) NOT NULL, + version INT NOT NULL, + status INT NOT NULL DEFAULT 1, -- 1=草稿 2=已发布 3=已停用 + definition_json JSONB NOT NULL, + published_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX uq_approval_definition_code_version + ON tb_approval_process_definition (process_code, version) + WHERE deleted_at IS NULL; ``` -### 6.2 提交时创建审批流 +### 5.2 业务流程绑定表 -业务提交接口(如 `POST /admin/refund-requests`): +```sql +CREATE TABLE tb_approval_process_binding ( + id BIGSERIAL PRIMARY KEY, + biz_type VARCHAR(50) NOT NULL, + process_code VARCHAR(50) NOT NULL, + status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用 + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); -```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) +CREATE UNIQUE INDEX uq_approval_binding_biz_type + ON tb_approval_process_binding (biz_type); ``` -### 6.3 监听审批事件,更新业务状态 +### 5.3 流程实例表 -每个接入方在 `internal/task/` 注册对应的 Asynq 事件处理器: +```sql +CREATE TABLE tb_approval_process_instance ( + id BIGSERIAL PRIMARY KEY, + flow_no VARCHAR(30) NOT NULL, + process_code VARCHAR(50) NOT NULL, + process_version INT NOT NULL, + definition_snapshot JSONB NOT NULL, + business_snapshot JSONB NOT NULL DEFAULT '{}', + biz_type VARCHAR(50) NOT NULL, + biz_id BIGINT NOT NULL, + biz_no VARCHAR(50) NOT NULL DEFAULT '', + submitter_type VARCHAR(30) NOT NULL, + submitter_id BIGINT, + submitter_name VARCHAR(50) NOT NULL DEFAULT '', + current_node_id VARCHAR(64) NOT NULL, + current_node_name VARCHAR(100) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + version BIGINT NOT NULL DEFAULT 1, + finish_reason TEXT, + completed_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); -```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) -} +CREATE UNIQUE INDEX uq_approval_instance_flow_no + ON tb_approval_process_instance (flow_no); +CREATE INDEX idx_approval_instance_biz + ON tb_approval_process_instance (biz_type, biz_id, created_at DESC); +CREATE UNIQUE INDEX uq_approval_instance_active_biz + ON tb_approval_process_instance (biz_type, biz_id) + WHERE status = 1; ``` -充值单同理,注册 `RechargeApprovalHandler`。 +`business_snapshot` 固化业务标题、业务单号、提交人、审批关键字段和业务资料元数据。退款申请凭证、充值凭证等在其中标记为 `business_materials`;审批人动作附件仍只写入 `tb_approval_operation_attachment`,两类文件不能混用。 -### 6.4 退回后重新提交 +第一版业务快照最低字段: -业务层新增重新提交接口(如 `POST /admin/refund-requests/{id}/resubmit`): +| 业务 | 字段和资料 | +|------|------------| +| 退款 | 退款单号、订单号、资产类型和标识、代理店铺、支付方式、订单实收、申请退款金额、退款原因、退款申请凭证 | +| 员工线下充值 | 充值单号、目标代理店铺、充值金额、支付方式、提交人、备注、支付凭证 | -```go -// 1. 校验业务单 status=已退回 -if bizRecord.Status != model.RefundStatusReturned { - return errors.New(errors.CodeForbidden, "当前状态不允许重新提交") -} +`submitter_type` 第一版支持 `admin_account`、`shop_account`。外部申请人属于需求16后续扩展,本期不引入 `external_applicant` 语义。 -// 2. 新建审批流(biz_type+biz_id 相同,旧的流程作为历史保留) -flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{...}) +### 5.4 审批任务表 -// 3. 更新业务单:approval_flow_id = 新 flow.ID,status 回到"待审批" -s.bizStore.Resubmit(ctx, bizRecord.ID, flow.ID) +```sql +CREATE TABLE tb_approval_task ( + id BIGSERIAL PRIMARY KEY, + process_instance_id BIGINT NOT NULL, + node_id VARCHAR(64) NOT NULL, + node_name VARCHAR(100) NOT NULL, + node_sequence INT NOT NULL, + approver_type VARCHAR(20) NOT NULL, + approver_config JSONB NOT NULL, + approval_mode VARCHAR(20) NOT NULL, + status INT NOT NULL DEFAULT 1, + completed_by BIGINT, + completed_by_name VARCHAR(50), + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_task_instance_node + ON tb_approval_task (process_instance_id, node_id); +CREATE INDEX idx_approval_task_status + ON tb_approval_task (status, created_at DESC); +``` + +任务表只保存节点结果。`completed_by` 表示触发节点完成的最后操作人,不代表节点只有一个审批人;人类审批意见统一保存在审批人记录和不可变操作日志中。 + +### 5.5 任务审批人表 + +```sql +CREATE TABLE tb_approval_task_assignee ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL, + assignee_id BIGINT NOT NULL, + assignee_name VARCHAR(50) NOT NULL DEFAULT '', + source_type VARCHAR(20) NOT NULL, + source_id BIGINT, + source_name VARCHAR(100) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + comment TEXT NOT NULL DEFAULT '', + operation_log_id BIGINT, + operated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_task_assignee + ON tb_approval_task_assignee (task_id, assignee_id); +CREATE INDEX idx_approval_assignee_pending + ON tb_approval_task_assignee (assignee_id, status, created_at DESC); +``` + +### 5.6 操作日志表 + +```sql +CREATE TABLE tb_approval_operation_log ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64) NOT NULL, + process_instance_id BIGINT NOT NULL, + task_id BIGINT, + node_id VARCHAR(64), + operator_type VARCHAR(30) NOT NULL, + operator_id BIGINT, + operator_name VARCHAR(50) NOT NULL DEFAULT '', + action VARCHAR(30) NOT NULL, + request_digest CHAR(64) NOT NULL, + before_status INT NOT NULL, + after_status INT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + business_data JSONB NOT NULL DEFAULT '{}', + client_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_operation_request_id + ON tb_approval_operation_log (request_id); +CREATE INDEX idx_approval_operation_instance + ON tb_approval_operation_log (process_instance_id, created_at); +``` + +操作日志只允许新增,禁止更新或删除。相同 `request_id` 仅在 `task_id`、操作人、动作和规范化请求摘要 SHA-256 均一致时返回第一次操作结果;任一字段不一致返回冲突,不能把误复用的请求 ID 当成成功重试。 + +### 5.7 审批操作附件表 + +```sql +CREATE TABLE tb_approval_operation_attachment ( + id BIGSERIAL PRIMARY KEY, + operation_log_id BIGINT NOT NULL, + file_key VARCHAR(500) NOT NULL, + file_name_snapshot VARCHAR(255) NOT NULL, + content_type_snapshot VARCHAR(100) NOT NULL, + size_bytes BIGINT NOT NULL, + object_etag VARCHAR(128) NOT NULL, + attached_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_attachment_operation_file + ON tb_approval_operation_attachment(operation_log_id, file_key); +CREATE INDEX idx_approval_attachment_operation + ON tb_approval_operation_attachment(operation_log_id); +``` + +附件表不建立数据库外键。`operation_log_id` 对应不可变操作日志;审批人记录通过 `operation_log_id` 定位该次操作的意见和附件。 + +附件提交规则: + +1. 前端生成本次动作 `request_id` 后,通过现有上传能力申请 `purpose=approval_attachment` 的一次性上传凭证;上传记录必须固化上传人账号、用途、`request_id`、临时 Key 和过期时间,临时 Key 位于 `attachments/` 前缀。 +2. 审批动作提交 `attachments=[{file_key,file_name}]`;每个临时 Key 最长 500,必须以 `attachments/` 开头且不能重复。`file_name` 清理后长度为 1~255,只作为显示快照,不作为对象定位依据。 +3. Application 在状态转换前先校验临时 Key 的上传记录属于当前账号、用途为 `approval_attachment` 且 `request_id` 一致,再调用对象存储 `Stat/HeadObject` 确认文件存在,并读取对象大小、Content-Type 和 ETag;后端同时校验文件扩展名与 Content-Type 组合,不能只信任请求体声明。 +4. 第一版每个文件最大 20MB,只允许 `.jpg/.jpeg/.png/.pdf/.doc/.docx/.xls/.xlsx`;拒绝可执行文件、脚本和普通压缩包。扩展名与 MIME 白名单定义在 `pkg/constants/approval.go`,不由前端决定。 +5. 校验通过后,后端按 `request_id` 将临时对象服务端复制到 `approval-attachments/{request_id}/` 审计前缀,并返回最终 Key、元数据和 ETag。该前缀不提供前端上传 URL,避免附件提交后被覆盖。 +6. 文件名只作为展示快照,必须去除路径和控制字符;下载时使用安全的 `Content-Disposition`。 +7. 数据库事务只保存最终审计 Key。事务提交后异步删除临时对象;事务失败产生的未引用审计对象和普通临时对象由统一生命周期清理。 + +### 5.8 Outbox 表 + +`tb_outbox_event` 是通用基础设施表,不只服务审批流: + +```sql +CREATE TABLE tb_outbox_event ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, + aggregate_type VARCHAR(50) NOT NULL, + aggregate_id VARCHAR(64) NOT NULL, + event_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + status INT NOT NULL DEFAULT 1, -- 1=待发布 2=已发布 3=发布失败 + retry_count INT NOT NULL DEFAULT 0, + available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_outbox_event_id ON tb_outbox_event (event_id); +CREATE INDEX idx_outbox_pending + ON tb_outbox_event (status, available_at) + WHERE status IN (1, 3); ``` --- -## 七、应用层(用例) +## 六、核心扩展接口 + +### 6.1 审批人解析器 ```go -// internal/application/approval/submit_approval.go - -type SubmitApprovalCommand struct { - BizType string - BizID uint - BizNo string - SubmitterID uint - SubmitterName string +// ApproverResolver 根据节点配置解析候选审批人 +type ApproverResolver interface { + Resolve(ctx context.Context, rule ApproverRule) ([]Approver, error) } +``` -type SubmitApprovalUseCase struct { - approvalRepo approval.ApprovalRepository - notifyPublisher *notification.NotificationPublisher - userStore UserStore +Infrastructure 提供: + +- `RoleApproverResolver` +- `UserApproverResolver` +- `ApproverResolverRegistry` + +注册表只是按 `approver.type` 选择策略,不承担流程推进逻辑。 + +### 6.2 业务审批处理器 + +```go +// BusinessApprovalHandler 处理审批结果对应的业务动作 +type BusinessApprovalHandler interface { + BusinessType() string + BuildSnapshot(ctx context.Context, bizID uint) (BusinessSnapshot, error) + OnApproved(ctx context.Context, event ProcessApprovedEvent) error + OnRejected(ctx context.Context, event ProcessRejectedEvent) error + OnReturned(ctx context.Context, event ProcessReturnedEvent) error } +``` -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 +`BuildSnapshot` 在业务单创建、重新提交并启动流程的同一事务前读取业务事实,返回标题、编号、提交人、关键字段和业务资料元数据;写入实例后不随业务表后续变化而变化。退款和充值分别实现处理器,并注册到 `BusinessApprovalHandlerRegistry`。异步处理器必须使用状态条件更新或业务幂等键,允许 Asynq 重试。 + +### 6.3 审批动作业务扩展 + +少数业务需要在审批动作中确认业务字段,例如退款最终审批需要确认实际退款金额。审批引擎不解析这些字段,只提供受控扩展端口: + +```go +// BusinessApprovalActionAdapter 处理审批动作中的业务字段。 +type BusinessApprovalActionAdapter interface { + BusinessType() string + ResolveActionForm(ctx context.Context, action ActionContext) ([]ActionField, error) + ApplyBeforeApprove(ctx context.Context, tx Transaction, action ActionContext, fields map[string]any) (map[string]any, error) +} +``` + +`Transaction` 是 Application 层的事务上下文抽象,由现有 GORM `TxManager` 实现;领域对象不直接依赖 GORM。 + +- `ResolveActionForm` 返回当前任务允许提交的字段定义,前端按接口渲染,不能按节点名称写死。 +- `ApplyBeforeApprove` 在审批状态转换的同一事务中校验并保存业务字段,返回标准化结果。 +- 标准化结果写入 `tb_approval_operation_log.business_data`,便于审计和幂等重放。 +- 没有注册适配器的业务拒绝非空 `business_fields`,不能静默忽略未知字段。 +- 第一版退款和员工线下充值实现该端口:节点通过 `action_config` 声明 `approved_refund_amount` 或 `operation_password`。字段只在当前操作会完成该节点时返回。 +- 金额必须大于 0,且不能超过申请退款金额和订单实收金额。 +- 会签节点的退款金额由最后一位完成会签的审批人确认;如需让指定人员确定金额,应在流程定义中增加其专属的最终决策节点,不能由第一位会签人预先锁定。 +- `operation_password` 由现有 `OperationPasswordService` 校验,只用于本次请求,不写业务表、操作日志、Outbox、错误日志或访问日志;适配器返回的标准化审计数据必须排除敏感字段。 + +`ProcessApproved` 消费者只读取已经持久化的业务字段执行后续退款,不能在异步阶段重新接受审批人输入。 + +### 6.4 审批附件存储端口 + +```go +// ApprovalAttachmentStore 校验临时对象并固化为不可覆盖的审计附件。 +type ApprovalAttachmentStore interface { + PrepareImmutable(ctx context.Context, requestID string, refs []AttachmentRef) ([]PreparedAttachment, error) +} +``` + +Infrastructure 使用对象存储 `HeadObject + CopyObject` 实现,并要求复制目标 Key 对同一 `request_id + source_key` 幂等:目标已存在时必须校验 ETag 一致并直接复用,禁止覆盖为不同内容。输入中的文件名只作显示候选值;大小、Content-Type 和 ETag 来自对象存储。该端口不生成下载 URL,下载授权由审批实例查询用例单独处理。 + +--- + +## 七、领域事件与事务 + +核心事件: + +```text +ProcessStarted +TaskCreated +TaskApproved +TaskRejected +TaskReturned +ProcessApproved +ProcessRejected +ProcessReturned +``` + +任务结果事件只携带 `operation_log_id` 和必要的意见摘要,不携带对象存储签名 URL。附件 Key 保留在审批附件表;站内消息第一版只提示“包含附件”,用户进入有权限的审批详情后再获取下载 URL。 + +审批动作先执行事务外预检: + +```text +1. 按 request_id 查询既有操作日志;只有任务、操作人、动作和规范化请求摘要均一致时才返回原结果,否则返回冲突 +2. 规范化并校验审批意见 +3. 调用 ApprovalAttachmentStore 检查附件并复制到不可覆盖的审计前缀 +4. 形成仅供本次请求使用的最终 Key 和附件元数据快照 +``` + +对象存储检查不得在持有流程实例行锁的数据库事务中执行。预检通过后,审批数据库事务必须同时完成: + +```text +1. 锁定或按 version 加载流程实例 +2. 校验 request_id 幂等及请求摘要 +3. 再次校验任务仍属于当前账号且状态未变化 +4. 调用业务动作适配器校验并保存扩展字段(如有) +5. 聚合执行状态转换 +6. 更新任务和审批人状态,保存该审批人的意见 +7. 创建下一节点任务和审批人快照(如需推进) +8. 使用预检快照写操作日志和审批附件记录,并回写审批人的 operation_log_id +9. 写 Outbox 事件 +10. 提交事务 +``` + +对象一旦写入审批附件记录即视为不可变引用,未引用文件清理任务不得删除它;如果预检后任务状态发生变化,数据库事务回滚,固化对象按未引用审计对象处理。 + +Outbox Relay 提交 Asynq 后,消费者负责: + +- 创建站内消息 +- 通知下一节点候选审批人 +- 通知有站内账号的申请人审批结果;外部申请人由业务投影提供结果查询 +- 调用退款、充值等业务处理器 +- 后续扩展企业微信通知 + +通知失败不能阻塞审批,但事件不能丢失。业务处理失败由 Asynq 重试并保留错误日志。 + +### 7.1 发起流程时序 + +```mermaid +sequenceDiagram + actor User as 提交人 + participant Biz as 退款/充值 Application + participant Approval as StartProcessUseCase + participant DB as PostgreSQL + participant Relay as Outbox Relay + participant Worker as Asynq Worker + + User->>Biz: 提交业务申请 + Biz->>DB: 开启同一数据库事务 + Biz->>DB: 创建业务单 + Biz->>Approval: 发起审批(biz_type, biz_id) + Approval->>DB: 读取已发布定义和业务绑定 + Approval->>DB: 创建实例、首任务、审批人快照、操作日志 + Approval->>DB: 写 ProcessStarted/TaskCreated Outbox + Biz->>DB: 回写 approval_instance_id + DB-->>Biz: 提交成功 + Biz-->>User: 返回业务单和审批摘要 + Relay->>DB: 拉取未投递事件 + Relay->>Worker: 投递通知任务 +``` + +业务单和首个审批任务必须同事务创建。任何一步失败都不能留下“有业务单但没有审批任务”的半成品。 + +### 7.2 审批与业务回调时序 + +```mermaid +sequenceDiagram + actor Approver as 审批人 + participant API as Approval API + participant Domain as ProcessInstance + participant DB as PostgreSQL + participant Relay as Outbox Relay + participant Worker as Asynq Worker + participant Biz as 退款/充值处理器 + + Approver->>API: approve/reject/return(task_id, request_id) + API->>DB: 开启事务并加载实例/任务/审批人 + API->>Domain: 执行状态转换 + Domain-->>API: 节点等待/推进/流程终态 + API->>DB: 保存状态、日志、下一任务和 Outbox + DB-->>API: 提交成功 + API-->>Approver: 返回最新审批摘要 + Relay->>Worker: 至少一次投递领域事件 + Worker->>Biz: 按 biz_type 调业务处理器 + Biz-->>Worker: 幂等成功或可重试错误 +``` + +审批接口成功只证明审批事实已提交,不证明退款到账或充值入账已经完成。关联业务接口必须返回独立的业务处理状态。 + +--- + +## 八、并发与幂等 + +### 8.1 流程并发 + +流程实例使用 `version` 乐观锁: + +```sql +UPDATE tb_approval_process_instance +SET current_node_id = ?, + current_node_name = ?, + status = ?, + version = version + 1, + updated_at = NOW() +WHERE id = ? + AND status = 1 + AND version = ?; +``` + +受影响行数为 0 时,返回“审批状态已变化,请刷新后重试”。 + +### 8.2 任务幂等 + +任务只能从待审批状态更新: + +```sql +UPDATE tb_approval_task_assignee +SET status = ?, operated_at = NOW() +WHERE task_id = ? + AND assignee_id = ? + AND status = 1; +``` + +所有审批动作必须携带 `request_id`,并由操作日志唯一索引防止重复请求。 + +--- + +## 九、应用用例 + +```text +internal/application/approval/ +├── create_definition.go +├── update_definition.go +├── publish_definition.go +├── disable_definition.go +├── bind_business_process.go +├── start_process.go +├── approve_task.go +├── reject_task.go +├── return_task.go +├── list_my_tasks.go +└── get_process_detail.go +``` + +### 9.1 发起流程 + +```text +1. 根据 biz_type 查询启用的流程绑定 +2. 查询 process_code 当前已发布版本 +3. 校验业务单不存在运行中的审批实例 +4. 由业务处理器构建业务快照,创建定义快照和流程实例 +5. 从 start 节点找到首个 approval 节点 +6. 解析角色或指定账号 +7. 创建审批任务和审批人快照 +8. 保存业务单、流程实例、任务、操作日志和 Outbox +``` + +业务单创建与流程创建必须共享同一个事务,禁止先创建业务单后忽略审批流创建失败。 + +### 9.2 审批通过 + +```text +1. 根据 task_id 加载流程实例、任务和当前审批人记录 +2. 校验任务属于当前账号且仍为待处理 +3. 聚合执行 Approve +4. 或签/会签策略判断节点是否完成 +5. 节点未完成:保存当前审批结果并等待其他人 +6. 节点完成:根据定义快照激活下一节点 +7. 到达 end:流程变为已通过 +8. 保存操作日志和 Outbox 事件 +``` + +### 9.3 驳回和退回 + +- 驳回:当前流程永久终止,业务进入已拒绝状态。 +- 退回:当前流程永久终止,业务进入可编辑状态。 +- 重新提交:业务创建新的审批实例,旧实例仅用于历史追溯。 + +--- + +## 十、API 设计 + +### 10.1 流程定义管理 + +```text +POST /api/admin/approval-definitions +PUT /api/admin/approval-definitions/{id} +POST /api/admin/approval-definitions/{id}/publish +POST /api/admin/approval-definitions/{id}/disable +GET /api/admin/approval-definitions +GET /api/admin/approval-definitions/{id} +PUT /api/admin/approval-bindings/{biz_type} +``` + +只有草稿可以修改。发布接口必须完成完整定义校验。 + +### 10.2 运行时接口 + +```text +GET /api/admin/approval-tasks?status=1&biz_type=refund&page=1&page_size=20 +POST /api/admin/approval-tasks/{task_id}/approve +POST /api/admin/approval-tasks/{task_id}/reject +POST /api/admin/approval-tasks/{task_id}/return +GET /api/admin/approval-instances/{instance_id} +GET /api/admin/approval-instances/by-business?biz_type=refund&biz_id=123 +POST /api/admin/approval-instances/{instance_id}/attachment-download-urls +POST /api/admin/approval-instances/{instance_id}/business-material-download-urls +``` + +审批动作请求: + +```json +{ + "request_id": "0190f6c9-2a4e-7f19-b8ab-ec11d63e9970", + "comment": "同意", + "attachments": [ + { + "file_key": "attachments/2026/07/13/0190f6c9-evidence.pdf", + "file_name": "退款核对说明.pdf" } - // 通知部门领导 - 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 + ], + "business_fields": { + "approved_refund_amount": 10000 + } } ``` ---- +`attachments` 可省略,最多 5 个;后端先校验临时对象的上传人、用途和 `request_id`,再以 `file_key` 定位对象,文件类型和大小从对象存储读取,`file_name` 经清理后作为显示快照。`business_fields` 可省略;仅当前节点 `action_config` 声明且本次操作会完成节点的字段允许提交。其他业务或其他节点传入未声明字段时,后端返回参数错误。 -## 八、API 设计 +意见规则由三个动作 DTO 分别校验:`approve` 的 `comment` 可为空,`reject/return` 的 `comment` 必填。后端必须统一去除首尾空白并限制最多 1000 字,不能只依赖前端校验。 -### 8.1 待我审批列表 +附件下载请求使用附件 ID,不接受客户端直接提交任意 `file_key`: -``` -GET /admin/approvals?status=1&biz_type=recharge&page=1&page_size=20 +```json +{ + "attachment_ids": [9001, 9002] +} ``` -### 8.2 审批通过 +单次支持 1~50 个附件 ID。后端先校验当前账号有权查看该流程实例,并确认所有附件都属于该实例的操作日志,再调用对象存储生成短期下载 URL。 -``` -POST /admin/approvals/{flow_id}/approve -{ "remark": "审批通过" } +业务资料下载请求只接受 `business_snapshot.business_materials` 中的 `file_id`,不接受任意 `file_key`。后端先校验流程详情权限,再从实例快照映射到原业务资料对象并生成短期 URL;业务资料不会被伪装成审批附件。 + +审批接口操作 `task_id`,不再直接操作 `flow_id`。 + +### 10.3 详情响应 + +详情必须动态返回节点和审批人,不使用固定的“部门领导审批人”“财务审批人”字段。`action_form` 只在当前账号拥有对应审批动作时返回: + +```json +{ + "instance_id": 1001, + "process_name": "退款审批", + "process_version": 2, + "status": 1, + "current_node_name": "财务审核", + "business_snapshot": { + "title": "退款申请", + "biz_no": "RF202607140001", + "submitter_name": "张三", + "fields": [ + {"key": "asset_identifier", "label": "资产标识", "value": "8986..."}, + {"key": "requested_refund_amount", "label": "申请退款金额", "value": 10000} + ], + "business_materials": [ + {"file_id": "refund-voucher-1", "file_name": "退款凭证.pdf", "content_type": "application/pdf", "size_bytes": 245760} + ] + }, + "available_actions": ["approve", "reject", "return"], + "action_form": { + "fields": [ + { + "key": "approved_refund_amount", + "type": "money", + "required": false, + "default_value": 10000, + "label": "实际退款金额" + } + ] + }, + "tasks": [ + { + "node_id": "business_review", + "node_name": "业务审核", + "approval_mode": "any", + "status": 2, + "current_user_assignee_status": 2, + "assignees": [ + { + "account_id": 88, + "account_name": "zhangsan", + "status": 2, + "comment": "同意", + "attachments": [ + { + "attachment_id": 9001, + "file_name": "退款核对说明.pdf", + "content_type": "application/pdf", + "size_bytes": 245760 + } + ] + } + ] + } + ] +} ``` -### 8.3 审批驳回(永久拒绝) +### 10.4 权限控制 -``` -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 列表(每步审批人、结果【通过/驳回/退回】、时间、备注) +- 流程定义创建、修改、发布、停用和业务绑定:仅流程管理员或系统管理员。 +- 审批任务操作:当前账号必须存在于 `tb_approval_task_assignee` 且状态为待处理。 +- 待我审批列表:按当前账号 ID 查询任务审批人表,不根据前端传入角色判断。 +- 流程详情:有站内账号的申请人、当前审批候选人、业务管理员和系统管理员可以查看。上述人员可查看本实例的业务快照、业务资料、审批意见和审批附件;跳转到实时业务详情时仍受现有店铺/企业数据范围过滤。 +- 审批附件下载:继承流程详情权限,并逐个校验附件归属;响应只返回短期签名 URL,不暴露 Bucket、公网永久地址或未授权 `file_key`。 +- 角色或账号配置在发布时校验存在性;节点激活时再次校验账号启用状态。 +- 所有权限校验在后端完成,前端隐藏按钮不能作为授权依据。 +- `available_actions` 由后端根据当前账号、实例状态和审批人快照计算,前端不得用角色名称自行拼装。 --- -## 九、前端对接 +## 十一、业务接入协议 -### 审批详情页操作区 +### 11.1 业务表关联 -当前登录用户是当前步骤审批人时,显示三个按钮: +退款单和充值单保留当前审批实例 ID: -``` -[审批通过] [退回修改] [驳回] -备注/原因输入框 +```sql +ALTER TABLE tb_xxx ADD COLUMN approval_instance_id BIGINT; ``` -- **审批通过** → `POST /admin/approvals/{id}/approve` -- **退回修改** → `POST /admin/approvals/{id}/return`(需填退回原因) -- **驳回** → `POST /admin/approvals/{id}/reject`(需填驳回原因) +历史审批通过 `biz_type + biz_id` 查询全部流程实例。业务表只保存当前实例 ID,重新提交时更新为新实例。 -### 权限控制 +### 11.2 业务状态归属 -- `department_leader` 角色:步骤1为"待审批"时显示操作按钮 -- `finance` 角色:步骤2为"待审批"时显示操作按钮 -- 其他角色:只读 +- 审批领域只维护流程和任务状态。 +- 退款、充值领域维护各自业务状态。 +- `ProcessApproved/Rejected/Returned` 通过 Outbox 可靠投递。 +- 业务处理器消费事件后,使用条件更新改变业务状态。 +- 审批通过不等同于业务处理成功;代理钱包回退和充值入账具备各自处理状态、业务幂等键和失败重试。第三方/线下退款本期由财务人工确认,不接入支付渠道自动退款。 +- 审批意见和审批附件的权威记录属于审批操作日志。业务表可以为列表、H5 或历史兼容快照最终驳回/退回原因,但不能反向覆盖审批日志,也不重复保存全部附件。 +- 退款的资金边界:仅代理钱包支付订单在审批通过后自动回退到原扣款代理主钱包;个人/客户资产钱包不纳入本期自动回退,微信、支付宝和线下退款均由财务在系统外完成后人工确认。 + +### 11.3 默认流程 + +本迭代预置两个流程定义: + +- `refund_approval`:退款审批 +- `recharge_approval`:员工线下代充值审批 + +默认节点可配置为两个串行审批节点,但审批节点名称、角色 ID、指定账号和审批方式必须来自流程定义,禁止在代码中使用固定步骤编号或固定角色名称。 + +### 11.4 停机切换与旧接口下线 + +本次发布明确采用停机切换,不建设新旧审批接口兼容门面: + +1. 维护窗口内停止退款、线下充值和审批写操作。 +2. 完成迁移后同时发布 API、Worker/Relay 和前端。 +3. 初始化并发布两个默认流程定义,绑定 `refund` 和 `recharge`。 +4. 退款按业务单 ID 直接审批接口、`POST /api/admin/agent-recharges/{id}/offline-pay`、`POST /api/admin/agent-recharges/{id}/reject` 在新版本中不再注册。 +5. 所有审批动作统一调用 `/api/admin/approval-tasks/{task_id}/*`。 +6. 验证流程发起、任务审批、结果通知和业务处理状态后再开放系统。 + +停机发布降低了同时维护两套状态转换语义的风险,也避免旧接口绕过任务审批人快照、请求幂等和操作日志。 + +### 11.5 存量审批数据切换 + +停机期间必须处理存量单据,不能在旧接口下线后留下无法审批的业务记录: + +1. 已通过、已拒绝、已退回等历史终态记录不伪造流程实例,`approval_instance_id` 保持为空;查询接口返回 `approval_source=legacy`,页面只读展示原业务状态、旧审批字段和审计日志。 +2. `status=1` 的待审批退款单,使用发布后的 `refund_approval` 从首节点创建流程实例、任务和审批人快照,并回写 `approval_instance_id`。 +3. 仅对符合“平台员工创建 + `payment_method=offline` + 尚未入账”的存量充值记录创建 `recharge_approval` 实例;代理在线充值不进入审批流。 +4. 回填使用一次性 Application 命令执行,不用裸 SQL 拼装任务;命令按 `biz_type + biz_id` 幂等,已有关联实例时跳过。 +5. 开放访问前必须确认所有需要继续审批的存量业务记录都已关联运行中的流程实例,不允许前端回退到旧审批按钮。 + +历史终态记录没有完整节点、候选人和操作意见时,宁可明确显示“历史审批记录”,也不能根据角色名称或当前账号关系反推并生成虚假的审批时间线。 + +--- + +## 十二、前端技术方案 + +跨需求共性规则见 [7月迭代前端技术方案](../前端技术方案.md),本节只描述审批流专项。 + +### 12.1 页面与路由 + +| 页面 | 建议路由 | 说明 | +|------|----------|------| +| 待我审批 | `/approvals/tasks` | 默认仅查询当前账号待处理任务,支持业务类型和时间筛选 | +| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、流程时间线、候选人状态和审批意见 | +| 流程定义列表 | `/settings/approval-flows` | 草稿、已发布、已停用、当前业务绑定 | +| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点有序编辑、角色/账号选择、或签/会签 | + +### 12.2 流程定义编辑器 + +第一版不做拖拽流程画布。前端使用有序节点列表: + +```text +开始 + ↓ +[业务审核] 审批人=角色:运营主管 方式=或签 + ↓ +[财务审核] 审批人=账号:张三/李四 方式=会签 + ↓ +结束 +``` + +- 开始和结束节点固定且只读。 +- 审批节点支持新增、删除、上移、下移。 +- 角色选择器提交稳定的 `role_id`,账号选择器提交 `user_ids`。 +- 发布前先做前端基础校验,再以发布接口的后端校验结果为准。 +- 已发布版本只读;修改操作创建新版本草稿。 + +### 12.3 待办与详情 + +- 待办列表按 `task_id` 操作,行点击进入 `instance_id` 详情。 +- 详情首屏显示业务标题、单号、提交人、关键字段和业务资料;审批人可在不跳转业务页面的前提下判断审批对象。 +- 时间线按 `tasks[]` 顺序动态渲染,不固定节点数量和名称。 +- 会签节点展示“已完成人数/总人数”;或签节点展示实际完成人和被取消候选人。 +- 每位已操作审批人分别展示动作、审批意见和附件;多人意见不得合并成一段任务级备注。 +- 通过意见可选,驳回和退回意见必填;附件最多 5 个,上传完成前禁止提交。 +- 附件使用权限校验后的短期下载 URL,页面过期后重新获取,不缓存永久地址。 +- 业务资料和审批附件使用不同下载接口和区域展示;前者来自流程发起快照,后者属于具体审批动作。 +- 操作成功后重新请求实例详情和待办列表,不做乐观推进。 +- 返回并发冲突时提示“审批状态已变化,已为你刷新”,随后重新加载详情。 +- 驳回和退回意见必填规则以后端 DTO 为准,提交期间三个操作按钮统一锁定。 + +### 12.4 通知跳转 + +`approval.pending` 通知的 `ref_id` 使用审批实例 ID,跳转审批详情。页面加载后再由 `available_actions` 判断当前账号是否可操作,不能因通知到达过就默认拥有审批权限。 + +--- + +## 十三、发布、停用与回滚 + +1. 进入维护模式并确认相关写入口已经停止。 +2. 执行增量迁移,创建定义、实例、任务、审批人、操作日志、审批附件和 Outbox 表,并为退款、充值两个业务表增加审批关联字段。 +3. 同时发布 API、Worker/Relay 和前端,旧审批路由不再注册。 +4. 创建并发布默认流程定义,配置真实角色 ID 或账号 ID,完成退款、充值两个业务绑定。 +5. 执行存量待审批退款和员工线下充值回填命令,并核对不存在应审批但 `approval_instance_id` 为空的记录。 +6. 人工验证退款、线下充值的发起、审批、退回重提、历史记录展示和业务处理状态。 +7. 验证通过后解除维护模式。 + +开放访问前失败时,回滚应用版本和可逆迁移;开放后不得删除已发布定义、历史实例、任务、日志和 Outbox。正在处理的业务事件通过管理查询确认后再决定重试或人工处理。 + +--- + +## 十四、可观测性与风险 + +### 14.1 必备查询维度 + +- `process_instance_id`、`task_id`、`request_id`、`operation_log_id`、`attachment_id`、`event_id`。 +- `biz_type + biz_id` 对应的全部历史实例。 +- 当前节点、候选审批人和每个审批人的操作状态。 +- Outbox 投递次数、下次重试时间和最后错误。 + +### 14.2 关键风险 + +| 风险 | 触发条件 | 处理 | +|------|----------|------| +| 节点无审批人 | 角色无人或账号被停用 | 节点激活事务失败,记录流程配置错误,不创建空任务 | +| 两人同时完成节点 | 或签/会签临界并发 | 实例 version + 审批人条件更新,冲突方刷新 | +| 审批成功但业务未完成 | Worker 或业务处理器失败 | 业务处理状态独立展示,Asynq 重试,禁止回滚审批事实 | +| 重复事件 | Relay 或 Worker 至少一次投递 | `event_id`、业务幂等键和状态条件更新 | +| 附件越权下载 | 用户猜测或获得其他流程的附件 ID | 按流程详情权限校验,并验证附件、操作日志和流程实例的完整归属链 | +| 临时附件误用 | 猜测或获得他人临时 `file_key` | 上传记录绑定上传人、用途和 `request_id`,提交时三者必须一致 | +| 非法或孤立附件 | 上传恶意文件,或上传后未提交审批 | 服务端读取对象元数据执行白名单/大小校验;未引用对象按生命周期清理 | +| 旧路由误保留 | 发布包仍注册业务单审批或直接入账接口 | 停机验收中逐个验证旧路由为不可用,审批写操作只允许任务级 API | + +--- + +## 十五、设计模式边界 + +| 能力 | 使用方式 | +|------|---------| +| 状态机 | `ProcessInstance` 聚合维护流程和任务状态转换 | +| 策略模式 | 角色/指定账号解析、或签/会签判断 | +| 适配器模式 | 退款、充值业务处理器和后续业务接入 | +| 领域事件 | 表达任务创建、流程通过、驳回、退回等业务事实 | +| Outbox Pattern | 保证领域事件不会因 Asynq 短暂故障丢失 | +| 命令模式 | Application Command DTO 即可,不额外建立命令类体系 | +| 责任链模式 | 第一版不使用,避免把校验拆成大量小 Handler | +| 条件规则引擎 | 第一版不实现,出现真实条件分支需求后再扩展 | + +审批流的核心不是堆叠设计模式,而是保持流程定义、实例状态、审批任务和业务回调之间的边界稳定。 diff --git a/docs/7月迭代/基础设施/站内消息.md b/docs/7月迭代/基础设施/站内消息.md index 6bd6c0c..8dd33ad 100644 --- a/docs/7月迭代/基础设施/站内消息.md +++ b/docs/7月迭代/基础设施/站内消息.md @@ -1,15 +1,35 @@ # 基础设施:站内消息(Notification) -> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒) +> 状态:待评审 +> 被依赖:需求 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 +39,13 @@ -- 迁移文件:YYYYMMDD_create_tb_notification.sql CREATE TABLE tb_notification ( id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID,用于幂等 recipient_id BIGINT NOT NULL, -- 用户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 +53,11 @@ CREATE TABLE tb_notification ( expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用) ); -CREATE INDEX idx_notification_recipient ON tb_notification (recipient_id, recipient_type, is_read); +CREATE INDEX idx_notification_recipient + ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC); CREATE INDEX idx_notification_created ON tb_notification (created_at DESC); +CREATE UNIQUE INDEX uq_notification_event_recipient + ON tb_notification (event_id, recipient_id, recipient_type); COMMENT ON TABLE tb_notification IS '站内消息通知表'; ``` @@ -72,8 +96,9 @@ const ( // Notification 站内消息模型 type Notification struct { ID uint `gorm:"column:id;primaryKey" json:"id"` - RecipientID uint `gorm:"column:recipient_id;not null;index" json:"recipient_id"` - RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin'" json:"recipient_type"` + EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"` + RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"` + RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"` Type string `gorm:"column:type;type:varchar(50);not null" json:"type"` Title string `gorm:"column:title;type:varchar(200);not null" json:"title"` Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` @@ -92,13 +117,16 @@ func (Notification) TableName() string { return "tb_notification" } ## 五、发布侧:NotificationPublisher -业务代码调这个发布通知,内部异步入队: +非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。 + +领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。 ```go -// internal/service/notification/publisher.go +// internal/infrastructure/messaging/notification_publisher.go // SendPayload 发送通知的参数 type SendPayload struct { + EventID string // 领域事件ID或调用方请求ID,同一次重试必须保持不变 RecipientIDs []uint // 接收人ID列表 RecipientType string // admin | agent Type string // 通知类型常量 @@ -115,29 +143,37 @@ type NotificationPublisher struct { logger *zap.Logger } -// Publish 发布通知(异步,不阻塞业务) -// 失败只记录日志,不影响业务事务 -func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) { +// Publish 发布通知(异步) +// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。 +func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error { + if payload.EventID == "" { + return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空") + } if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil { p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type)) + return err } + return nil } ``` -业务代码调用示例(审批推进后通知下一审批人): +审批事件处理器调用示例(节点激活后通知候选审批人): ```go -p.notifyPublisher.Publish(ctx, notification.SendPayload{ +return h.notifyPublisher.Publish(ctx, notification.SendPayload{ + EventID: event.EventID, RecipientIDs: []uint{nextApproverID}, RecipientType: constants.NotifyRecipientAdmin, Type: constants.NotifyTypeApprovalPending, Title: "您有一条待审批记录", Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName), RefType: "approval", - RefID: approvalFlowID, + RefID: processInstanceID, }) ``` +审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。 + --- ## 六、消费侧:Asynq Task Handler @@ -164,6 +200,7 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T ref_id = &payload.RefID } records = append(records, model.Notification{ + EventID: payload.EventID, RecipientID: uid, RecipientType: payload.RecipientType, Type: payload.Type, @@ -175,7 +212,9 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T }) } - if err := h.db.WithContext(ctx).CreateInBatches(records, 100).Error; err != nil { + if err := h.db.WithContext(ctx). + Clauses(clause.OnConflict{DoNothing: true}). + CreateInBatches(records, 100).Error; err != nil { return fmt.Errorf("批量写入通知失败: %w", err) } @@ -193,7 +232,7 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T ### 7.1 未读数量(前端轮询用,30秒一次) ``` -GET /admin/notifications/unread-count +GET /api/admin/notifications/unread-count ``` 响应: @@ -204,7 +243,7 @@ GET /admin/notifications/unread-count ### 7.2 通知列表 ``` -GET /admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20 +GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20 ``` 请求参数: @@ -244,13 +283,13 @@ type NotificationListRequest struct { ### 7.3 标记已读 ``` -PUT /admin/notifications/{id}/read +PUT /api/admin/notifications/{id}/read ``` ### 7.4 全部标记已读 ``` -PUT /admin/notifications/read-all +PUT /api/admin/notifications/read-all ``` 可传 `type` 过滤(只把某类型全部已读): @@ -298,29 +337,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 扩展预留 diff --git a/docs/7月迭代/基础设施/系统配置.md b/docs/7月迭代/基础设施/系统配置.md index 26a6e1b..4558b49 100644 --- a/docs/7月迭代/基础设施/系统配置.md +++ b/docs/7月迭代/基础设施/系统配置.md @@ -1,6 +1,7 @@ # 基础设施:系统配置(tb_system_config) -> 被依赖:需求 02(H5流程配置)、需求 09(C端支付限制) +> 状态:待评审 +> 被依赖:需求 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 文本框。 diff --git a/docs/7月迭代/需求01-复机实名规则.md b/docs/7月迭代/需求01-复机实名规则.md index 073ad85..e612405 100644 --- a/docs/7月迭代/需求01-复机实名规则.md +++ b/docs/7月迭代/需求01-复机实名规则.md @@ -1,5 +1,7 @@ # 需求01:行业卡后台手动复机允许未实名 +> 状态:待评审 + --- ## 背景 diff --git a/docs/7月迭代/需求02-H5流程配置.md b/docs/7月迭代/需求02-H5流程配置.md index 25f2d0e..6ba7aeb 100644 --- a/docs/7月迭代/需求02-H5流程配置.md +++ b/docs/7月迭代/需求02-H5流程配置.md @@ -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` | | 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 | | 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 | diff --git a/docs/7月迭代/需求03-07-11-12-13-简单改动.md b/docs/7月迭代/需求03-07-11-12-13-简单改动.md index c239ad4..f8ab7d7 100644 --- a/docs/7月迭代/需求03-07-11-12-13-简单改动.md +++ b/docs/7月迭代/需求03-07-11-12-13-简单改动.md @@ -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,7 +140,7 @@ if req.RealNameStatus != nil { ### 前端 -IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。 +IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。 设备管理同上,列表展示 `real_name_status_name` 字段。 --- @@ -255,7 +260,9 @@ if req.OldAssetKeyword != "" { ### 核心原则 -提交人、审批人均在**写入时快照**到单据本身,读取时直接返回,不做额外查询。 +- 提交人账号名在业务单创建时快照到业务表。 +- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。 +- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。 --- @@ -287,35 +294,43 @@ SubmitterName string `json:"submitter_name" description:"提交人账号名"` ### COL-001:退款管理列表新增提交人、审批人(依赖审批流) -**迁移**:`tb_refund` 新增字段: +**迁移**:`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加: ```sql -ALTER TABLE tb_refund ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; -ALTER TABLE tb_refund ADD COLUMN dept_leader_name varchar(50) NOT NULL DEFAULT ''; -ALTER TABLE tb_refund ADD COLUMN finance_approver_name varchar(50) NOT NULL DEFAULT ''; +ALTER TABLE tb_refund_request + ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; ``` - `submitter_name`:创建退款单时快照操作人 username -- `dept_leader_name` / `finance_approver_name`:审批流各环节通过时快照对应审批人 username +- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取 -> **实施依赖**:`dept_leader_name` / `finance_approver_name` 在审批流(需求20)实现后才能写入,未实现前返回空字符串。`submitter_name` 本迭代即可实现。 +> **实施依赖**:动态审批摘要依赖审批流(需求20);`submitter_name` 可独立实现。 **响应 DTO**(退款列表响应新增): ```go -SubmitterName string `json:"submitter_name" description:"提交人账号名"` -DeptLeaderName string `json:"dept_leader_name" description:"部门领导审批人账号名"` -FinanceApproverName string `json:"finance_approver_name" description:"财务审批人账号名"` +SubmitterName string `json:"submitter_name" description:"提交人账号名"` +ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"` +ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"` +ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"` +CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"` +ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"` +ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"` +ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"` ``` +`ApprovalRecordSummary` 动态返回 `node_name`、`approval_mode`、`status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。 + +停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id`、`processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。 + --- ### COL-002:代理充值列表新增提交人、审批人(依赖审批流) -与 COL-001 同理,`tb_agent_recharge`(充值单表)新增同样三个字段,快照逻辑一致。 +与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息。 -> **实施依赖**:`submitter_name` 本迭代可实现;审批人字段依赖需求21(充值审批流)。 +> **实施依赖**:`submitter_name` 本迭代可实现;动态审批摘要依赖需求21(充值审批流)。 --- ### 前端 -各列表页新增对应字段列,展示即可,无额外交互。 +退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮。 diff --git a/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md b/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md index 9b40ad0..244ac32 100644 --- a/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md +++ b/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md @@ -1,6 +1,8 @@ -# 需求04:退款中资产禁止换货 +# 需求04:退款中资产禁止换货 # 需求06:资产最后到期时间 +> 状态:待评审 + --- ## 需求04:退款中禁止换货 @@ -9,8 +11,13 @@ 资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。 -未结束状态:1=待审批、4=已退回(退回给提交人修改中)。 -终态(不拦截):2=已通过、3=已拒绝。 +拦截范围: + +- `status=1` 待审批。 +- `status=4` 已退回,等待提交人修改。 +- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。 + +不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。 ### 数据模型 @@ -41,10 +48,17 @@ RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货) func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) { var count int64 err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). - Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", + Where(`iot_card_id = ? + AND deleted_at IS NULL + AND ( + status IN (?, ?) + OR (status = ? AND processing_status <> ?) + )`, cardID, model.RefundStatusPending, // 1=待审批 model.RefundStatusReturned, // 4=已退回(拦截) + model.RefundStatusApproved, // 2=审批已通过 + constants.ProcessingStatusSucceeded, ).Count(&count).Error return count > 0, err } @@ -53,10 +67,17 @@ func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (b func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) { var count int64 err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). - Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", + Where(`device_id = ? + AND deleted_at IS NULL + AND ( + status IN (?, ?) + OR (status = ? AND processing_status <> ?) + )`, deviceID, model.RefundStatusPending, // 1=待审批 model.RefundStatusReturned, // 4=已退回(拦截) + model.RefundStatusApproved, // 2=审批已通过 + constants.ProcessingStatusSucceeded, ).Count(&count).Error return count > 0, err } @@ -94,9 +115,11 @@ func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *res ### 业务规则 -资产详情页展示:**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。 +资产详情页展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最后到期时间**。 -(即所有排队套餐依次激活完毕后,资产最终什么时候到期) +不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。 + +加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。 ### 接口 @@ -115,69 +138,66 @@ GET /api/admin/assets/resolve/:identifier **DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`): ```go -// 所有套餐(生效中+待生效)中最晚的到期时间,无套餐时为 null -LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"所有套餐(生效中+待生效)的最后到期时间,无套餐时为 null"` +// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null +LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为null"` ``` **Store 新增方法**(`internal/store/postgres/package_usage_store.go`): ```go -// GetActiveAndQueuedByCardID 获取卡的生效中和待生效套餐 -func (s *PackageUsageStore) GetActiveAndQueuedByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) { +// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。 +func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) { var usages []*model.PackageUsage err := s.db.WithContext(ctx). - Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", cardID, + Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID, constants.PackageUsageStatusActive, // 1=生效中 constants.PackageUsageStatusPending, // 0=待生效 + constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期 ). - Order("created_at ASC"). + Order("priority ASC, created_at ASC, id ASC"). Find(&usages).Error return usages, err } -// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐 -func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) { +// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。 +func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) { var usages []*model.PackageUsage err := s.db.WithContext(ctx). - Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", deviceID, + Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID, constants.PackageUsageStatusActive, // 1=生效中 constants.PackageUsageStatusPending, // 0=待生效 + constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期 ). - Order("created_at ASC"). + Order("priority ASC, created_at ASC, id ASC"). Find(&usages).Error return usages, err } ``` -> 注:待生效套餐的 `expires_at` 可能为 null(实名即生效尚未实名),取已知 expires_at 中的最大值。 +新建 `PackageUsage` 必须快照 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`,与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。 **Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加): ```go -var lastExpiry *time.Time -var usages []*model.PackageUsage -if card != nil { - usages, _ = s.packageUsageStore.GetActiveAndQueuedByCardID(ctx, card.ID) -} else if device != nil { - usages, _ = s.packageUsageStore.GetActiveAndQueuedByDeviceID(ctx, device.ID) -} -for _, u := range usages { - if u.ExpiresAt == nil { - continue - } - if lastExpiry == nil || u.ExpiresAt.After(*lastExpiry) { - lastExpiry = u.ExpiresAt - } +// 1. 找当前主套餐的 expires_at 作为 cursor。 +// 2. 按 priority、created_at、id 遍历排队主套餐。 +// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。 +// 4. cursor 即预计最后到期时间。 +lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now) +if err != nil { + return nil, err } resp.LastPackageExpiresAt = lastExpiry ``` +`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。 + ### 前端 资产详情"套餐信息"板块新增展示: ``` -最后到期时间:2027-01-01 +预计最后到期时间:2027-01-01 ``` 读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。 diff --git a/docs/7月迭代/需求05-套餐分配生效条件.md b/docs/7月迭代/需求05-套餐分配生效条件.md index 867ffa6..9d7b0b0 100644 --- a/docs/7月迭代/需求05-套餐分配生效条件.md +++ b/docs/7月迭代/需求05-套餐分配生效条件.md @@ -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`。 diff --git a/docs/7月迭代/需求08-设备批量分配Excel.md b/docs/7月迭代/需求08-设备批量分配Excel.md index 2bf9604..cbbb0f1 100644 --- a/docs/7月迭代/需求08-设备批量分配Excel.md +++ b/docs/7月迭代/需求08-设备批量分配Excel.md @@ -1,13 +1,40 @@ -# 需求08:设备批量分配代理和套餐系列(Excel导入) +# 需求08:设备批量分配代理或套餐系列(Excel导入) + +> 状态:待评审 +> 模板:前端静态资源,后端仅负责上传校验和异步处理。 --- ## 背景 -设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)来批量指定分配目标。 +设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作: + +1. 批量分配设备给代理。 +2. 批量分配设备给套餐系列。 + +两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id` 和 `series_id`。 `Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。 +```mermaid +sequenceDiagram + actor User as 平台员工 + participant Web as 设备管理前端 + participant API as BatchAllocation API + participant DB as PostgreSQL + participant Worker as Asynq Worker + + User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel + Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series + API->>DB: 创建任务并保存上传文件引用 + API-->>Web: task_id + API->>Worker: 入队处理任务 + Worker->>DB: 按设备号批量查询并条件更新 + Worker->>DB: 写成功数、失败数和失败明细 + Web->>API: 轮询任务详情 + API-->>Web: 处理结果 +``` + --- ## 数据库变更 @@ -23,8 +50,9 @@ CREATE TABLE tb_device_batch_allocation_task ( creator BIGINT NOT NULL DEFAULT 0, updater BIGINT NOT NULL DEFAULT 0, task_no VARCHAR(30) NOT NULL, - shop_id BIGINT NOT NULL, - series_id BIGINT, + source_file_key VARCHAR(500) NOT NULL, + operation_type VARCHAR(30) NOT NULL, + target_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, total_count INT NOT NULL DEFAULT 0, success_count INT NOT NULL DEFAULT 0, @@ -36,10 +64,13 @@ CREATE TABLE tb_device_batch_allocation_task ( ); CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL; +CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC); ``` 状态常量:1=处理中,2=已完成,3=失败。 +`operation_type`:`assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。 + 失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。 --- @@ -52,8 +83,9 @@ type DeviceBatchAllocationTask struct { gorm.Model BaseModel `gorm:"embedded"` TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"` - ShopID uint `gorm:"column:shop_id;not null;comment:分配目标代理店铺ID" json:"shop_id"` - SeriesID *uint `gorm:"column:series_id;comment:套餐系列ID(可选)" json:"series_id,omitempty"` + SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"` + OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"` + TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID(代理店铺或套餐系列)" json:"target_id"` OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"` TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"` SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"` @@ -75,38 +107,56 @@ func (DeviceBatchAllocationTask) TableName() string { ## API 设计 -### 1. 下载 Excel 模板 +### 1. 前端静态 Excel 模板 -``` -GET /admin/devices/batch-allocation/template -``` +模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。 -响应:Excel 文件,表头为"设备号"(一列)。 +- 文件建议命名:`设备批量分配模板-v1.xlsx` +- 表头:`设备号`(一列) +- 前端“下载模板”按钮直接下载静态文件。 +- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。 + +模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。 ### 2. 上传并提交 ``` -POST /admin/devices/batch-allocation +POST /api/admin/devices/batch-assign-shop Content-Type: multipart/form-data 字段: shop_id: 123 (必填,分配给哪个代理) - series_id: 45 (可选,同时分配套餐系列) file: ``` +```text +POST /api/admin/devices/batch-assign-series +Content-Type: multipart/form-data + +字段: + series_id: 45 (必填,分配给哪个套餐系列) + file: +``` + +两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。 + **处理逻辑(Asynq 异步)**: -1. 解析 Excel,读取"设备号"列 -2. 逐行查找 `tb_device` 中匹配的设备(按 `virtual_no` 或 `imei`) -3. 对找到的设备:更新 `shop_id` + `series_id` -4. 未找到的:记录失败原因"设备号不存在" -5. 已分配给其他代理的:记录"该设备已分配,请先回收" +1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务;Worker 从对象存储下载并解析 Excel +2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no` 或 `imei`),禁止逐行查询 +3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id`;`assign_series` 仅更新 `series_id`。 +4. 未找到的:记录失败原因"设备号不存在"。 +5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收";`assign_series` 不改变代理归属。 +6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。 + +临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。 + +限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。 ### 3. 查询任务状态 ``` -GET /admin/devices/batch-allocation/{task_id} +GET /api/admin/devices/batch-allocation/{task_id} ``` 响应: @@ -114,6 +164,8 @@ GET /admin/devices/batch-allocation/{task_id} ```json { "task_no": "DBA20240101001", + "operation_type": "assign_shop", + "target_id": 123, "status": 2, "status_name": "已完成", "total_count": 100, @@ -134,18 +186,18 @@ GET /admin/devices/batch-allocation/{task_id} ### 入口 -设备管理页 > 批量操作 > "批量分配代理" +设备管理页 > 批量操作: + +- "批量分配代理" +- "批量分配套餐系列" ### 操作流程 -1. 点击"批量分配代理" -2. 弹框内: - - 代理选择器(搜索+选择目标代理,必填) - - 套餐系列选择器(可选) - - Excel 上传区域(含模板下载链接) -3. 上传后点击"提交" → `POST /admin/devices/batch-allocation` +1. 点击其中一个批量操作入口。 +2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。 +3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop` 或 `POST /api/admin/devices/batch-assign-series`。 4. 提示"任务提交成功,正在处理..." -5. 轮询 `GET /admin/devices/batch-allocation/{task_id}` 直到 `status != 1` +5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1` 6. 展示结果:成功X条,失败X条,失败明细在页面展示 ### Excel 规范 diff --git a/docs/7月迭代/需求09-C端支付限制配置化.md b/docs/7月迭代/需求09-C端支付限制配置化.md index 9df9ed3..f6c9b9a 100644 --- a/docs/7月迭代/需求09-C端支付限制配置化.md +++ b/docs/7月迭代/需求09-C端支付限制配置化.md @@ -1,5 +1,6 @@ # 需求09:C端支付方式限制配置化 +> 状态:待评审 > 依赖:[系统配置](./基础设施/系统配置.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`。 > 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。 diff --git a/docs/7月迭代/需求10-限速规则.md b/docs/7月迭代/需求10-限速规则.md index fae21fb..d89d634 100644 --- a/docs/7月迭代/需求10-限速规则.md +++ b/docs/7月迭代/需求10-限速规则.md @@ -1,194 +1,136 @@ -# 需求10:限速规则 +# 需求10:Gateway 手动卡限速 + +> 状态:待评审 +> 已确认边界:本期只提供手动设置/取消限速;Gateway 最终对象始终是卡,统一使用 `cardNo`。 --- -## 背景 +## 一、范围 -Gateway 已有 `SetSpeedLimit` 接口,支持卡和设备: +本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。 + +- 单卡资产:使用 `IotCard.ICCID` 作为 `cardNo`。 +- 设备资产:查询 `tb_device_sim_binding.is_current=true` 的当前绑定卡,再使用该卡 ICCID。 +- `speed_kbps > 0` 表示设置限速;`speed_kbps = 0` 表示取消限速。 +- 内部业务单位固定为 `kbps`,前端也按 `kbps` 输入和展示。 + +本期不做: + +- 套餐限速字段或套餐编辑页限速配置。 +- 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。 +- 设备 IMEI 限速。 +- “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。 + +取消限速仍调用同一个 Gateway 方法。`speed_kbps=0` 是本系统语义,Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。 + +--- + +## 二、流程 + +```mermaid +sequenceDiagram + actor User as 平台员工 + participant Web as 资产详情页 + participant API as SpeedLimit Application + participant DB as PostgreSQL + participant Resolver as 当前卡解析器 + participant Gateway as Gateway Adapter + + User->>Web: 输入限速值或点击取消 + Web->>API: asset identifier + speed_kbps + request_id + API->>Resolver: 解析实际 cardNo + Resolver-->>API: ICCID 或无当前卡错误 + API->>DB: 创建或复用限速操作记录 + API->>Gateway: SetSpeedLimit(cardNo, speedKbps) + Gateway-->>API: 成功或失败 + API->>DB: 写结果、错误摘要和操作审计 + API-->>Web: 返回本次操作结果 +``` + +设备不存在当前绑定卡时,接口返回业务错误并记录失败原因;绝不把设备 IMEI、设备 ID 或空字符串发送给 Gateway。 + +--- + +## 三、应用端口与数据 ```go -// internal/gateway/device.go -func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error +// GatewaySpeedLimitPort 设置或取消卡限速。 +// speedKbps=0 表示取消,具体上游参数由适配器转换。 +type GatewaySpeedLimitPort interface { + SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error +} -// internal/gateway/models.go -type SpeedLimitReq struct { - CardNo string `json:"cardNo,omitempty"` // 卡(与 DeviceID 二选一) - DeviceID string `json:"deviceId,omitempty"` // 设备(与 CardNo 二选一) - SpeedLimit int `json:"speedLimit"` // 限速值(KB/s),0=不限速 - Extend string `json:"extend,omitempty"` +// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。 +type SpeedLimitCardResolver interface { + ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error) } ``` -**"根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则"** - -设计思路:限速规则配置在**套餐**上(不同运营商、不同流量档位对应不同套餐,套餐本身就是差异载体)。套餐激活时自动调用 Gateway 设置限速。 - ---- - -## 数据库变更 +新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试: ```sql --- 套餐表增加限速字段 -ALTER TABLE tb_package - ADD COLUMN speed_limit_kbps INT NOT NULL DEFAULT 0 - COMMENT '限速值(KB/s),0=不限速'; +CREATE TABLE tb_gateway_speed_limit_operation ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64) NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + card_no VARCHAR(100) NOT NULL, + desired_speed_kbps INT NOT NULL, + status INT NOT NULL DEFAULT 1, + error_summary TEXT NOT NULL DEFAULT '', + operator_id BIGINT NOT NULL, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_gateway_speed_limit_operation_request + ON tb_gateway_speed_limit_operation(request_id); +CREATE INDEX idx_gateway_speed_limit_operation_asset + ON tb_gateway_speed_limit_operation(asset_type, asset_id, created_at DESC); ``` +状态:`1=处理中, 2=成功, 3=失败`。同一 `request_id` 重试时,任务、操作人、资产和目标限速一致才返回或继续原记录;不一致返回冲突。Gateway 网络超时后允许使用相同目标值重试,因为 `SetSpeedLimit` 是“设为目标状态”的幂等操作。 + --- -## Model 变更 +## 四、接口与前端 -```go -// internal/model/package.go -type Package struct { - // ...原有字段... - SpeedLimitKbps int `gorm:"column:speed_limit_kbps;type:int;not null;default:0;comment:限速值(KB/s),0=不限速" json:"speed_limit_kbps"` +```text +POST /api/admin/assets/{identifier}/speed-limit +``` + +```json +{ + "request_id": "01JZ...", + "speed_kbps": 1024 } ``` +- `speed_kbps` 必须为整数且大于等于 0。 +- 取消操作提交 `speed_kbps=0`,不新增第二个取消接口。 +- 后端根据 `identifier` 解析资产类型和数据范围,前端不得传 `cardNo`、设备 ID 或资产类型作为权威依据。 + +资产详情页提供两个独立命令: + +- 数字输入框加“保存”用于设置限速,单位固定显示 `kbps`。 +- 取消按钮用于提交 `speed_kbps=0`,并使用确认弹窗避免误操作。 + +设备详情页显示“当前使用卡 ICCID”。没有当前卡时禁用两个命令并展示后端返回的原因。页面可展示最近一次操作的目标限速、结果、操作人和时间,但不能把该记录描述为 Gateway 的实时状态。 + --- -## 后端实现 +## 五、审计与人工验收 -### 1. 套餐激活时设置限速 +每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。 -在 `internal/service/package/activation_service.go` 中,套餐激活(`ActivatedAt` 赋值)时调用限速: +人工验收覆盖: -```go -// activatePackageUsage 套餐激活核心逻辑(已有函数,追加限速调用) -func (s *ActivationService) activatePackageUsage(ctx context.Context, usage *model.PackageUsage, pkg *model.Package) error { - // ...现有激活逻辑... +1. 单卡按 ICCID 设置限速。 +2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。 +3. 设备无当前卡时拒绝调用 Gateway。 +4. `speed_kbps=0` 经同一 Gateway 方法取消限速。 +5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。 +6. Gateway 失败记录错误并允许相同目标值重试。 - // 套餐有限速配置时,调用 Gateway 设置 - if pkg.SpeedLimitKbps > 0 { - if err := s.applySpeedLimit(ctx, usage, pkg.SpeedLimitKbps); err != nil { - // 限速失败不阻断套餐激活,记录错误日志 - s.logger.Error("设置限速失败", zap.Error(err), - zap.Uint("package_usage_id", usage.ID), - zap.Int("speed_limit", pkg.SpeedLimitKbps)) - } - } - return nil -} - -// applySpeedLimit 调用 Gateway 设置限速 -func (s *ActivationService) applySpeedLimit(ctx context.Context, usage *model.PackageUsage, speedKbps int) error { - req := &gateway.SpeedLimitReq{SpeedLimit: speedKbps} - - switch usage.UsageType { - case constants.PackageUsageTypeSingleCard: // "single_card",定义在 pkg/constants/iot.go - // 查卡的 ICCID - card, err := s.iotCardStore.GetByID(ctx, usage.IotCardID) - if err != nil { return err } - req.CardNo = card.ICCID - - case constants.PackageUsageTypeDevice: // "device",定义在 pkg/constants/iot.go - // 查设备的 IMEI - device, err := s.deviceStore.GetByID(ctx, usage.DeviceID) - if err != nil { return err } - req.DeviceID = device.IMEI - } - - return s.gatewayClient.SetSpeedLimit(ctx, req) -} -``` - -### 2. 套餐到期/失效时取消限速 - -当套餐状态变为 `已过期(3)` 或 `已失效(4)` 时,重置限速为 0(不限速)。 - -在套餐过期处理任务中追加: - -```go -// 套餐过期时取消限速(SpeedLimit=0 表示不限速) -if expiredPkg.SpeedLimitKbps > 0 { - s.applySpeedLimit(ctx, usage, 0) // 0 = 不限速 -} -``` - -> **注意**:如果有续费的排队套餐立即生效,应以新套餐的限速为准,而不是先取消再设置。 - -### 3. 手动操作限速(后台管理) - -``` -POST /admin/iot-cards/{id}/speed-limit -POST /admin/devices/{id}/speed-limit -``` - -请求体: -```go -type SetSpeedLimitRequest struct { - SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值(KB/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. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。 +上线前置条件:Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。 diff --git a/docs/7月迭代/需求14-导出功能.md b/docs/7月迭代/需求14-导出功能.md index cfd50f3..2b87436 100644 --- a/docs/7月迭代/需求14-导出功能.md +++ b/docs/7月迭代/需求14-导出功能.md @@ -1,6 +1,7 @@ # 需求14:导出功能 -> 复用现有 ExportTask 体系(`tb_export_task` + Asynq) +> 状态:待评审 +> 复用现有 ExportTask 体系(`tb_export_task` + Asynq),不为每个业务模块新增一套导出路由。 --- @@ -14,6 +15,7 @@ | 6.8.4 | 退款管理退款列表导出 | **全新** | | 6.8.5 | 换货管理导出 | **全新** | | 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) | +| 需求22 | 临期资产列表导出 | **全新**,`scene=expiring_asset` | --- @@ -26,11 +28,47 @@ - Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行 - 前端轮询任务状态后下载 +```mermaid +sequenceDiagram + actor User as 后台用户 + participant Web as 前端 + participant API as ExportTask API + participant DB as PostgreSQL + participant Worker as Asynq Worker + participant Storage as 对象存储 + + User->>Web: 选择导出字段并确认 + Web->>API: POST /api/admin/export-tasks + API->>API: 计算数据范围和字段权限交集 + API->>DB: 保存查询、范围和字段快照 + API-->>Web: task_id + API->>Worker: 入队导出任务 + Worker->>DB: 按 scene 分片查询 + Worker->>Storage: 上传导出文件 + Worker->>DB: 更新进度和 download file_key + Web->>API: 轮询 GET /api/admin/export-tasks/{id} + API-->>Web: 状态、进度、download_url +``` + 新增导出模块需要: 1. 在 `pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量 2. 在 `internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`) -3. 在 `registry.go` 的 `NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()` -4. 新增对应的 Export API(创建导出任务,传入 `scene` 字段) +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/` 必须同步维护。 --- @@ -66,7 +104,7 @@ DataLimitMB int64 `gorm:"column:data_limit_mb"` ## 6.8.2:代理资金概况-预充值钱包流水导出 -**新增接口**:`POST /admin/agent-wallet-transactions/export` +**新增场景**:`scene=agent_wallet_transaction` 支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。 @@ -83,7 +121,7 @@ DataLimitMB int64 `gorm:"column:data_limit_mb"` | 交易时间 | `created_at` | | 交易前金额 | `balance_before / 100` | | 交易后金额 | `balance_after / 100` | -| 购买套餐名称 | `metadata` 中 JSON 字段 `package_name`,或 JOIN 订单 | +| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 | | 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) | | 交易 ID | `id` | | 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) | @@ -93,11 +131,11 @@ DataLimitMB int64 `gorm:"column:data_limit_mb"` ## 6.8.3:套餐列表导出 -**新增接口**:`POST /admin/packages/export` +**新增场景**:`scene=package` 支持现有套餐列表筛选条件。 -导出字段(按需求文档 24 个字段): +导出字段(按实际列举的 25 个稳定字段 Key): ```go type PackageExportRow struct { @@ -133,9 +171,9 @@ type PackageExportRow struct { ## 6.8.4:退款管理退款列表导出 -**新增接口**:`POST /admin/refund-orders/export` +**新增场景**:`scene=refund` -导出字段(去掉"退款到账方式",保留其余字段,部门领导/财务审批人依赖审批流): +导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点): ```go type RefundExportRow struct { @@ -153,21 +191,25 @@ type RefundExportRow struct { Status string `xlsx:"状态"` RefundReason string `xlsx:"退款原因"` Remark string `xlsx:"备注"` - ApprovalRemark string `xlsx:"审批备注"` + ApprovalSource string `xlsx:"审批来源"` + ApprovalStatus string `xlsx:"审批状态"` + ProcessingStatus string `xlsx:"退款处理状态"` + CurrentNodeName string `xlsx:"当前审批节点"` + ApprovalRecords string `xlsx:"审批记录"` AppliedAt string `xlsx:"退款申请时间"` CompletedAt string `xlsx:"退款完成时间"` SubmitterName string `xlsx:"提交人"` - DeptLeaderName string `xlsx:"部门领导审批人"` - FinanceName string `xlsx:"财务审批人"` VoucherURLs string `xlsx:"退款凭证"` } ``` +`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。 + --- ## 6.8.5:换货管理导出 -**新增接口**:`POST /admin/exchange-orders/export` +**新增场景**:`scene=exchange` ```go type ExchangeExportRow struct { @@ -193,7 +235,7 @@ type ExchangeExportRow struct { ## 6.8.6:代理充值导出 -**新增接口**:`POST /admin/agent-recharge-orders/export` +**新增场景**:`scene=agent_recharge` 去掉"支付通道"字段(需求文档中明确去掉),保留其他字段: @@ -215,13 +257,17 @@ type AgentRechargeExportRow struct { PaidAt string `xlsx:"支付时间"` CompletedAt string `xlsx:"完成时间"` SubmitterName string `xlsx:"提交人"` - DeptLeaderName string `xlsx:"部门领导审批人"` - FinanceName string `xlsx:"财务审批人"` + ApprovalSource string `xlsx:"审批来源"` + ApprovalStatus string `xlsx:"审批状态"` + CurrentNodeName string `xlsx:"当前审批节点"` + ApprovalRecords string `xlsx:"审批记录"` VoucherURLs string `xlsx:"支付凭证"` Remark string `xlsx:"备注"` } ``` +充值导出的审批记录格式和批量查询规则与退款导出一致。 + --- ## 前端对接(通用模式) @@ -229,19 +275,138 @@ type AgentRechargeExportRow struct { 各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。 调用流程: -1. 点击"导出" → 携带当前筛选条件 → `POST /admin/{module}/export` -2. 返回 `export_task_id` -3. 前端轮询 `GET /admin/export-tasks/{id}` 直到 `status=completed` -4. 下载 `download_url` +1. 点击“导出”后请求当前账号在该场景可导出的字段目录。 +2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。 +3. 提交 `scene + format + query + fields` 到 `POST /api/admin/export-tasks`。 +4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。 +5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。 (与现有导出体系完全一致,复用现有前端导出 Hook) --- -## 权限导出配置(预留) +## 导出字段权限 需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。 -**本次迭代**:统一导出全量字段,不做权限差异化(复杂度高,单独排期)。 +评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。 -**预留方案**:在导出 Handler 里预留 `filterFieldsByRole(userRoles, rows)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。 +该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。 + +### 1. 字段目录 + +每个场景在代码中注册稳定字段 key: + +```go +// ExportFieldDefinition 导出字段定义 +type ExportFieldDefinition struct { + Key string + Header string + DefaultSelected bool + Required bool +} +``` + +示例: + +```text +scene=package +package_code 套餐编码 默认选择 +package_name 套餐名称 默认选择 +real_data_mb 真流量额度 敏感字段 +virtual_data_mb 虚流量额度 默认选择 +cost_price 成本价 敏感字段 +``` + +表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。 + +### 2. 角色字段授权表 + +```sql +CREATE TABLE tb_role_export_field_permission ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + scene VARCHAR(50) NOT NULL, + field_key VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + creator BIGINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX idx_role_export_field_permission + ON tb_role_export_field_permission(role_id, scene, field_key); +``` + +账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。 + +### 3. 权限 API + +```text +GET /api/admin/export-fields?scene=package +``` + +返回当前账号可选择的字段: + +```json +{ + "scene": "package", + "fields": [ + { + "key": "package_code", + "header": "套餐编码", + "default_selected": true, + "required": true + } + ] +} +``` + +角色管理: + +```text +GET /api/admin/roles/{role_id}/export-fields +PUT /api/admin/roles/{role_id}/export-fields +``` + +更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。 + +### 4. 创建任务时的权限快照 + +创建任务时计算: + +```text +resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields +``` + +- 必选字段由后端自动补齐。 +- 交集为空时拒绝创建任务。 +- `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`,Worker 不重新根据后来变化的角色权限扩大字段。 +- Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。 + +### 5. 前端角色配置 + +- 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。 +- 普通列表的导出弹框只显示当前账号被授权的字段。 +- 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。 +- 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。 + +--- + +## 查询与性能约束 + +- 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。 +- 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。 +- DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。 +- 动态字段不代表动态拼接不受控 SQL;字段 key 必须通过服务端白名单映射到固定查询列。 + +--- + +## 发布与回滚 + +本需求随七月迭代停机发布: + +1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。 +2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。 +3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。 +4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。 + +禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。 diff --git a/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md b/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md index 10c6fdc..15c0d3e 100644 --- a/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md +++ b/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md @@ -1,5 +1,8 @@ # 需求15/16/18/19/20/21 技术方案 +> 状态:待评审 +> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。 + --- ## 需求15:套餐下架后允许续费 @@ -10,145 +13,81 @@ - 下架套餐**可以续费**(已在使用该套餐的客户) - 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户) +```mermaid +flowchart TD + Start[请求购买套餐] --> Enabled{套餐是否启用?} + Enabled -->|否| RejectDisabled[拒绝:套餐已禁用] + Enabled -->|是| Shelf{是否已下架?} + Shelf -->|否| Allow[允许继续下单] + Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?} + Renewal -->|否| RejectNew[拒绝:下架套餐不可新购] + Renewal -->|是| Actor{是否客户本人续费?} + Actor -->|是| Allow + Actor -->|否| RejectProxy[拒绝:下架套餐不可代购] +``` + +“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 `is_renewal=true`。 + ### 后端 **当前逻辑**:下架套餐在购买时被拦截。 -修改:在订单创建校验中,区分"新购"和"续费"场景: +修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 `is_renewal`: ```go // internal/service/order/service.go 或 client_order/service.go func (s *Service) validatePackageAvailability( ctx context.Context, pkg *model.Package, - assetID uint, - isRenewal bool, + asset *ResolvedAsset, + actor *PurchaseActor, ) error { if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go return errors.New(errors.CodeForbidden, "套餐已禁用") } - if pkg.ShelfStatus == constants.ShelfStatusOff && !isRenewal { // 2=下架 - return errors.New(errors.CodeForbidden, "套餐已下架,不可新购") + if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架 + if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) { + return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费") + } } return nil } ``` -**isRenewal 判断**:查当前资产是否有该套餐的历史生效记录: +**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格: ```go -func (s *Service) isRenewal(ctx context.Context, assetType string, assetID uint, packageID uint) bool { - count, _ := s.packageUsageStore.CountByAssetAndPackage(ctx, assetType, assetID, packageID) - return count > 0 +func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool { + return actor.OwnsAsset(asset) && + s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID) } ``` ### 前端 -C端续费页面:下架套餐不在"新购"列表中展示,但在"续费"入口中仍可展示。 +C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。 后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。 --- -## 需求16:代理分销码与佣金提现 +## 需求16:代理分销码与佣金提现(已移出7月迭代) -### 业务规则 +> 状态:已移出本期 +> 决策日期:2026-07-14 +> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布 -**DST-001**:新建代理时自动建立分销归属关系(指定发展人) -**DST-002**:员工可作为代理发展人进行标识 -**二维码**:代理或员工生成推广二维码 → 扫码进入H5填写信息 → 提交成为代理申请 → 平台审批 +本次范围调整包含整个需求 16: -### 数据库变更 +- 代理/员工分销码和推广二维码。 +- H5 代理申请、进度查询和退回重提。 +- 代理申请接入通用审批及审批后自动开店。 +- 店铺发展人关系和代理申请来源字段。 +- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。 -```sql --- 1. Shop 表新增发展人字段 -ALTER TABLE tb_shop - ADD COLUMN referrer_type VARCHAR(20) DEFAULT NULL - COMMENT '发展人类型 shop=代理介绍 admin=员工介绍', - ADD COLUMN referrer_id BIGINT DEFAULT NULL - COMMENT '发展人ID(shop_id 或 tb_account.id)', - ADD COLUMN referrer_name VARCHAR(50) DEFAULT NULL - COMMENT '发展人姓名快照'; +因此 7 月迭代不创建 `tb_distribution_code`、`tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API,不发布 `agent_application_approval`,也不建设对应前端页面。 --- 2. 分销码表 -CREATE TABLE tb_distribution_code ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - code VARCHAR(32) NOT NULL, - owner_type VARCHAR(20) NOT NULL, -- shop=代理 | admin=员工 - owner_id BIGINT NOT NULL, - owner_name VARCHAR(50) NOT NULL DEFAULT '', - qr_code_url TEXT, - use_count INT NOT NULL DEFAULT 0, - status INT NOT NULL DEFAULT 1, -- 1=有效 0=禁用 - expires_at TIMESTAMPTZ -); -CREATE UNIQUE INDEX idx_distribution_code ON tb_distribution_code(code) WHERE deleted_at IS NULL; - --- 3. 代理申请表(H5扫码提交) -CREATE TABLE tb_agent_application ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - apply_no VARCHAR(30) NOT NULL, - distribution_code VARCHAR(32) NOT NULL, - referrer_type VARCHAR(20) NOT NULL, - referrer_id BIGINT NOT NULL, - applicant_name VARCHAR(50) NOT NULL, - applicant_phone VARCHAR(20) NOT NULL, - shop_name VARCHAR(100) NOT NULL, - province VARCHAR(50), - city VARCHAR(50), - district VARCHAR(50), - address VARCHAR(255), - status INT NOT NULL DEFAULT 1, -- 1=待审批 2=已通过 3=已拒绝 - reject_reason TEXT, - reviewed_by BIGINT, - reviewed_at TIMESTAMPTZ -); -CREATE UNIQUE INDEX idx_agent_application_no ON tb_agent_application(apply_no) WHERE deleted_at IS NULL; -``` - -### API 设计 - -**生成分销码**: -``` -POST /admin/distribution-codes -body: { owner_type: "admin"|"shop", owner_id: 123 } -``` - -**H5 扫码获取分销码信息**: -``` -GET /app/distribution-codes/{code} -``` - -**H5 提交代理申请**: -``` -POST /app/agent-applications -body: { distribution_code, applicant_name, applicant_phone, shop_name, ... } -``` - -**后台代理申请列表/审批**: -``` -GET /admin/agent-applications?status=1 -POST /admin/agent-applications/{id}/approve -POST /admin/agent-applications/{id}/reject -body: { reject_reason: "..." } -``` - -审批通过时:自动创建 `tb_shop` + `tb_account`,设置 `referrer_type` 和 `referrer_id`。 - -**佣金提现(DST-003~007)**: - -现有 commission 框架基础上新增材料上传字段,提现申请时一次性上传所有材料(对象存储 Key 列表)。 +通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力,但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。 --- @@ -160,46 +99,99 @@ body: { reject_reason: "..." } APR-009(企微审批对接)= Phase 2。 +```mermaid +sequenceDiagram + actor Applicant as 提交人 + participant Biz as 退款/充值业务 + participant Approval as 审批流 + participant Notice as 站内消息 + actor Approver1 as 当前节点审批人 + actor Approver2 as 下一节点审批人 + + Applicant->>Biz: 提交申请 + Biz->>Approval: 同事务创建流程实例和首任务 + Approval->>Notice: TaskCreated + Notice-->>Approver1: 待审批提醒 + Approver1->>Approval: 审批通过 + Approval->>Notice: 下一节点 TaskCreated + Notice-->>Approver2: 待审批提醒 + Approver2->>Approval: 通过/驳回/退回 + Approval->>Notice: 流程结果事件 + Notice-->>Applicant: 结果和原因 +``` + ### 实现要点 | 编号 | 需求 | 实现 | |------|------|------| -| APR-001~003 | 充值/退款多级审核 | 见需求20/21 | -| APR-004 | 审核环节:部门领导→财务 | 审批流固定两步:step=1 部门领导,step=2 财务 | +| APR-001~003 | 充值/退款多级审核 | 见需求20/21;需求16已移出本期 | +| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 | | APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` | -| APR-006 | 上一级完成后才提示下一级 | `ApprovalFlowAggregate.Approve()` 触发 `ApprovalStepAdvancedEvent` | -| APR-007 | 通过后通知申请人 | `ApprovalCompletedEvent` handler | -| APR-008 | 驳回/退回后通知申请人含原因 | `ApprovalRejectedEvent` / `ApprovalReturnedEvent` handler | +| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 | +| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 | +| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 | | APR-009 | 对接企微 | **Phase 2** | +### 默认流程与可配置边界 + +- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。 +- 每个节点可配置 `role` 或 `user` 审批人来源,以及 `any` 或 `all` 完成方式。 +- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。 +- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。 +- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。 +- 已发起实例始终使用发起时保存的流程定义快照。 + ### 业务表与审批流的关联 -充值单、退款单接入审批流,各自新增 `approval_flow_id` 字段: +充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。 -```sql -ALTER TABLE tb_refund_request ADD COLUMN approval_flow_id BIGINT DEFAULT NULL - COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)'; +业务表只保存当前审批实例 ID,历史实例通过 `biz_type + biz_id` 查询。接入协议详见 [审批流文档 - 业务接入协议](./基础设施/审批流.md#十一业务接入协议)。 -ALTER TABLE tb_agent_recharge_record ADD COLUMN approval_flow_id BIGINT DEFAULT NULL - COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)'; -``` +退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。 -接入协议详见 [审批流文档 - 接入协议](./基础设施/审批流.md#六接入协议业务层如何接入审批流)。 +### 前端技术方案 + +- 新增“待我审批”和动态审批详情,具体页面、状态和请求幂等规则见 [前端技术方案](./前端技术方案.md#六统一审批交互)。 +- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。 +- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。 +- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。 +- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。 +- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。 --- ## 需求19:批量订购套餐(BPO-001~008) +### 支付方式粒度 + +评审结论:支付方式按**整批统一**设计: + +- 页面选择 `offline` 或 `agent_wallet`,Excel 不再重复填写支付方式。 +- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。 +- Excel 不包含支付方式列,后端拒绝同一批次混合支付。 + ### 业务流程 -1. 员工进入批量订购页面 -2. 选择代理 + 上传 Excel(字段:资产类型/资产标识/套餐编码/套餐名称/支付方式) -3. 系统解析并校验每一行 -4. 支付方式:`offline=线下支付(默认成功)` / `agent_wallet=代理钱包支付` -5. 代理钱包支付时从代理余额扣款(含信用额度) -6. 线下支付时标记为已支付(不走钱包) -7. 校验失败的行展示在页面,带失败原因 -8. **线下支付需上传整批次的支付凭证** +```mermaid +flowchart TD + Start[员工选择代理和支付方式] --> Upload[上传 Excel] + Upload --> Parse[解析并持久化逐行明细] + Parse --> Validate[校验资产、套餐、归属和重复行] + Validate --> Item{处理下一条有效明细} + Item -->|代理钱包| Lock[锁定钱包并校验可用余额] + Item -->|线下支付| Voucher[校验整批支付凭证] + Lock --> Order[单条事务创建订单并扣款] + Voucher --> OrderOffline[单条事务创建已支付订单] + Order --> Result[记录订单 ID 和成功状态] + OrderOffline --> Result + Result --> More{还有待处理明细?} + More -->|是| Item + More -->|否| Summary[汇总任务结果] + Validate -->|校验失败| Failed[记录结构化失败原因] + Failed --> More +``` + +任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。 ### 数据库变更 @@ -212,45 +204,121 @@ CREATE TABLE tb_bulk_purchase_task ( creator BIGINT NOT NULL DEFAULT 0, updater BIGINT NOT NULL DEFAULT 0, task_no VARCHAR(30) NOT NULL, + source_file_key VARCHAR(500) NOT NULL, shop_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, - payment_method VARCHAR(20) NOT NULL, -- offline | agent_wallet - voucher_keys JSONB, -- 线下支付凭证列表 + payment_method VARCHAR(20) NOT NULL, + voucher_keys JSONB NOT NULL DEFAULT '[]', + total_amount BIGINT NOT NULL DEFAULT 0, total_count INT NOT NULL DEFAULT 0, success_count INT NOT NULL DEFAULT 0, fail_count INT NOT NULL DEFAULT 0, - status INT NOT NULL DEFAULT 1, -- 1=处理中 2=已完成 - failed_items JSONB -- 失败明细 + status INT NOT NULL DEFAULT 1, + error_message TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ ); -CREATE UNIQUE INDEX idx_bulk_purchase_task_no ON tb_bulk_purchase_task(task_no) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX idx_bulk_purchase_task_no + ON tb_bulk_purchase_task(task_no) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_bulk_purchase_item ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + task_id BIGINT NOT NULL, + row_no INT NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_identifier VARCHAR(100) NOT NULL, + package_code VARCHAR(50) NOT NULL, + package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '', + package_id BIGINT, + amount BIGINT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + order_id BIGINT, + failure_code VARCHAR(50) NOT NULL DEFAULT '', + failure_reason VARCHAR(500) NOT NULL DEFAULT '', + idempotency_key VARCHAR(100) NOT NULL, + processed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row + ON tb_bulk_purchase_item(task_id, row_no); + +CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency + ON tb_bulk_purchase_item(idempotency_key); + +CREATE INDEX idx_bulk_purchase_item_task_status + ON tb_bulk_purchase_item(task_id, status); ``` -> 失败明细存 JSONB(`failed_items`),与现有 DeviceImportTask 模式一致,无需单独明细表。 +状态建议: + +- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`。 +- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`。 + +### 处理与幂等 + +1. API 校验文件、代理、支付方式和凭证,将 Excel 保存到对象存储并创建任务,返回 `task_id`。 +2. Worker 根据 `source_file_key` 下载并解析 Excel,将每一行先写入明细表,再开始业务处理。 +3. 同一任务按行顺序处理,避免对同一代理钱包制造不必要的乐观锁冲突。 +4. 每行使用独立事务。代理钱包支付时锁定钱包记录,校验 `balance - frozen_balance + credit_limit` 后,在同一事务扣款、创建订单、资金流水并更新明细。 +5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。 +6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。 +7. 任务汇总从明细表计算,不信任内存计数。 + +Asynq 载荷只传任务 ID,不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理,确保 Worker 重试期间仍可读取。 + +建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间。 ### API 设计 -**下载 Excel 模板**: -``` -GET /admin/bulk-purchases/template +**前端静态 Excel 模板**: + +模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为: + +```text +资产类型 | 资产标识 | 套餐编码 | 套餐名称 ``` +`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。 + **上传并提交**: -``` -POST /admin/bulk-purchases + +```text +POST /api/admin/bulk-purchases Content-Type: multipart/form-data -字段: - shop_id: 123 - payment_method: offline | agent_wallet - voucher_keys: ["key1","key2"] (offline 时必填) - file: +shop_id: 123 +payment_method: offline | agent_wallet +voucher_keys: ["key1","key2"] +file: ``` +`offline` 时 `voucher_keys` 必填,`agent_wallet` 时忽略该字段。 + **查询任务状态**: + +```text +GET /api/admin/bulk-purchases/{task_id} ``` -GET /admin/bulk-purchases/{task_id} + +**分页查询任务明细**: + +```text +GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50 ``` +### 前端技术方案 + +- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。 +- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。 +- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。 +- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。 +- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。 +- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。 + --- ## 需求20:退款审批 @@ -265,32 +333,137 @@ GET /admin/bulk-purchases/{task_id} 1=待审批 2=已通过 3=已拒绝 4=已退回 ``` -退款单本身的状态含义不变,审批进度由 `tb_approval_flow` 管理,两者通过 `approval_flow_id` 关联。 +退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态: + +```text +processing_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,且不超过申请退款金额和订单实收金额,并写入退款单和审批操作日志。会签需要指定金额决策人时,流程定义增加其专属最终节点,禁止第一位会签人预先锁定金额。 ### 流程 -``` -提交退款申请(POST /admin/refund-requests) - → 创建 RefundRequest(status=1 待审批) - → 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=refund) - → 把 approval_flow_id 写入 RefundRequest +```mermaid +sequenceDiagram + actor Applicant as 提交人 + actor Approver as 审批人 + participant Refund as Refund Application + participant Approval as Approval Application + participant DB as PostgreSQL + participant Worker as AgentWalletRefundHandler + actor Finance as 财务人员 -审批人操作(POST /admin/approvals/{flow_id}/approve|reject|return) - → ApprovalCompletedEvent → RefundRequest.status=2(已通过)→ 触发实际退款 - → ApprovalRejectedEvent → RefundRequest.status=3(已拒绝) - → ApprovalReturnedEvent → RefundRequest.status=4(已退回)→ 通知提交人可修改 + Applicant->>Refund: POST /api/admin/refunds + Refund->>DB: 同事务创建退款单(status=1) + Refund->>Approval: StartProcess(refund, refund_id) + Approval->>DB: 创建实例、首任务、审批人、Outbox + Refund->>DB: 回写 approval_instance_id + Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额 + Approval->>DB: 提交 ProcessApproved/Rejected/Returned + alt 代理钱包支付且审批通过 + DB-->>Worker: Outbox + Asynq 至少一次投递 + Worker->>DB: claim processing_status=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)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。 + ### 退回后重新提交 ``` -PUT /admin/refund-requests/{id} 仅 status=4 时可编辑退款单内容 -POST /admin/refund-requests/{id}/resubmit +POST /api/admin/refunds/{id}/resubmit → 校验 status=4 - → 新建 ApprovalFlow - → 更新 approval_flow_id,status 回到 1(待审批) + → 请求体可修改 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:充值审核流程 @@ -301,48 +474,114 @@ POST /admin/refund-requests/{id}/resubmit tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 ``` -员工线下充值走审批流,需在现有状态基础上**新增"已退回"状态**: +代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`: ```sql --- status 说明更新(不改原有值语义,追加新状态) --- 6=已退回(审批人退回给提交人修改) +-- 现有:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回 +-- 新增:7=已退回 + +ALTER TABLE tb_agent_recharge_record + ADD COLUMN approval_instance_id BIGINT, + ADD COLUMN processing_status INT NOT NULL DEFAULT 0, + ADD COLUMN processing_error TEXT NOT NULL DEFAULT '', + ADD COLUMN processing_started_at TIMESTAMPTZ, + ADD COLUMN processing_completed_at TIMESTAMPTZ, + ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT ''; + +CREATE INDEX idx_agent_recharge_approval_instance + ON tb_agent_recharge_record(approval_instance_id) + WHERE approval_instance_id IS NOT NULL; ``` 充值业务的状态语义: -- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 approval_flow_id 判断是否在审批中) -- `2=已支付`:支付成功(线下充值审批通过后跳到已完成,不经过此状态) +- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 `approval_instance_id` 查询审批状态) +- `2=已支付`:在线支付已确认,或线下充值审批通过后正在执行钱包入账 - `3=已完成`:充值到账 - `4=已关闭`:取消/超时 - `5=已退款`:退款 -- `6=已退回`:审批人退回给提交人修改 +- `6=已驳回`:审批流程拒绝 +- `7=已退回`:审批人退回给提交人修改 + +`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。 + +充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。 ### 流程 **代理自行充值(不走审批)**: -``` -代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包 +```mermaid +flowchart LR + A[代理提交充值申请] --> B[系统生成收款码] + B --> C[代理扫码支付] + C --> D[支付回调幂等入账] ``` **员工线下代充值(走审批)**: -``` -POST /admin/agent-recharge-records(payment_method=offline) - → 创建 AgentRechargeRecord(status=1) - → 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=recharge) - → 把 approval_flow_id 写入记录 +现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。 -审批通过 → AgentRechargeRecord.status=3(已完成)→ 实际充值到钱包 -审批驳回 → AgentRechargeRecord.status=4(已关闭) -审批退回 → AgentRechargeRecord.status=6(已退回)→ 通知提交人可修改 +```mermaid +sequenceDiagram + actor Staff as 平台员工 + actor Approver as 审批人 + participant Recharge as Recharge Application + participant Approval as Approval Application + participant DB as PostgreSQL + participant Worker as RechargeApprovalHandler + + Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline) + Recharge->>DB: 同事务创建充值单(status=1) + Recharge->>Approval: StartProcess(recharge, recharge_id) + Approval->>DB: 创建实例、首任务、审批人、Outbox + Recharge->>DB: 回写 approval_instance_id + Approver->>Approval: 按 task_id 审批 + DB-->>Worker: 投递流程结果事件 + alt 审批通过 + Worker->>DB: status 从 1 更新为 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` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。 + ### 退回后重新提交 ``` -PUT /admin/agent-recharge-records/{id} 仅 status=6 时可编辑 -POST /admin/agent-recharge-records/{id}/resubmit - → 校验 status=6 - → 新建 ApprovalFlow - → 更新 approval_flow_id,status 回到 1(待支付/待审批) +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` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。 diff --git a/docs/7月迭代/需求17-信用额度.md b/docs/7月迭代/需求17-信用额度.md index 967c18b..6216e1e 100644 --- a/docs/7月迭代/需求17-信用额度.md +++ b/docs/7月迭代/需求17-信用额度.md @@ -1,264 +1,327 @@ # 需求17:信用额度 -> DDD 结构:`internal/domain/wallet/` +> 状态:待评审 +> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query +> 关联需求:BPO-009~012、需求19批量订购 --- -## 业务规则 +## 一、评审结论 -### 两类主体 +代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。 -| 主体 | 信用额度来源 | 修改条件 | -|------|------------|---------| -| 代理(店铺) | 创建店铺时设置 `credit_limit` | 修改前必须先清零欠款(`balance >= 0`)| -| 平台员工 | 基于**角色**,不是账号 | 角色权限表里配置 | +平台员工信用额度不实施,原因如下: -### 可用余额公式 +- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。 +- 角色表达权限范围,不表达资产、余额和债务,给角色配置资金会混淆 RBAC 与财务账本。 +- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。 +- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。 +- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。 -``` -可用额度 = balance + credit_limit - frozen_balance -``` - -- `balance` 可以为负(表示欠款) -- `balance < 0` 时表示已在使用信用额度 -- `balance < -credit_limit` 表示超额使用(理论上不可达,系统保护) - -### BPO-009 "是否可授权额度"开关 - -- 代理创建时,新增 `enable_credit` 开关 -- 开关关闭 = `credit_limit` 强制为 0,不允许使用信用额度 -- 平台员工账号通过角色自动拥有额度,无需开关 - -### BPO-012 负余额显示 - -- 代理前台(AgentWallet):`balance` 正常展示(可为负) -- 前端不做"不能为负"的前端校验,由后端控制 +因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。 --- -## 数据库变更 +## 二、已确认范围:代理主钱包授信 -### 1. Shop 表加信用额度字段 +### 2.1 业务规则 -```sql -ALTER TABLE tb_shop - ADD COLUMN enable_credit BOOLEAN NOT NULL DEFAULT FALSE - COMMENT '是否可使用信用额度', - ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0 - COMMENT '授信额度上限(分)'; +- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。 +- 可用金额:`balance - frozen_balance + effective_credit_limit`。 +- `credit_enabled=false` 时,`effective_credit_limit=0`。 +- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`。 +- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。 +- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。 +- 金额统一使用分,禁止浮点数入库。 + +```mermaid +flowchart TD + Debit[请求扣款] --> Lock[按钱包ID和version加载] + Lock --> Calc[计算 balance - frozen + effective_credit] + Calc --> Enough{可用金额足够?} + Enough -->|否| Reject[拒绝:可用余额不足] + Enough -->|是| Update[条件更新余额和version] + Update --> Tx[同事务写资金流水] + Tx --> Success[返回扣款后余额] ``` -### 2. AgentWallet 表加信用额度字段 +### 2.2 信用开关 + +- 创建代理时可以提交 `enable_credit` 和 `credit_limit`。 +- 关闭开关时额度必须为 0。 +- 打开开关时额度必须大于 0。 +- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`。 + +--- + +## 三、数据库变更 + +信用额度属于钱包,不在 `tb_shop` 和 `tb_agent_wallet` 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。 ```sql ALTER TABLE tb_agent_wallet - ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0 - COMMENT '信用额度(分),与 shop.credit_limit 同步,余额可到 -credit_limit'; + ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效'; +COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效'; + +ALTER TABLE tb_agent_wallet + DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance; + +ALTER TABLE tb_agent_wallet + ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative + CHECK (frozen_balance >= 0), + ADD CONSTRAINT chk_agent_wallet_credit_nonnegative + CHECK (credit_limit >= 0), + ADD CONSTRAINT chk_agent_wallet_available_nonnegative + CHECK ( + balance - frozen_balance + + CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0 + ); ``` -> 钱包上冗余一份,是为了避免每次扣款都要 join shop 表。通过 Shop 修改额度时同步更新钱包。 - -### 3. 角色信用额度配置(平台员工) - -```sql -ALTER TABLE tb_role - ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0 - COMMENT '该角色的信用额度(分),仅对平台员工角色有效'; -``` +必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。 --- -## Model 变更 +## 四、领域模型 ```go -// internal/model/shop.go -type Shop struct { - // ...原有字段... - EnableCredit bool `gorm:"column:enable_credit;not null;default:false;comment:是否可使用信用额度" json:"enable_credit"` - CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:授信额度上限(分)" json:"credit_limit"` -} - -// internal/model/agent_wallet.go +// AgentWallet 代理主钱包聚合根 type AgentWallet struct { - // ...原有字段(Balance, FrozenBalance 已存在)... - CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:信用额度(分)" json:"credit_limit"` + ID uint + WalletType string + Balance int64 + FrozenBalance int64 + CreditEnabled bool + CreditLimit int64 + Version int64 } -// GetAvailableBalance 更新:可用余额 = 余额 - 冻结 + 信用额度 -func (w *AgentWallet) GetAvailableBalance() int64 { - return w.Balance - w.FrozenBalance + w.CreditLimit -} -``` - ---- - -## 领域层(DDD) - -```go -// internal/domain/wallet/wallet.go - -// AgentWallet 钱包聚合根(扩展现有模型) -type AgentWalletAggregate struct { - model.AgentWallet - domainEvents []DomainEvent `gorm:"-"` -} - -// Debit 扣款(含信用额度) -func (w *AgentWalletAggregate) Debit(amountCents int64, refType string, refID uint) error { - available := w.Balance - w.FrozenBalance + w.CreditLimit - if available < amountCents { - return ErrInsufficientBalance +// AvailableBalance 返回当前可用金额。 +func (w *AgentWallet) AvailableBalance() int64 { + credit := int64(0) + if w.CreditEnabled { + credit = w.CreditLimit } - before := w.Balance - w.Balance -= amountCents - w.recordEvent(WalletDebitedEvent{ - WalletID: w.ID, - ShopID: w.ShopID, - Amount: amountCents, - BalanceBefore: before, - BalanceAfter: w.Balance, - RefType: refType, - RefID: refID, - }) + return w.Balance - w.FrozenBalance + credit +} + +// Debit 执行钱包扣款并维护信用边界。 +func (w *AgentWallet) Debit(amount int64) error { + if amount <= 0 { + return ErrInvalidAmount + } + if w.AvailableBalance() < amount { + return ErrInsufficientAvailableBalance + } + w.Balance -= amount + return nil +} + +// ChangeCredit 修改信用配置。 +func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error { + if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) { + return ErrInvalidCreditConfig + } + nextCredit := int64(0) + if enabled { + nextCredit = limit + } + if w.Balance-w.FrozenBalance+nextCredit < 0 { + return ErrCreditLimitBelowDebt + } + w.CreditEnabled = enabled + w.CreditLimit = limit return nil } ``` +领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。 + --- -## 应用层 +## 五、应用用例与持久化 + +### 5.1 修改代理信用额度 + +```text +Handler + → ChangeShopCreditUseCase + → 校验操作人和代理数据权限 + → 加载代理主钱包 + → 调用 wallet.ChangeCredit() + → 按 version 条件更新钱包 + → 写操作日志和信用变更流水 +``` + +条件更新示例: + +```sql +UPDATE tb_agent_wallet +SET credit_enabled = ?, + credit_limit = ?, + version = version + 1, + updated_at = NOW() +WHERE id = ? + AND wallet_type = 'main' + AND version = ? + AND balance - frozen_balance + + CASE WHEN ? THEN ? ELSE 0 END >= 0; +``` + +受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。 + +### 5.2 钱包扣款 + +所有现有主钱包扣款语句必须从: + +```text +balance - frozen_balance >= amount +``` + +统一改为: + +```text +balance - frozen_balance + +CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount +``` + +扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。 + +### 5.3 受影响用例 + +- 后台订单和批量订购的代理钱包支付。 +- C端/代理端使用代理主钱包的订单支付。 +- 钱包冻结与解冻。 +- 退款回充和员工线下代充值。 +- 资金概况、钱包详情和导出 Query。 + +实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。 + +--- + +## 六、查询方案 + +资金概况、钱包详情、列表和导出使用 Query 直接读取: + +```sql +balance - frozen_balance + +CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance +``` + +响应统一增加: ```go -// internal/application/wallet/grant_credit.go - -// GrantCreditCommand 授信命令 -type GrantCreditCommand struct { - ShopID uint - CreditLimit int64 // 分 - OperatorID uint -} - -// GrantCreditUseCase 设置代理信用额度 -type GrantCreditUseCase struct { - shopStore ShopStore - walletStore WalletStore - txManager TxManager -} - -func (uc *GrantCreditUseCase) Execute(ctx context.Context, cmd GrantCreditCommand) error { - return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error { - shop, err := uc.shopStore.GetByID(ctx, cmd.ShopID) - if err != nil { return err } - - // 修改信用额度前,必须当前余额 >= 0(无欠款) - wallet, err := uc.walletStore.GetMainWallet(ctx, cmd.ShopID) - if err != nil { return err } - if wallet.Balance < 0 { - return errors.New(errors.CodeForbidden, "存在欠款,请先清零后再修改信用额度") - } - - // 同步更新 shop 和 wallet - if err := uc.shopStore.UpdateCreditLimit(ctx, tx, cmd.ShopID, cmd.CreditLimit); err != nil { - return err - } - return uc.walletStore.UpdateCreditLimit(ctx, tx, wallet.ID, cmd.CreditLimit) - }) -} +CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"` +CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"` +AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"` +IsInDebt bool `json:"is_in_debt" description:"余额是否为负"` +DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"` ``` +`debt_amount = max(-balance, 0)`,不包含冻结金额。 + --- -## API 设计 +## 七、API 设计 -### 1. 创建店铺(新增字段) +### 7.1 创建代理 -``` -POST /admin/shops +现有: + +```text +POST /api/admin/shops ``` 新增参数: + ```go EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` -CreditLimit int64 `json:"credit_limit" description:"授信额度(分),enable_credit=true 时有效"` +CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` ``` -### 2. 修改信用额度 +Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。 -``` -PUT /admin/shops/{id}/credit-limit +### 7.2 修改信用额度 + +```text +PUT /api/admin/shops/{id}/credit-limit ``` ```go type UpdateCreditLimitRequest struct { - CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新授信额度(分),修改前需余额>=0"` + EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` + CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` + Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"` } ``` -错误场景:余额为负时,返回 `CodeForbidden`,msg="存在欠款(-XX元),请先清零后再修改信用额度" +### 7.3 查询展示 -### 3. 角色信用额度配置(平台员工) - -``` -PUT /admin/roles/{id}/credit-limit -``` - -```go -type UpdateRoleCreditLimitRequest struct { - CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"该角色的信用额度(分)"` -} -``` - -### 4. 查询钱包(余额展示更新) - -现有 `GET /admin/agent-wallets/{shopID}` 响应新增字段: - -```go -type AgentWalletResponse struct { - // ...原有字段... - CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"` - AvailableBalance int64 `json:"available_balance" description:"可用余额 = balance - frozen + credit_limit(分),可为负"` - IsInDebt bool `json:"is_in_debt" description:"是否有欠款(balance < 0)"` - DebtAmount int64 `json:"debt_amount" description:"欠款金额(分),balance<0时有值"` -} -``` +现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。 --- -## 前端对接 +## 八、前端技术方案 -### 页面:创建/编辑代理 +### 8.1 创建/编辑代理 -新增"信用额度"区块: -``` -信用额度: - [☑ 开启信用额度] - 授信上限:[____] 元 (enable_credit=true 时展示) +```text +信用额度 +[开关] 允许使用信用额度 +授信上限 [金额输入,单位元] ``` -提交时:`enable_credit: true, credit_limit: 100000`(单位:分,前端转换) +- 开关关闭时清空输入并提交 `credit_limit=0`。 +- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。 +- 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。 -### 页面:代理详情 > 钱包信息 +### 8.2 资金概况和详情 -``` -钱包余额:-¥200.00 (红色显示) +```text +账面余额:-¥200.00 +冻结金额:¥0.00 信用额度:¥1,000.00 -可用余额:¥800.00 +可用金额:¥800.00 ``` -当 `is_in_debt=true` 时,余额展示红色,并加"欠款"标签。 +- `balance < 0` 时账面余额显示欠款样式。 +- 可用金额以接口值为准,不在前端自行重复计算。 +- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。 +- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。 -### 页面:修改信用额度 +平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。 -入口:代理详情 > 钱包 > "修改信用额度"按钮 -弹框:输入新额度 → `PUT /admin/shops/{id}/credit-limit` +--- -如果当前有欠款(`is_in_debt=true`),禁用该按钮,展示"存在欠款,无法修改信用额度"。 +## 九、审计与可观测性 -### 页面:余额显示(全局) +每次信用配置变更记录: -代理管理列表的余额列:支持显示负数,负数红色标注。 +- 操作人、店铺、钱包 ID。 +- 变更前后开关、额度、余额、冻结金额和版本。 +- `request_id`、IP、设备信息和时间。 -### 平台员工余额(角色信用) +关键日志: -平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。 -在批量订购确认页展示可用额度。 +- 扣款因信用额度不足被拒绝。 +- 钱包 version 冲突。 +- 数据库信用边界约束失败。 +- 信用额度调整失败和旧值/新值。 + +资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。 + +--- + +## 十、发布与回滚 + +本需求随七月迭代停机发布: + +1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加信用字段,历史数据默认关闭。 +2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。 +3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。 +4. 系统开放后再由有权限的管理员对指定代理逐个开启额度。 + +尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。 diff --git a/docs/7月迭代/需求22-套餐临期提醒.md b/docs/7月迭代/需求22-套餐临期提醒.md index 7c407ec..877ed03 100644 --- a/docs/7月迭代/需求22-套餐临期提醒.md +++ b/docs/7月迭代/需求22-套餐临期提醒.md @@ -1,14 +1,22 @@ # 需求22:套餐临期提醒 +> 状态:待评审 + --- ## 业务规则 ### 临期定义 -当资产的**当前生效套餐**(`PackageUsage.status=1`)的 `expires_at` ≤ 15天后,该资产进入临期状态。 +当资产的**当前生效主套餐**(`PackageUsage.status=1`)按 `Asia/Shanghai` 自然日计算的剩余天数在 `0~15` 天时,该资产进入临期状态。 -**例外**:资产若有**排队中(status=0)的套餐**,不属于临期,不触发提醒。 +资产有可接续的排队主套餐时不进入临期提醒,因为服务不会在当前套餐到期后中断;需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。 + +### 查询与通知职责 + +- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。 +- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。 +- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。 ### 颜色规则(Version 2) @@ -27,34 +35,59 @@ | **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 | | **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 | +```mermaid +flowchart TD + Schedule[每日定时任务] --> Query[批量查询实时临期候选] + Query --> QueueCheck{存在排队套餐?} + QueueCheck -->|是| Skip[不进入临期提醒] + QueueCheck -->|否| Days[计算剩余自然日] + Days --> Node{命中 15/7/3 天节点?} + Node -->|否| End[本次不发送] + Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录] + Upsert --> Notification[站内消息] + Upsert -. Phase 2 .-> WeCom[企业微信推送] +``` + --- ## 数据库变更 -无新表。临期数据实时计算,不存储(避免数据陈旧)。 +临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。 -企业客户的**每日临期推送记录**需防重,用一个 key 记录已推送记录: +每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知: ```sql -- 临期推送记录(防重) CREATE TABLE tb_expiry_push_record ( - id BIGSERIAL PRIMARY KEY, - asset_type VARCHAR(20) NOT NULL, -- iot_card | device - asset_id BIGINT NOT NULL, - push_node INT NOT NULL, -- 推送节点(3/7/15天) - push_date DATE NOT NULL, -- 推送日期 - pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_expiry_push UNIQUE (asset_type, asset_id, push_node, push_date) + id BIGSERIAL PRIMARY KEY, + package_usage_id BIGINT NOT NULL, + asset_type VARCHAR(20) NOT NULL, -- iot_card | device + asset_id BIGINT NOT NULL, + recipient_id BIGINT NOT NULL, + channel VARCHAR(20) NOT NULL, -- notification | wecom + push_node INT NOT NULL, -- 推送节点(3/7/15天) + event_id VARCHAR(64) NOT NULL, + pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + +CREATE UNIQUE INDEX idx_expiry_push_idempotency + ON tb_expiry_push_record( + package_usage_id, recipient_id, channel, push_node + ); + +CREATE UNIQUE INDEX idx_expiry_push_event + ON tb_expiry_push_record(event_id); ``` +同一资产可能对应多个业务员,唯一约束必须包含接收人和渠道;否则第一位业务员写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。站内消息和 Phase 2 企业微信都使用该表防重。 + --- ## 后端实现 ### 1. 现有接口新增临期字段 -#### 资产列表接口(`GET /admin/iot-cards` / `GET /admin/devices`) +#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`) 响应新增字段: ```go @@ -73,17 +106,22 @@ SELECT ic.*, CASE WHEN pu.expires_at IS NULL THEN NULL + WHEN (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date + < (NOW() AT TIME ZONE 'Asia/Shanghai')::date THEN NULL WHEN EXISTS ( SELECT 1 FROM tb_package_usage q WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL ) THEN NULL -- 有排队套餐,不临期 - ELSE GREATEST(0, EXTRACT(DAY FROM (pu.expires_at - NOW()))::INT) + ELSE (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date + - (NOW() AT TIME ZONE 'Asia/Shanghai')::date END AS days_until_expiry FROM tb_iot_card ic LEFT JOIN tb_package_usage pu ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL ``` +`is_expiring` 由同一 SQL 表达式派生:`days_until_expiry IS NOT NULL AND days_until_expiry BETWEEN 0 AND 15`。设备查询复用同一规则,不能另写一套日期计算。 + #### 资产列表筛选条件新增 ```go @@ -107,7 +145,7 @@ IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且 ### 2. 新增临期列表接口(独立页面) ``` -GET /admin/expiring-assets +GET /api/admin/expiring-assets ``` 查询参数: @@ -140,49 +178,58 @@ type ExpiringAssetItem struct { } ``` -### 3. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧) +### 3. 每日定时任务(仅通知) ```go // internal/task/expiry_reminder_handler.go -// HandleExpiryReminder 每日03:00执行,生成临期数据 + 写站内消息 +// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。 func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error { - nodes := []int{15, 7, 3} - for _, node := range nodes { - assets, err := h.packageUsageStore.GetExpiringAssets(ctx, node) - if err != nil { return err } - - 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 } + assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15) + if err != nil { return err } - // 获取对应业务员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, - }) + for _, asset := range assets { + node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3}) + if node == 0 { + continue + } + today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化 + for _, recipientID := range asset.SalesmanIDs { + eventID := fmt.Sprintf( + "expiry:%d:%d:%d:%d:%s", + asset.PackageUsageID, node, recipientID, asset.AssetID, today, + ) - // 记录推送防重 - h.expiryPushStore.Record(ctx, asset.AssetType, asset.AssetID, node, today) + // 在事务内先写 expiry_push_record,再通过 Outbox 发布站内消息。 + if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{ + EventID: eventID, + RecipientIDs: []uint{recipientID}, + RecipientType: constants.NotifyRecipientAdmin, + Type: constants.NotifyTypePackageExpiring, + Title: fmt.Sprintf("套餐临期提醒(%d天节点)", node), + Body: fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry), + RefType: asset.AssetType, + RefID: asset.AssetID, + }); err != nil { + return err + } } } return nil } ``` +定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `0~15` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。 + --- ## 导出功能(临期列表) -``` -GET /admin/expiring-assets/export +使用统一导出任务: + +```text +POST /api/admin/export-tasks +scene=expiring_asset ``` 导出字段: @@ -205,7 +252,7 @@ GET /admin/expiring-assets/export ### 公众号首页 -现有接口(`GET /app/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: +现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: ```go DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"` @@ -248,7 +295,7 @@ IsExpiring bool `json:"is_expiring" description:"是否临期"` 列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量 -操作:导出按钮 → GET /admin/expiring-assets/export +操作:导出按钮 → `POST /api/admin/export-tasks`,`scene=expiring_asset` ``` ### 代理端首页