feat: initialize Nuanji (Warm Notes) project
- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing)
This commit is contained in:
111
.gitignore
vendored
Normal file
111
.gitignore
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Tauri
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker data
|
||||||
|
docker/postgres_data/
|
||||||
|
docker/redis_data/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.test_token
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
apps/miniprogram/dist-h5/
|
||||||
|
|
||||||
|
# Runtime uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Temp logs
|
||||||
|
_server_out.txt
|
||||||
|
*.heapsnapshot
|
||||||
|
perf-trace-*.json
|
||||||
|
docs/debug-*.png
|
||||||
|
|
||||||
|
# Development env
|
||||||
|
.env.development
|
||||||
|
docker/docker-compose.override.yml
|
||||||
|
.agents/skills/
|
||||||
|
.claude/skills/
|
||||||
|
.kiro/skills/
|
||||||
|
.trae/skills/
|
||||||
|
.windsurf/skills/
|
||||||
|
skills/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
.logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Playwright reports
|
||||||
|
**/playwright-report/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
plans/
|
||||||
|
|
||||||
|
# MCP config
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# Superpowers temp
|
||||||
|
.superpowers/brainstorm/
|
||||||
|
|
||||||
|
# Test temp files
|
||||||
|
.test_token*
|
||||||
|
chi_sim.traineddata
|
||||||
|
|
||||||
|
# Local settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
tools/
|
||||||
|
|
||||||
|
# Temp/debug files
|
||||||
|
_temp/
|
||||||
|
tmp/
|
||||||
|
screenshots/
|
||||||
|
server-log.txt
|
||||||
|
snapshot_*.txt
|
||||||
|
_*.txt
|
||||||
|
_server_*.txt
|
||||||
|
tmp_*.txt
|
||||||
|
direct_*.txt
|
||||||
|
server_*.txt
|
||||||
|
server_combined.txt
|
||||||
|
out.txt
|
||||||
|
_wx_login.json
|
||||||
|
.claude/settings.json
|
||||||
|
|
||||||
|
# Trace/debug JSON
|
||||||
|
trace-*.json
|
||||||
|
|
||||||
|
# Graphify knowledge graph (regenerated locally)
|
||||||
|
graphify-out/
|
||||||
|
|
||||||
|
# Native miniprogram (separate project)
|
||||||
|
apps/mp-native/
|
||||||
|
|
||||||
|
# Misc untracked
|
||||||
|
err.txt
|
||||||
|
uploads/g:/hms/.superpowers/
|
||||||
|
.claude/skills/design-handoff/node_modules/
|
||||||
|
.design/config.yml
|
||||||
|
.superpowers/
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
535
CLAUDE.md
Normal file
535
CLAUDE.md
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
@wiki/index.md
|
||||||
|
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||||
|
|
||||||
|
# HMS 健康管理平台 — 协作与实现规则
|
||||||
|
|
||||||
|
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
|
||||||
|
|
||||||
|
> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施。
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
### 1.1 这是什么
|
||||||
|
|
||||||
|
一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台:
|
||||||
|
|
||||||
|
- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health)
|
||||||
|
- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP)
|
||||||
|
- **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署
|
||||||
|
- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发
|
||||||
|
|
||||||
|
### 1.2 决策原则
|
||||||
|
|
||||||
|
**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?**
|
||||||
|
|
||||||
|
- ✅ 完善模块接口和 trait 定义 → 最高优先
|
||||||
|
- ✅ 确保多租户隔离的正确性 → 最高优先
|
||||||
|
- ✅ 按计划推进 Phase 交付物 → 高优先
|
||||||
|
- ✅ 清晰的模块边界和事件契约 → 高优先
|
||||||
|
- ❌ 跳过 Phase 顺序提前实现远期功能 → 禁止
|
||||||
|
- ❌ 在模块间创建直接耦合 → 永远不做
|
||||||
|
- ❌ 硬编码租户 ID 或绕过多租户中间件 → 永远不做
|
||||||
|
- ❌ 过度设计未来才需要的能力 → 永远不做
|
||||||
|
|
||||||
|
### 1.3 架构铁律
|
||||||
|
|
||||||
|
| 约束 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| 模块间只通过事件总线和 trait 通信 | 保证模块可独立拆分为微服务 |
|
||||||
|
| 所有数据表必须含 `tenant_id` | 多租户是核心能力,不可事后补 |
|
||||||
|
| 使用 UUID v7 作为主键 | 时间排序 + 唯一性,分布式友好 |
|
||||||
|
| 软删除,不硬删除 | ERP 数据不可丢失,审计追溯需要 |
|
||||||
|
| 所有 API 使用 `/api/v1/` 前缀 | 版本化是 SaaS 产品的基本要求 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工作风格
|
||||||
|
|
||||||
|
### 2.1 按计划推进
|
||||||
|
|
||||||
|
- **严格按 Phase 顺序执行** — Phase 2 依赖 Phase 1 的基础设施
|
||||||
|
- **每个 Task 完成后立即提交** — 不积压,保持可追溯
|
||||||
|
- **先测试后实现** — TDD 流程:写失败测试 → 实现 → 通过 → 提交
|
||||||
|
|
||||||
|
### 2.2 分步编写文档(强制)
|
||||||
|
|
||||||
|
编写计划、设计文档、实施报告等长文档时,**必须分步编写**,禁止一次性输出全文:
|
||||||
|
|
||||||
|
1. **先写大纲** — 确认文档结构和章节划分
|
||||||
|
2. **逐章编写** — 每次只写 1-2 个章节,写完确认后继续下一章
|
||||||
|
3. **最终整合** — 所有章节完成后合并为完整文档
|
||||||
|
|
||||||
|
**原因:** 上下文过长会导致输出截断或卡死。分步编写保证每步都能完整输出,且用户可以中途调整方向。
|
||||||
|
|
||||||
|
**适用范围:** 超过 200 行的文档、实施计划、设计规格、技术报告等。简短的 bugfix 说明、单页 wiki 更新不受此限制。
|
||||||
|
|
||||||
|
### 2.3 讨论记录
|
||||||
|
|
||||||
|
每次发散式讨论(brainstorming、方案探索、需求梳理、技术选型等)**必须建立独立文档**:
|
||||||
|
|
||||||
|
- **存放位置:** `docs/discussions/YYYY-MM-DD-{主题简称}.md`
|
||||||
|
- **文档格式:**
|
||||||
|
```markdown
|
||||||
|
# {讨论主题}
|
||||||
|
> 日期: YYYY-MM-DD | 参与者: ...
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
为什么会有这次讨论
|
||||||
|
|
||||||
|
## 讨论要点
|
||||||
|
- 要点 1
|
||||||
|
- 要点 2
|
||||||
|
|
||||||
|
## 结论 / 待定
|
||||||
|
达成的共识或遗留问题
|
||||||
|
```
|
||||||
|
- **时机:** 讨论结束后立即创建,不要积压。如果讨论横跨多个主题,拆分为多份文档。
|
||||||
|
- **用途:** 作为后续实施的输入和决策追溯的依据,避免"之前讨论过但忘了结论"。
|
||||||
|
|
||||||
|
### 2.4 模块化思维
|
||||||
|
|
||||||
|
开发任何功能时先问:
|
||||||
|
|
||||||
|
1. **它属于哪个模块?** — 不确定就放到 `erp-core` 共享层
|
||||||
|
2. **它的接口是什么?** — 先定义 trait,再实现
|
||||||
|
3. **它需要发什么事件?** — 跨模块通知必须走事件总线
|
||||||
|
4. **其他模块怎么发现它?** — 通过 `ErpModule` trait 注册
|
||||||
|
|
||||||
|
### 2.5 闭环工作法(强制)
|
||||||
|
|
||||||
|
每次改动**必须**按顺序完成以下步骤,不允许跳过:
|
||||||
|
|
||||||
|
0. **阅读 Wiki(强制起点)** — 收到任何任务后,**先读 wiki 再动手**:
|
||||||
|
- 读取 `wiki/index.md` 了解项目全貌和当前进度
|
||||||
|
- 根据任务涉及的范围,读取相关 wiki 页面(`wiki/infrastructure.md`、`wiki/testing.md`、`wiki/wasm-plugin.md` 等)
|
||||||
|
- wiki 中包含实际的环境配置(数据库连接、端口、登录凭据、启动方式),不看 wiki 就无法正确验证
|
||||||
|
- **违反此步骤 = 盲目工作,浪费时间去猜环境配置,产出不可信**
|
||||||
|
1. **现状确认(强制)** — 动手之前,先检查代码里已经有什么:
|
||||||
|
- 用 Grep/Glob/Read 工具搜索相关文件,确认哪些能力已存在
|
||||||
|
- 明确列出"已有"和"缺失",不允许凭印象断言缺失
|
||||||
|
- 如果不确定现有实现状态,停下来问用户,不要编造
|
||||||
|
- 违反此步骤 = 所有后续工作可能脱离实际,白费力气
|
||||||
|
2. **理解需求** — 确认改动的目标模块和影响范围
|
||||||
|
3. **最小实现** — 只改必要的代码,保持模块边界
|
||||||
|
4. **验证通过** — 必须全部通过才可继续:
|
||||||
|
- `cargo check` — 编译无错误
|
||||||
|
- `cargo test --workspace` — 所有测试通过(有相关测试时)
|
||||||
|
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
|
||||||
|
- `pnpm build` — 前端生产构建通过(涉及前端时)
|
||||||
|
5. **提交 + 文档 + 推送(三合一,强制)** — 验证通过后按顺序执行:
|
||||||
|
- a. 按 §5 规范提交代码
|
||||||
|
- b. 检查本次变更是否触发 wiki 更新(见下方 wiki 更新触发条件),触发则更新后单独 `docs(wiki)` 提交
|
||||||
|
- c. `git push` 立即推送,不允许"等一下再推"
|
||||||
|
- **禁止连续 5 个非 docs 提交而不更新 wiki 关键数字**
|
||||||
|
|
||||||
|
#### wiki 更新触发条件(步骤 5b 的判定标准)
|
||||||
|
|
||||||
|
以下任一条件满足时,**必须**更新 wiki 后才能继续下一任务:
|
||||||
|
|
||||||
|
- **fix 提交** → `wiki/index.md` 症状导航新增条目或标记"已修复"
|
||||||
|
- **feat 提交(新功能)** → `wiki/index.md` 关键数字更新 + 对应模块 wiki 页更新(实体数/路由数/端点数等)
|
||||||
|
- **数据库迁移变化** → 关键数字中的迁移数/表数更新
|
||||||
|
- **API 路由变化** → 路由数更新
|
||||||
|
- **测试数量变化** → 测试数/断言数更新
|
||||||
|
- **连续 5 个代码提交** → 强制做一次 wiki/index.md 关键数字全文校正(对比代码实际数量)
|
||||||
|
|
||||||
|
**铁律:**
|
||||||
|
- **步骤 0 阅读 Wiki 是绝对起点** — 不读 wiki 就开干 = 连环境配置都不知道,所有验证步骤都是空谈。
|
||||||
|
- **步骤 1 现状确认是强制起点** — 不检查就开干 = 脱离实际,所有产出不可信。
|
||||||
|
- **步骤 4 功能验证必须实际操作** — 只看编译通过不算验证,必须启动服务、在浏览器中确认功能正常。
|
||||||
|
- **步骤 5 三合一是强制流程** — 提交后必须检查 wiki、必须推送,缺一不可。
|
||||||
|
- **每次新会话开始时,先检查是否有未推送的提交并立即推送**。
|
||||||
|
|
||||||
|
### 2.6 Feature DoD — 功能完成定义(强制)
|
||||||
|
|
||||||
|
> 历史数据显示 24% 的提交是 fix,根因是缺少统一的完成标准。
|
||||||
|
> 每个功能标记"完成"前,**必须**逐项检查以下清单,不允许跳过。
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
|
||||||
|
- [ ] Entity 包含所有标准字段(`id`/`tenant_id`/`created_at`/`updated_at`/`created_by`/`updated_by`/`deleted_at`/`version`)
|
||||||
|
- [ ] Handler 添加 `require_permission` 权限守卫
|
||||||
|
- [ ] 权限码已写入 seed 迁移(每个实体 `.list` + `.manage`,权限码前缀与实体名一致)
|
||||||
|
- [ ] utoipa 注解已添加(`#[derive(utoipa::OpenApi)]` + path/response schema)
|
||||||
|
- [ ] Service 层核心路径有单元/集成测试
|
||||||
|
- [ ] 多租户隔离正确(所有查询含 `tenant_id` 过滤,无手写 SQL 拼接)
|
||||||
|
- [ ] 输入验证完整(必填字段 + 格式校验 + 长度限制)
|
||||||
|
- [ ] 错误处理统一(`AppError`,不 panic,不 unwrap 生产代码)
|
||||||
|
- [ ] 关键操作有 `tracing` 日志(info/warn/error 级别合理)
|
||||||
|
|
||||||
|
#### 前端(Web)
|
||||||
|
|
||||||
|
- [ ] API 路径与后端 OpenAPI spec 一致(不手写路径,从 `api/health/` 模块调用)
|
||||||
|
- [ ] 路由声明权限码(`permissions: [...]`),与后端 handler 一致
|
||||||
|
- [ ] 菜单配置已更新(`parent_id` 正确 + `permission` 字段 + `menu_roles` 关联)
|
||||||
|
- [ ] 错误状态有用户友好提示(不显示原始 error message)
|
||||||
|
- [ ] 不使用 `any` 类型(用 `unknown` + 类型守卫)
|
||||||
|
|
||||||
|
#### 前端(小程序)
|
||||||
|
|
||||||
|
- [ ] Service 层接口契约与后端 DTO 一致(字段名/类型/结构体)
|
||||||
|
- [ ] 登录态处理正确(`useDidShow` 恢复认证、退出清理 Storage)
|
||||||
|
- [ ] 页面间数据通过 API 获取,不用 Storage 传递
|
||||||
|
- [ ] 长者模式适配完成(字号 ≥ 22px)
|
||||||
|
- [ ] 图片使用合法 URL(HTTPS 或相对路径,不用 HTTP)
|
||||||
|
|
||||||
|
#### 安全
|
||||||
|
|
||||||
|
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
|
||||||
|
- [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM)
|
||||||
|
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越)
|
||||||
|
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
|
||||||
|
- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等)
|
||||||
|
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
|
||||||
|
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
|
||||||
|
- [ ] 速率限制已配置(认证端点更严格)
|
||||||
|
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
|
||||||
|
|
||||||
|
#### 文档一致性
|
||||||
|
|
||||||
|
- [ ] `wiki/index.md` 关键数字与代码实际状态一致(迁移数、路由数、实体数、测试数等)
|
||||||
|
- [ ] 新增/修复的 bug 已记录在症状导航中(含根因+解决方案)
|
||||||
|
- [ ] 新增功能已记录在对应模块 wiki 页面中(实体、端点、事件等)
|
||||||
|
- [ ] wiki 页面的"最后更新"日期已刷新为当天
|
||||||
|
|
||||||
|
#### 端到端验证
|
||||||
|
|
||||||
|
- [ ] `cargo check` 全 workspace 通过
|
||||||
|
- [ ] `cargo test` 全部通过
|
||||||
|
- [ ] 浏览器中手动验证功能正常(列表/创建/编辑/删除/权限拦截)
|
||||||
|
- [ ] 小程序中验证(涉及小程序页面时)
|
||||||
|
- [ ] 相关路由权限按角色测试通过(至少 admin + 只读角色)
|
||||||
|
- [ ] 本地提交已推送到远程仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 实现规则
|
||||||
|
|
||||||
|
### 3.1 错误处理
|
||||||
|
|
||||||
|
- **跨 crate 边界**:使用 `thiserror` 定义类型化错误,转换为 `AppError`
|
||||||
|
- **crate 内部**:可以使用 `anyhow`,但**永远不**跨越 crate 边界
|
||||||
|
- **数据库错误**:通过 `From<sea_orm::DbErr>` 自动转换为 `AppError`
|
||||||
|
- **验证错误**:包含字段级详情,方便 UI 渲染
|
||||||
|
|
||||||
|
### 3.2 数据库操作
|
||||||
|
|
||||||
|
- 所有 SeaORM Entity 必须包含:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||||
|
- 查询时**始终**带 `tenant_id` 过滤(中间件自动注入)
|
||||||
|
- 更新时检查 `version` 字段实现乐观锁
|
||||||
|
- 删除使用软删除(设置 `deleted_at`)
|
||||||
|
|
||||||
|
### 3.3 API 设计
|
||||||
|
|
||||||
|
- 所有端点使用 `/api/v1/` 前缀
|
||||||
|
- 响应统一使用 `ApiResponse<T>` 包装
|
||||||
|
- 分页使用 `Pagination` + `PaginatedResponse<T>`
|
||||||
|
- utoipa 自动生成 OpenAPI 文档
|
||||||
|
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
|
||||||
|
|
||||||
|
#### 新增 API 端点安全检查(强制)
|
||||||
|
|
||||||
|
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
|
||||||
|
> 新增端点时**必须**逐项确认:
|
||||||
|
|
||||||
|
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
|
||||||
|
- [ ] 公开端点已显式标记为 `public`(不继承认证中间件)
|
||||||
|
- [ ] 路由使用 `.nest()` 注册带中间件的子路由(禁止 `.merge()` 防止中间件泄漏)
|
||||||
|
- [ ] 敏感操作有速率限制
|
||||||
|
- [ ] 无 `format!` 拼接 SQL — 所有查询使用 SeaORM 参数化
|
||||||
|
- [ ] FHIR/第三方端点有 `tenant_id` 和 `allowed_patient_ids` 范围过滤
|
||||||
|
- [ ] 无硬编码密钥或 fallback 默认值
|
||||||
|
|
||||||
|
#### 前后端接口同步检查(强制)
|
||||||
|
|
||||||
|
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
|
||||||
|
> 后端 DTO 变更时**必须**同步检查前端:
|
||||||
|
|
||||||
|
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
|
||||||
|
- [ ] DTO 新增必填字段 → 前端表单和请求体同步更新
|
||||||
|
- [ ] API 路径变更 → 前端 `api/` 模块路径同步更新
|
||||||
|
- [ ] 返回数据结构变更(数组/对象/嵌套)→ 前端解析逻辑同步更新
|
||||||
|
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
|
||||||
|
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
|
||||||
|
|
||||||
|
#### DTO 输入校验检查(强制)
|
||||||
|
|
||||||
|
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。
|
||||||
|
> 新增/修改 DTO 时**必须**逐项确认:
|
||||||
|
|
||||||
|
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数)
|
||||||
|
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
|
||||||
|
- [ ] 字符串字段有 `#[validate(length(min, max))]`
|
||||||
|
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
|
||||||
|
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
|
||||||
|
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
|
||||||
|
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https)
|
||||||
|
- [ ] 密码字段有 `max = 128` 防止 DoS
|
||||||
|
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
|
||||||
|
|
||||||
|
### 3.4 事件总线
|
||||||
|
|
||||||
|
- 模块间通信**只能**通过 `EventBus`
|
||||||
|
- 事件必须持久化到 `domain_events` 表(outbox 模式)
|
||||||
|
- 事件处理失败记录到 dead-letter 存储
|
||||||
|
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
||||||
|
- **铁律:每个事件必须有至少一个消费者,否则功能不算完成。** 新增事件发布时必须同步实现消费者和对应测试。详见 `docs/discussions/2026-04-28-architecture-retrospective.md` §4。
|
||||||
|
|
||||||
|
### 3.5 Rust 代码规范
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 命名:snake_case (函数/变量), PascalCase (类型/trait), SCREAMING_SNAKE (常量)
|
||||||
|
// 模块公开接口通过 lib.rs 统一导出
|
||||||
|
// 每个 public 函数和 trait 必须有文档注释
|
||||||
|
// 异步函数返回 Result 时使用 AppResult<T> 类型别名
|
||||||
|
// 数据库操作使用 SeaORM 的 Entity + Model + Relation 模式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 TypeScript / React 代码规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 避免 any,优先 unknown + 类型守卫
|
||||||
|
// 函数组件 + hooks
|
||||||
|
// 复杂状态收敛到 Zustand store
|
||||||
|
// API 调用封装到独立的 service 层,不在组件中直接 fetch
|
||||||
|
// 使用 Ant Design 组件,不自行实现已有组件
|
||||||
|
// 国际化文案使用 i18n key,不硬编码中文
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 安全规范
|
||||||
|
|
||||||
|
#### 密钥与凭据管理
|
||||||
|
|
||||||
|
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
|
||||||
|
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
|
||||||
|
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
|
||||||
|
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
|
||||||
|
|
||||||
|
#### 依赖安全
|
||||||
|
|
||||||
|
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`)
|
||||||
|
- 禁止引入有未修补高危漏洞的依赖版本
|
||||||
|
- 定期更新依赖到最新安全补丁版本
|
||||||
|
|
||||||
|
#### 数据安全
|
||||||
|
|
||||||
|
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储(AES-256-GCM)
|
||||||
|
- 日志中**禁止**输出 PII 数据和认证凭据(密码、token、session key)
|
||||||
|
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
|
||||||
|
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize)
|
||||||
|
|
||||||
|
#### 传输安全
|
||||||
|
|
||||||
|
- 生产环境**必须**强制 HTTPS,**禁止**降级到 HTTP
|
||||||
|
- HTTP 响应**必须**包含安全头(HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy)
|
||||||
|
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie)
|
||||||
|
- API 响应**禁止**暴露内部实现细节(堆栈跟踪、数据库错误、文件路径、SQL 语句)
|
||||||
|
|
||||||
|
#### 认证与授权
|
||||||
|
|
||||||
|
- 密码**必须**使用单向哈希(bcrypt/argon2),**禁止**明文或可逆加密存储
|
||||||
|
- JWT **必须**设置合理过期时间,支持 token 吊销机制
|
||||||
|
- 敏感操作(删除数据、权限变更)需要二次确认
|
||||||
|
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
|
||||||
|
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
|
||||||
|
|
||||||
|
#### 速率限制
|
||||||
|
|
||||||
|
- 所有 API 端点**必须**配置速率限制
|
||||||
|
- 认证相关端点(登录、注册、密码重置)限制更严格
|
||||||
|
- 批量操作和数据导出需要独立的速率限制策略
|
||||||
|
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 测试与验证
|
||||||
|
|
||||||
|
### 4.1 测试要求
|
||||||
|
|
||||||
|
| 测试类型 | 覆盖目标 | 工具 |
|
||||||
|
|----------|---------|------|
|
||||||
|
| 单元测试 | 每个 service 函数 | `#[cfg(test)]` + `tokio::test` |
|
||||||
|
| 集成测试 | API 端点 → 数据库 | Testcontainers + 真实 PostgreSQL |
|
||||||
|
| 多租户测试 | 数据隔离验证 | 独立测试 crate |
|
||||||
|
| E2E 测试 | 前端关键流程 | Playwright |
|
||||||
|
| 插件测试 | 动态表 CRUD + 租户隔离 | Testcontainers |
|
||||||
|
|
||||||
|
### 4.2 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rust 编译检查
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
# Rust 全量测试
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# 后端服务启动
|
||||||
|
cd crates/erp-server && cargo run
|
||||||
|
|
||||||
|
# Docker 环境
|
||||||
|
cd docker && docker compose up -d
|
||||||
|
|
||||||
|
# 桌面端开发
|
||||||
|
cd apps/desktop && pnpm tauri dev
|
||||||
|
|
||||||
|
# 数据库迁移检查
|
||||||
|
docker exec erp-postgres psql -U erp -c "\dt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Phase 完成标准
|
||||||
|
|
||||||
|
每个 Phase 完成时必须满足:
|
||||||
|
|
||||||
|
- [ ] `cargo check` 全 workspace 通过
|
||||||
|
- [ ] `cargo test` 全部通过
|
||||||
|
- [ ] PostgreSQL 服务正常运行,迁移自动执行
|
||||||
|
- [ ] 所有迁移可正/反向执行
|
||||||
|
- [ ] API 端点可通过 Swagger UI 测试
|
||||||
|
- [ ] 桌面端可正常启动并展示对应 UI
|
||||||
|
- [ ] 所有代码已提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 提交规范
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
```
|
||||||
|
|
||||||
|
**类型:**
|
||||||
|
- `feat` — 新功能
|
||||||
|
- `fix` — 修复问题
|
||||||
|
- `refactor` — 重构
|
||||||
|
- `docs` — 文档更新
|
||||||
|
- `test` — 测试相关
|
||||||
|
- `chore` — 杂项(构建、配置等)
|
||||||
|
- `perf` — 性能优化
|
||||||
|
|
||||||
|
**Scope 对应 crate 或模块名:**
|
||||||
|
|
||||||
|
| scope | 范围 |
|
||||||
|
|-------|------|
|
||||||
|
| `core` | erp-core |
|
||||||
|
| `auth` | erp-auth |
|
||||||
|
| `workflow` | erp-workflow |
|
||||||
|
| `message` | erp-message |
|
||||||
|
| `config` | erp-config |
|
||||||
|
| `server` | erp-server |
|
||||||
|
| `health` | erp-health |
|
||||||
|
| `ai` | erp-ai |
|
||||||
|
| `dialysis` | erp-dialysis |
|
||||||
|
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
||||||
|
| `assessment` | erp-plugin-assessment |
|
||||||
|
| `crm` | erp-plugin-crm |
|
||||||
|
| `inventory` | erp-plugin-inventory |
|
||||||
|
| `web` | Web 前端 |
|
||||||
|
| `ui` | React 组件 |
|
||||||
|
| `db` | 数据库迁移 |
|
||||||
|
| `docker` | Docker 配置 |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(auth): 添加用户管理 CRUD
|
||||||
|
feat(core): 实现事件总线和模块注册
|
||||||
|
fix(server): 修复数据库连接池配置
|
||||||
|
refactor(auth): 拆分 RBAC 和 ABAC 权限模型
|
||||||
|
chore(docker): 添加 PostgreSQL 健康检查
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 反模式警告
|
||||||
|
|
||||||
|
- ❌ **不要**不看 wiki 就开干 — wiki 包含环境配置、数据库连接、启动方式、已知问题,不看就做等于盲猜,浪费时间且产出不可信
|
||||||
|
- ❌ **不要**在业务 crate 之间创建直接依赖 — 只通过事件和 trait 通信
|
||||||
|
- ❌ **不要**跳过多租户中间件 — 所有数据操作必须带 `tenant_id` 过滤
|
||||||
|
- ❌ **不要**硬编码配置值 — 使用 config.toml + 环境变量
|
||||||
|
- ❌ **不要**跳过迁移直接建表 — 所有 schema 变更通过 SeaORM Migration
|
||||||
|
- ❌ **不要**在前端组件中直接调用 HTTP — 封装到 service 层
|
||||||
|
- ❌ **不要**使用 `anyhow` 跨越 crate 边界 — 内部可用,对外必须转 `AppError`
|
||||||
|
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
|
||||||
|
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
|
||||||
|
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
|
||||||
|
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
|
||||||
|
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL,保持模块边界
|
||||||
|
- ❌ **不要**在 plugin.toml 中使用与实体名不一致的权限码 — `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致(如实体 `customer_tag` → 权限码 `customer_tag.list`/`customer_tag.manage`,不能写成 `tag.manage`),否则页面 403
|
||||||
|
- ❌ **不要**漏掉实体的 `.list` 权限 — 每个实体必须同时声明 `.list` 和 `.manage`,缺少 `.list` 导致列表页 403
|
||||||
|
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||||
|
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
|
||||||
|
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||||
|
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
|
||||||
|
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
|
||||||
|
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
|
||||||
|
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
|
||||||
|
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
|
||||||
|
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
|
||||||
|
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
|
||||||
|
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
|
||||||
|
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(Validate derive / 枚举 custom / Vec min=1 / 密码 max=128)
|
||||||
|
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
|
||||||
|
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
|
||||||
|
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
|
||||||
|
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
|
||||||
|
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
|
||||||
|
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
|
||||||
|
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
|
||||||
|
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
|
||||||
|
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
|
||||||
|
|
||||||
|
### 场景化指令
|
||||||
|
|
||||||
|
- 当遇到**新增模块** → 实现 `ErpModule` trait,在 `erp-server` 注册
|
||||||
|
- 当遇到**跨模块通信** → 定义事件类型,通过 `EventBus` 发布/订阅
|
||||||
|
- 当遇到**数据查询** → 确保包含 `tenant_id` 过滤,检查软删除条件
|
||||||
|
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
|
||||||
|
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||||
|
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||||
|
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`)**
|
||||||
|
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 详细参考(wiki)
|
||||||
|
|
||||||
|
以下内容已从本文件迁移到 wiki,需要时查阅:
|
||||||
|
|
||||||
|
| 主题 | wiki 页面 |
|
||||||
|
|------|----------|
|
||||||
|
| 目录结构、crate 依赖、技术栈 | `wiki/architecture.md` §2 |
|
||||||
|
| 模块开发规范、ErpModule trait、迁移规范 | `wiki/architecture.md` §3 |
|
||||||
|
| 安全注意事项(认证/多租户/通用) | `wiki/architecture.md` §4 |
|
||||||
|
| UI 布局规范 | `wiki/frontend.md` §2 |
|
||||||
|
| 常用命令(Rust/前端/数据库/WASM) | `wiki/infrastructure.md` §3 |
|
||||||
|
| 设计文档索引 | `wiki/index.md` |
|
||||||
|
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
|
||||||
|
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
|
||||||
|
|
||||||
|
## graphify — 代码知识图谱
|
||||||
|
|
||||||
|
> 项目知识图谱位于 `graphify-out/`,当前规模:18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
|
||||||
|
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
|
||||||
|
|
||||||
|
### 开发流程中的使用场景
|
||||||
|
|
||||||
|
| 时机 | 命令 | 目的 |
|
||||||
|
|------|------|------|
|
||||||
|
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
|
||||||
|
| **排查 bug,追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
|
||||||
|
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
|
||||||
|
| **代码改动后** | `graphify update .` | 增量更新图谱(AST-only,秒级完成) |
|
||||||
|
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
|
||||||
|
|
||||||
|
### 使用优先级(融入 §2.5 闭环工作法)
|
||||||
|
|
||||||
|
在 §2.5 步骤 1「现状确认」中,**优先使用 graphify 替代盲目 Grep**:
|
||||||
|
|
||||||
|
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
|
||||||
|
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
|
||||||
|
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token,每次代码改动后都可以运行
|
||||||
|
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain,仅在需要全局视图时读报告
|
||||||
|
- 首次生成需几分钟(1712 文件),后续增量更新秒级完成
|
||||||
6752
Cargo.lock
generated
Normal file
6752
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
117
Cargo.toml
Normal file
117
Cargo.toml
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/erp-core",
|
||||||
|
"crates/erp-server",
|
||||||
|
"crates/erp-auth",
|
||||||
|
"crates/erp-workflow",
|
||||||
|
"crates/erp-message",
|
||||||
|
"crates/erp-config",
|
||||||
|
"crates/erp-server/migration",
|
||||||
|
"crates/erp-plugin",
|
||||||
|
"crates/erp-diary",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Async
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# Web
|
||||||
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sea-orm = { version = "1.1", features = [
|
||||||
|
"sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json"
|
||||||
|
] }
|
||||||
|
sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# UUID & Time
|
||||||
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Config
|
||||||
|
config = "0.14"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
argon2 = "0.5"
|
||||||
|
|
||||||
|
# Cryptographic hashing (token storage)
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
# API docs
|
||||||
|
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||||
|
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
|
||||||
|
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
validator = { version = "0.19", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async trait
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
aes = "0.8"
|
||||||
|
cbc = "0.1"
|
||||||
|
hex = "0.4"
|
||||||
|
regex-lite = "0.1"
|
||||||
|
|
||||||
|
# CSV and Excel export
|
||||||
|
csv = "1"
|
||||||
|
rust_xlsxwriter = "0.82"
|
||||||
|
|
||||||
|
# Internal crates
|
||||||
|
erp-core = { path = "crates/erp-core" }
|
||||||
|
erp-auth = { path = "crates/erp-auth" }
|
||||||
|
erp-workflow = { path = "crates/erp-workflow" }
|
||||||
|
erp-message = { path = "crates/erp-message" }
|
||||||
|
erp-config = { path = "crates/erp-config" }
|
||||||
|
erp-plugin = { path = "crates/erp-plugin" }
|
||||||
|
erp-diary = { path = "crates/erp-diary" }
|
||||||
|
|
||||||
|
# Async streaming
|
||||||
|
futures = "0.3"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
async-stream = "0.3"
|
||||||
|
dashmap = "6"
|
||||||
|
|
||||||
|
# Template engine
|
||||||
|
handlebars = "6"
|
||||||
|
|
||||||
|
# HTML sanitization
|
||||||
|
ammonia = "4"
|
||||||
|
|
||||||
|
# Document parsing
|
||||||
|
pdf-extract = "0.7"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
metrics = "0.24"
|
||||||
|
metrics-exporter-prometheus = "0.16"
|
||||||
113
Dockerfile
Normal file
113
Dockerfile
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ==============================
|
||||||
|
# Stage 1: Build Rust backend
|
||||||
|
# ==============================
|
||||||
|
FROM rust:1-bookworm AS rust-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先复制依赖文件以利用 Docker 缓存
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml
|
||||||
|
COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml
|
||||||
|
COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml
|
||||||
|
COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml
|
||||||
|
COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml
|
||||||
|
COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml
|
||||||
|
COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml
|
||||||
|
COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml
|
||||||
|
COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml
|
||||||
|
COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml
|
||||||
|
COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml
|
||||||
|
COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml
|
||||||
|
|
||||||
|
# 创建空的 lib.rs/main.rs 占位以缓存依赖
|
||||||
|
RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \
|
||||||
|
&& mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \
|
||||||
|
&& mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \
|
||||||
|
&& for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \
|
||||||
|
mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译)
|
||||||
|
RUN cargo build --release -p erp-server 2>/dev/null || true
|
||||||
|
|
||||||
|
# 复制实际源码
|
||||||
|
COPY crates/ crates/
|
||||||
|
|
||||||
|
# 重新构建(增量编译,只编译业务代码)
|
||||||
|
RUN cargo build --release -p erp-server
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Stage 2: Build frontend
|
||||||
|
# ==============================
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/
|
||||||
|
|
||||||
|
RUN cd apps/web && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY apps/web/ ./apps/web/
|
||||||
|
|
||||||
|
RUN cd apps/web && pnpm build
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Stage 3: Production runtime
|
||||||
|
# ==============================
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制 Rust 二进制
|
||||||
|
COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
|
||||||
|
|
||||||
|
# 复制配置文件
|
||||||
|
COPY config/ /app/config/
|
||||||
|
|
||||||
|
# 复制前端构建产物(可通过 volume 暴露给 OpenResty)
|
||||||
|
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
|
||||||
|
|
||||||
|
# 创建上传目录
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
# 非特权用户运行
|
||||||
|
RUN useradd -r -s /bin/false appuser \
|
||||||
|
&& chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# 环境变量(运行时通过 docker-compose / .env 覆盖)
|
||||||
|
ENV ERP__SERVER__HOST=0.0.0.0
|
||||||
|
ENV ERP__SERVER__PORT=3000
|
||||||
|
ENV ERP__SERVER__METRICS_PORT=9090
|
||||||
|
ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
|
EXPOSE 3000 9090
|
||||||
|
|
||||||
|
VOLUME ["/app/uploads", "/app/static"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/api/v1/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/erp-server"]
|
||||||
81
config/default.toml
Normal file
81
config/default.toml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 3000
|
||||||
|
|
||||||
|
[database]
|
||||||
|
url = "__MUST_SET_VIA_ENV__"
|
||||||
|
max_connections = 20
|
||||||
|
min_connections = 5
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
url = "__MUST_SET_VIA_ENV__"
|
||||||
|
|
||||||
|
[jwt]
|
||||||
|
secret = "__MUST_SET_VIA_ENV__"
|
||||||
|
access_token_ttl = "15m"
|
||||||
|
refresh_token_ttl = "7d"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
super_admin_password = "__MUST_SET_VIA_ENV__"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "info"
|
||||||
|
|
||||||
|
[cors]
|
||||||
|
# Comma-separated allowed origins. Use "*" for development only.
|
||||||
|
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
|
||||||
|
|
||||||
|
[wechat]
|
||||||
|
appid = "__MUST_SET_VIA_ENV__"
|
||||||
|
secret = "__MUST_SET_VIA_ENV__"
|
||||||
|
# dev_mode = true 跳过 jscode2session,允许微信开发者工具模拟器登录
|
||||||
|
# 生产环境必须为 false(默认)
|
||||||
|
dev_mode = false
|
||||||
|
|
||||||
|
[health]
|
||||||
|
aes_key = "__MUST_SET_VIA_ENV__"
|
||||||
|
hmac_key = "__MUST_SET_VIA_ENV__"
|
||||||
|
|
||||||
|
[crypto]
|
||||||
|
kek = "__MUST_SET_VIA_ENV__"
|
||||||
|
|
||||||
|
[ai]
|
||||||
|
default_provider = "claude"
|
||||||
|
api_key = ""
|
||||||
|
base_url = "https://api.anthropic.com"
|
||||||
|
model = "claude-sonnet-4-6"
|
||||||
|
max_tokens = 2048
|
||||||
|
temperature = 0.3
|
||||||
|
cache_ttl_seconds = 604800
|
||||||
|
rate_limit_patient_daily = 10
|
||||||
|
quota_check_enabled = true
|
||||||
|
|
||||||
|
[ai.providers.claude]
|
||||||
|
provider_type = "claude"
|
||||||
|
api_key_env = "ANTHROPIC_API_KEY"
|
||||||
|
base_url = "https://api.anthropic.com"
|
||||||
|
default_model = "claude-sonnet-4-6"
|
||||||
|
max_tokens = 2048
|
||||||
|
temperature = 0.3
|
||||||
|
is_enabled = true
|
||||||
|
|
||||||
|
[ai.providers.openai]
|
||||||
|
provider_type = "openai"
|
||||||
|
api_key_env = "OPENAI_API_KEY"
|
||||||
|
base_url = "https://api.openai.com"
|
||||||
|
default_model = "gpt-4o"
|
||||||
|
max_tokens = 2048
|
||||||
|
temperature = 0.3
|
||||||
|
is_enabled = false
|
||||||
|
|
||||||
|
[ai.providers.ollama]
|
||||||
|
provider_type = "ollama"
|
||||||
|
base_url = "http://localhost:11434"
|
||||||
|
default_model = "qwen2.5:7b"
|
||||||
|
max_tokens = 2048
|
||||||
|
temperature = 0.3
|
||||||
|
is_enabled = false
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
upload_dir = "./uploads"
|
||||||
|
max_file_size = "10MB"
|
||||||
29
crates/erp-auth/Cargo.toml
Normal file
29
crates/erp-auth/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "erp-auth"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
erp-core.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
sea-orm.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
jsonwebtoken.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
validator.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
aes.workspace = true
|
||||||
|
cbc.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
base64 = "0.22"
|
||||||
|
redis.workspace = true
|
||||||
|
dashmap.workspace = true
|
||||||
83
crates/erp-auth/src/auth_state.rs
Normal file
83
crates/erp-auth/src/auth_state.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use erp_core::crypto::PiiCrypto;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Auth-specific state extracted from the server's AppState via `FromRef`.
|
||||||
|
///
|
||||||
|
/// This avoids a circular dependency between erp-auth and erp-server.
|
||||||
|
/// The server crate implements `FromRef<AppState> for AuthState` so that
|
||||||
|
/// Axum handlers in erp-auth can extract `State<AuthState>` directly.
|
||||||
|
///
|
||||||
|
/// Contains everything the auth handlers need:
|
||||||
|
/// - Database connection for user/credential lookups
|
||||||
|
/// - EventBus for publishing domain events
|
||||||
|
/// - JWT configuration for token signing and validation
|
||||||
|
/// - Default tenant ID for the bootstrap phase
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub event_bus: EventBus,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub access_ttl_secs: i64,
|
||||||
|
pub refresh_ttl_secs: i64,
|
||||||
|
pub default_tenant_id: Uuid,
|
||||||
|
pub wechat_appid: String,
|
||||||
|
pub wechat_secret: String,
|
||||||
|
pub wechat_dev_mode: bool,
|
||||||
|
pub redis: Option<redis::Client>,
|
||||||
|
pub crypto: PiiCrypto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
||||||
|
///
|
||||||
|
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
|
||||||
|
pub fn parse_ttl(ttl: &str) -> i64 {
|
||||||
|
let ttl = ttl.trim();
|
||||||
|
if let Some(num) = ttl.strip_suffix('s') {
|
||||||
|
num.parse::<i64>().unwrap_or(900)
|
||||||
|
} else if let Some(num) = ttl.strip_suffix('m') {
|
||||||
|
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
|
||||||
|
} else if let Some(num) = ttl.strip_suffix('h') {
|
||||||
|
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
|
||||||
|
} else if let Some(num) = ttl.strip_suffix('d') {
|
||||||
|
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
|
||||||
|
} else {
|
||||||
|
ttl.parse::<i64>().unwrap_or(900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ttl_seconds() {
|
||||||
|
assert_eq!(parse_ttl("900s"), 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ttl_minutes() {
|
||||||
|
assert_eq!(parse_ttl("15m"), 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ttl_hours() {
|
||||||
|
assert_eq!(parse_ttl("1h"), 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ttl_days() {
|
||||||
|
assert_eq!(parse_ttl("7d"), 604800);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ttl_raw_number() {
|
||||||
|
assert_eq!(parse_ttl("300"), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ttl_fallback_on_invalid() {
|
||||||
|
assert_eq!(parse_ttl("invalid"), 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
506
crates/erp-auth/src/dto.rs
Normal file
506
crates/erp-auth/src/dto.rs
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||||
|
|
||||||
|
// --- Auth DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct LoginReq {
|
||||||
|
#[validate(length(min = 1, message = "用户名不能为空"))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 1, max = 128, message = "密码长度需在1-128之间"))]
|
||||||
|
pub password: String,
|
||||||
|
/// 客户端类型: "miniprogram" 允许患者角色登录
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct LoginResp {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
pub user: UserResp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct RefreshReq {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wechat DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct WechatLoginReq {
|
||||||
|
#[validate(length(min = 1, message = "code 不能为空"))]
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct WechatLoginResp {
|
||||||
|
pub bound: bool,
|
||||||
|
pub openid: String,
|
||||||
|
pub token: Option<LoginResp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct WechatBindPhoneReq {
|
||||||
|
#[validate(length(min = 1, message = "openid 不能为空"))]
|
||||||
|
pub openid: String,
|
||||||
|
#[validate(length(min = 1, message = "encrypted_data 不能为空"))]
|
||||||
|
pub encrypted_data: String,
|
||||||
|
#[validate(length(min = 1, message = "iv 不能为空"))]
|
||||||
|
pub iv: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改密码请求
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct ChangePasswordReq {
|
||||||
|
#[validate(length(min = 1, message = "当前密码不能为空"))]
|
||||||
|
pub current_password: String,
|
||||||
|
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 管理员重置用户密码请求
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct ResetPasswordReq {
|
||||||
|
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
||||||
|
pub new_password: String,
|
||||||
|
#[validate(range(min = 0))]
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct UserResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub phone: Option<String>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub roles: Vec<RoleResp>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateUserReq {
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub username: String,
|
||||||
|
#[validate(length(min = 6, max = 128))]
|
||||||
|
pub password: String,
|
||||||
|
#[validate(email)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[validate(length(max = 20))]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[validate(length(max = 100))]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateUserReq {
|
||||||
|
/// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
self.username = sanitize_string(&self.username);
|
||||||
|
self.email = sanitize_option(self.email.take());
|
||||||
|
self.phone = sanitize_option(self.phone.take());
|
||||||
|
self.display_name = sanitize_option(self.display_name.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateUserReq {
|
||||||
|
#[validate(email)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[validate(length(max = 20))]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[validate(length(max = 100))]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 20))]
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateUserReq {
|
||||||
|
/// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
self.email = sanitize_option(self.email.take());
|
||||||
|
self.phone = sanitize_option(self.phone.take());
|
||||||
|
self.display_name = sanitize_option(self.display_name.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Role DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct RoleResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_system: bool,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateRoleReq {
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub code: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateRoleReq {
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct AssignRolesReq {
|
||||||
|
#[validate(length(min = 1, message = "至少需要分配一个角色"))]
|
||||||
|
pub role_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permission DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct PermissionResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub code: String,
|
||||||
|
pub name: String,
|
||||||
|
pub resource: String,
|
||||||
|
pub action: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct AssignPermissionsReq {
|
||||||
|
#[validate(length(min = 1, message = "至少需要分配一个权限"))]
|
||||||
|
pub permission_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Organization DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct OrganizationResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub level: i32,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub children: Vec<OrganizationResp>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateOrganizationReq {
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateOrganizationReq {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Department DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DepartmentResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub org_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub manager_id: Option<Uuid>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub children: Vec<DepartmentResp>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateDepartmentReq {
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub manager_id: Option<Uuid>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateDepartmentReq {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub manager_id: Option<Uuid>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Position DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct PositionResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub dept_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub level: i32,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreatePositionReq {
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub level: Option<i32>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdatePositionReq {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub level: Option<i32>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_req_valid() {
|
||||||
|
let req = LoginReq {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "password123".to_string(),
|
||||||
|
client_type: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_req_empty_username_fails() {
|
||||||
|
let req = LoginReq {
|
||||||
|
username: "".to_string(),
|
||||||
|
password: "password123".to_string(),
|
||||||
|
client_type: None,
|
||||||
|
};
|
||||||
|
let result = req.validate();
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_password_req_valid() {
|
||||||
|
let req = ChangePasswordReq {
|
||||||
|
current_password: "oldPassword123".to_string(),
|
||||||
|
new_password: "newPassword456".to_string(),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_password_req_empty_current_fails() {
|
||||||
|
let req = ChangePasswordReq {
|
||||||
|
current_password: "".to_string(),
|
||||||
|
new_password: "newPassword456".to_string(),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_password_req_short_new_fails() {
|
||||||
|
let req = ChangePasswordReq {
|
||||||
|
current_password: "oldPassword123".to_string(),
|
||||||
|
new_password: "12345".to_string(), // min 6
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_password_req_long_new_fails() {
|
||||||
|
let req = ChangePasswordReq {
|
||||||
|
current_password: "oldPassword123".to_string(),
|
||||||
|
new_password: "a".repeat(129), // max 128
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_req_empty_password_fails() {
|
||||||
|
let req = LoginReq {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "".to_string(),
|
||||||
|
client_type: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_user_req_valid() {
|
||||||
|
let req = CreateUserReq {
|
||||||
|
username: "alice".to_string(),
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
email: Some("alice@example.com".to_string()),
|
||||||
|
phone: None,
|
||||||
|
display_name: Some("Alice".to_string()),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_user_req_short_password_fails() {
|
||||||
|
let req = CreateUserReq {
|
||||||
|
username: "bob".to_string(),
|
||||||
|
password: "12345".to_string(), // min 6
|
||||||
|
email: None,
|
||||||
|
phone: None,
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_user_req_empty_username_fails() {
|
||||||
|
let req = CreateUserReq {
|
||||||
|
username: "".to_string(),
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
email: None,
|
||||||
|
phone: None,
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_user_req_invalid_email_fails() {
|
||||||
|
let req = CreateUserReq {
|
||||||
|
username: "charlie".to_string(),
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
email: Some("not-an-email".to_string()),
|
||||||
|
phone: None,
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_user_req_long_username_fails() {
|
||||||
|
let req = CreateUserReq {
|
||||||
|
username: "a".repeat(51), // max 50
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
email: None,
|
||||||
|
phone: None,
|
||||||
|
display_name: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_role_req_valid() {
|
||||||
|
let req = CreateRoleReq {
|
||||||
|
name: "管理员".to_string(),
|
||||||
|
code: "admin".to_string(),
|
||||||
|
description: Some("系统管理员".to_string()),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_role_req_empty_name_fails() {
|
||||||
|
let req = CreateRoleReq {
|
||||||
|
name: "".to_string(),
|
||||||
|
code: "admin".to_string(),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_role_req_empty_code_fails() {
|
||||||
|
let req = CreateRoleReq {
|
||||||
|
name: "管理员".to_string(),
|
||||||
|
code: "".to_string(),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_org_req_valid() {
|
||||||
|
let req = CreateOrganizationReq {
|
||||||
|
name: "总部".to_string(),
|
||||||
|
code: Some("HQ".to_string()),
|
||||||
|
parent_id: None,
|
||||||
|
sort_order: Some(0),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_org_req_empty_name_fails() {
|
||||||
|
let req = CreateOrganizationReq {
|
||||||
|
name: "".to_string(),
|
||||||
|
code: None,
|
||||||
|
parent_id: None,
|
||||||
|
sort_order: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dept_req_valid() {
|
||||||
|
let req = CreateDepartmentReq {
|
||||||
|
name: "技术部".to_string(),
|
||||||
|
code: Some("TECH".to_string()),
|
||||||
|
parent_id: None,
|
||||||
|
manager_id: None,
|
||||||
|
sort_order: Some(1),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_position_req_valid() {
|
||||||
|
let req = CreatePositionReq {
|
||||||
|
name: "高级工程师".to_string(),
|
||||||
|
code: Some("SENIOR".to_string()),
|
||||||
|
level: Some(3),
|
||||||
|
sort_order: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_position_req_empty_name_fails() {
|
||||||
|
let req = CreatePositionReq {
|
||||||
|
name: "".to_string(),
|
||||||
|
code: None,
|
||||||
|
level: None,
|
||||||
|
sort_order: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
68
crates/erp-auth/src/entity/department.rs
Normal file
68
crates/erp-auth/src/entity/department.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "departments")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub org_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub manager_id: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::organization::Entity",
|
||||||
|
from = "Column::OrgId",
|
||||||
|
to = "super::organization::Column::Id",
|
||||||
|
on_delete = "Restrict"
|
||||||
|
)]
|
||||||
|
Organization,
|
||||||
|
#[sea_orm(has_many = "super::position::Entity")]
|
||||||
|
Position,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::ManagerId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "SetNull"
|
||||||
|
)]
|
||||||
|
Manager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::organization::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Organization.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::position::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Position.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Manager.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
12
crates/erp-auth/src/entity/mod.rs
Normal file
12
crates/erp-auth/src/entity/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod department;
|
||||||
|
pub mod organization;
|
||||||
|
pub mod permission;
|
||||||
|
pub mod position;
|
||||||
|
pub mod role;
|
||||||
|
pub mod role_permission;
|
||||||
|
pub mod user;
|
||||||
|
pub mod user_credential;
|
||||||
|
pub mod user_department;
|
||||||
|
pub mod user_role;
|
||||||
|
pub mod user_token;
|
||||||
|
pub mod wechat_user;
|
||||||
40
crates/erp-auth/src/entity/organization.rs
Normal file
40
crates/erp-auth/src/entity/organization.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "organizations")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub level: i32,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::department::Entity")]
|
||||||
|
Department,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::department::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Department.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
37
crates/erp-auth/src/entity/permission.rs
Normal file
37
crates/erp-auth/src/entity/permission.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "permissions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub code: String,
|
||||||
|
pub name: String,
|
||||||
|
pub resource: String,
|
||||||
|
pub action: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||||
|
RolePermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::role_permission::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::RolePermission.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
42
crates/erp-auth/src/entity/position.rs
Normal file
42
crates/erp-auth/src/entity/position.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "positions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub dept_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub level: i32,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::department::Entity",
|
||||||
|
from = "Column::DeptId",
|
||||||
|
to = "super::department::Column::Id",
|
||||||
|
on_delete = "Restrict"
|
||||||
|
)]
|
||||||
|
Department,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::department::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Department.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
44
crates/erp-auth/src/entity/role.rs
Normal file
44
crates/erp-auth/src/entity/role.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "roles")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_system: bool,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||||
|
RolePermission,
|
||||||
|
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||||
|
UserRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::role_permission::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::RolePermission.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user_role::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::UserRole.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
53
crates/erp-auth/src/entity/role_permission.rs
Normal file
53
crates/erp-auth/src/entity/role_permission.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "role_permissions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub role_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub permission_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
/// 行级数据权限范围: all, self, department, department_tree
|
||||||
|
pub data_scope: String,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::role::Entity",
|
||||||
|
from = "Column::RoleId",
|
||||||
|
to = "super::role::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Role,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::permission::Entity",
|
||||||
|
from = "Column::PermissionId",
|
||||||
|
to = "super::permission::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Permission,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::role::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Role.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::permission::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Permission.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
59
crates/erp-auth/src/entity/user.rs
Normal file
59
crates/erp-auth/src/entity/user.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_login_at: Option<DateTimeUtc>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::user_credential::Entity")]
|
||||||
|
UserCredential,
|
||||||
|
#[sea_orm(has_many = "super::user_token::Entity")]
|
||||||
|
UserToken,
|
||||||
|
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||||
|
UserRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user_credential::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::UserCredential.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user_token::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::UserToken.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user_role::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::UserRole.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_credentials")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub credential_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub credential_data: Option<serde_json::Value>,
|
||||||
|
pub verified: bool,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
54
crates/erp-auth/src/entity/user_department.rs
Normal file
54
crates/erp-auth/src/entity/user_department.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_departments")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub department_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub is_primary: bool,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::department::Entity",
|
||||||
|
from = "Column::DepartmentId",
|
||||||
|
to = "super::department::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Department,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::department::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Department.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
51
crates/erp-auth/src/entity/user_role.rs
Normal file
51
crates/erp-auth/src/entity/user_role.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_roles")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub role_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::role::Entity",
|
||||||
|
from = "Column::RoleId",
|
||||||
|
to = "super::role::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::role::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Role.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
44
crates/erp-auth/src/entity/user_token.rs
Normal file
44
crates/erp-auth/src/entity/user_token.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_tokens")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub expires_at: DateTimeUtc,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub revoked_at: Option<DateTimeUtc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub device_info: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
41
crates/erp-auth/src/entity/wechat_user.rs
Normal file
41
crates/erp-auth/src/entity/wechat_user.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "wechat_users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub openid: String,
|
||||||
|
#[sea_orm(column_name = "union_id")]
|
||||||
|
pub union_id: Option<String>,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub phone: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
105
crates/erp-auth/src/error.rs
Normal file
105
crates/erp-auth/src/error.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use erp_core::error::AppError;
|
||||||
|
|
||||||
|
/// Auth module error types
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error("用户名或密码错误")]
|
||||||
|
InvalidCredentials,
|
||||||
|
|
||||||
|
#[error("Token 已过期")]
|
||||||
|
TokenExpired,
|
||||||
|
|
||||||
|
#[error("Token 已被吊销")]
|
||||||
|
TokenRevoked,
|
||||||
|
|
||||||
|
#[error("用户已被{0}")]
|
||||||
|
UserDisabled(String),
|
||||||
|
|
||||||
|
#[error("密码哈希错误")]
|
||||||
|
HashError(String),
|
||||||
|
|
||||||
|
#[error("JWT 错误: {0}")]
|
||||||
|
JwtError(#[from] jsonwebtoken::errors::Error),
|
||||||
|
|
||||||
|
#[error("数据库错误: {0}")]
|
||||||
|
DbError(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthError> for AppError {
|
||||||
|
fn from(err: AuthError) -> Self {
|
||||||
|
match err {
|
||||||
|
AuthError::InvalidCredentials => AppError::Unauthorized,
|
||||||
|
AuthError::TokenExpired => AppError::Unauthorized,
|
||||||
|
AuthError::TokenRevoked => AppError::Unauthorized,
|
||||||
|
AuthError::UserDisabled(s) => AppError::Forbidden(s),
|
||||||
|
AuthError::Validation(s) => AppError::Validation(s),
|
||||||
|
AuthError::Forbidden(s) => AppError::Forbidden(s),
|
||||||
|
AuthError::DbError(_) => AppError::Internal(err.to_string()),
|
||||||
|
AuthError::HashError(_) => AppError::Internal(err.to_string()),
|
||||||
|
AuthError::JwtError(_) => AppError::Unauthorized,
|
||||||
|
AuthError::VersionMismatch => AppError::VersionMismatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AuthResult<T> = Result<T, AuthError>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_error_invalid_credentials_maps_to_unauthorized() {
|
||||||
|
let app: AppError = AuthError::InvalidCredentials.into();
|
||||||
|
match app {
|
||||||
|
AppError::Unauthorized => {}
|
||||||
|
other => panic!("Expected Unauthorized, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_error_token_expired_maps_to_unauthorized() {
|
||||||
|
let app: AppError = AuthError::TokenExpired.into();
|
||||||
|
match app {
|
||||||
|
AppError::Unauthorized => {}
|
||||||
|
other => panic!("Expected Unauthorized, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_error_user_disabled_maps_to_forbidden() {
|
||||||
|
let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"),
|
||||||
|
other => panic!("Expected Forbidden, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_error_hash_error_maps_to_internal() {
|
||||||
|
let app: AppError = AuthError::HashError("argon2 failed".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::Internal(_) => {}
|
||||||
|
other => panic!("Expected Internal, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_error_validation_maps_to_validation() {
|
||||||
|
let app: AppError = AuthError::Validation("用户名已存在".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"),
|
||||||
|
other => panic!("Expected Validation, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
crates/erp-auth/src/handler/auth_handler.rs
Normal file
192
crates/erp-auth/src/handler/auth_handler.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq};
|
||||||
|
use crate::service::auth_service::{AuthService, JwtConfig, RequestInfo};
|
||||||
|
|
||||||
|
/// 从请求头中提取客户端信息。
|
||||||
|
fn extract_request_info(headers: &HeaderMap) -> RequestInfo {
|
||||||
|
let ip = headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.or_else(|| headers.get("x-real-ip"))
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
|
||||||
|
let user_agent = headers
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
RequestInfo { ip, user_agent }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/login",
|
||||||
|
request_body = LoginReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "登录成功", body = ApiResponse<LoginResp>),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
(status = 401, description = "用户名或密码错误"),
|
||||||
|
),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/login
|
||||||
|
///
|
||||||
|
/// Authenticates a user with username and password, returning access and refresh tokens.
|
||||||
|
///
|
||||||
|
/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`.
|
||||||
|
/// In production, this will come from a tenant-resolution middleware.
|
||||||
|
pub async fn login<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<LoginReq>,
|
||||||
|
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let req_info = extract_request_info(&headers);
|
||||||
|
let tenant_id = state.default_tenant_id;
|
||||||
|
|
||||||
|
let jwt_config = JwtConfig {
|
||||||
|
secret: &state.jwt_secret,
|
||||||
|
access_ttl_secs: state.access_ttl_secs,
|
||||||
|
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = AuthService::login(
|
||||||
|
tenant_id,
|
||||||
|
&req.username,
|
||||||
|
&req.password,
|
||||||
|
&state.db,
|
||||||
|
&jwt_config,
|
||||||
|
&state.event_bus,
|
||||||
|
Some(&req_info),
|
||||||
|
req.client_type.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/refresh",
|
||||||
|
request_body = RefreshReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "刷新成功", body = ApiResponse<LoginResp>),
|
||||||
|
(status = 401, description = "刷新令牌无效或已过期"),
|
||||||
|
),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/refresh
|
||||||
|
///
|
||||||
|
/// Validates an existing refresh token, revokes it (rotation), and issues
|
||||||
|
/// a new access + refresh token pair.
|
||||||
|
pub async fn refresh<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Json(req): Json<RefreshReq>,
|
||||||
|
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let jwt_config = JwtConfig {
|
||||||
|
secret: &state.jwt_secret,
|
||||||
|
access_ttl_secs: state.access_ttl_secs,
|
||||||
|
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/logout",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "已成功登出"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/logout
|
||||||
|
///
|
||||||
|
/// Revokes all refresh tokens for the authenticated user, effectively
|
||||||
|
/// logging them out on all devices.
|
||||||
|
pub async fn logout<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let req_info = extract_request_info(&headers);
|
||||||
|
AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db, Some(&req_info)).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("已成功登出".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/change-password",
|
||||||
|
request_body = ChangePasswordReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "密码修改成功,需重新登录"),
|
||||||
|
(status = 400, description = "当前密码不正确"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/change-password
|
||||||
|
///
|
||||||
|
/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销,
|
||||||
|
/// 用户需要在所有设备上重新登录。
|
||||||
|
pub async fn change_password<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<ChangePasswordReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let req_info = extract_request_info(&headers);
|
||||||
|
AuthService::change_password(
|
||||||
|
ctx.user_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
&req.current_password,
|
||||||
|
&req.new_password,
|
||||||
|
&state.db,
|
||||||
|
Some(&req_info),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("密码修改成功,请重新登录".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
5
crates/erp-auth/src/handler/mod.rs
Normal file
5
crates/erp-auth/src/handler/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod auth_handler;
|
||||||
|
pub mod org_handler;
|
||||||
|
pub mod role_handler;
|
||||||
|
pub mod user_handler;
|
||||||
|
pub mod wechat_handler;
|
||||||
460
crates/erp-auth/src/handler/org_handler.rs
Normal file
460
crates/erp-auth/src/handler/org_handler.rs
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{
|
||||||
|
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
||||||
|
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
||||||
|
};
|
||||||
|
use crate::service::dept_service::DeptService;
|
||||||
|
use crate::service::org_service::OrgService;
|
||||||
|
use crate::service::position_service::PositionService;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
|
||||||
|
// --- Organization handlers ---
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/organizations",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<OrganizationResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/organizations
|
||||||
|
///
|
||||||
|
/// List all organizations within the current tenant as a nested tree.
|
||||||
|
/// Requires the `organization.list` permission.
|
||||||
|
pub async fn list_organizations<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "organization.list")?;
|
||||||
|
|
||||||
|
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(tree)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/organizations",
|
||||||
|
request_body = CreateOrganizationReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "创建成功", body = ApiResponse<OrganizationResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/organizations
|
||||||
|
///
|
||||||
|
/// Create a new organization within the current tenant.
|
||||||
|
/// Requires the `organization.create` permission.
|
||||||
|
pub async fn create_organization<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateOrganizationReq>,
|
||||||
|
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "organization.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let org = OrgService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(org)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/organizations/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "组织ID")),
|
||||||
|
request_body = UpdateOrganizationReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "更新成功", body = ApiResponse<OrganizationResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "组织不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/organizations/{id}
|
||||||
|
///
|
||||||
|
/// Update editable organization fields (name, code, sort_order).
|
||||||
|
/// Requires the `organization.update` permission.
|
||||||
|
pub async fn update_organization<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateOrganizationReq>,
|
||||||
|
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "organization.update")?;
|
||||||
|
|
||||||
|
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(org)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/organizations/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "组织ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "组织已删除"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "组织不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/organizations/{id}
|
||||||
|
///
|
||||||
|
/// Soft-delete an organization by ID.
|
||||||
|
/// Requires the `organization.delete` permission.
|
||||||
|
pub async fn delete_organization<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "organization.delete")?;
|
||||||
|
|
||||||
|
OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("组织已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Department handlers ---
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/organizations/{org_id}/departments",
|
||||||
|
params(("org_id" = Uuid, Path, description = "组织ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<DepartmentResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/organizations/{org_id}/departments
|
||||||
|
///
|
||||||
|
/// List all departments for an organization as a nested tree.
|
||||||
|
/// Requires the `department.list` permission.
|
||||||
|
pub async fn list_departments<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(org_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "department.list")?;
|
||||||
|
|
||||||
|
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(tree)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/organizations/{org_id}/departments",
|
||||||
|
params(("org_id" = Uuid, Path, description = "组织ID")),
|
||||||
|
request_body = CreateDepartmentReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "创建成功", body = ApiResponse<DepartmentResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/organizations/{org_id}/departments
|
||||||
|
///
|
||||||
|
/// Create a new department under the specified organization.
|
||||||
|
/// Requires the `department.create` permission.
|
||||||
|
pub async fn create_department<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(org_id): Path<Uuid>,
|
||||||
|
Json(req): Json<CreateDepartmentReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "department.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let dept = DeptService::create(
|
||||||
|
org_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(dept)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/departments/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "部门ID")),
|
||||||
|
request_body = UpdateDepartmentReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "更新成功", body = ApiResponse<DepartmentResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "部门不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/departments/{id}
|
||||||
|
///
|
||||||
|
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||||
|
/// Requires the `department.update` permission.
|
||||||
|
pub async fn update_department<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateDepartmentReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "department.update")?;
|
||||||
|
|
||||||
|
let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(dept)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/departments/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "部门ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "部门已删除"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "部门不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/departments/{id}
|
||||||
|
///
|
||||||
|
/// Soft-delete a department by ID.
|
||||||
|
/// Requires the `department.delete` permission.
|
||||||
|
pub async fn delete_department<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "department.delete")?;
|
||||||
|
|
||||||
|
DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("部门已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Position handlers ---
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/departments/{dept_id}/positions",
|
||||||
|
params(("dept_id" = Uuid, Path, description = "部门ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<PositionResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/departments/{dept_id}/positions
|
||||||
|
///
|
||||||
|
/// List all positions for a department.
|
||||||
|
/// Requires the `position.list` permission.
|
||||||
|
pub async fn list_positions<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(dept_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "position.list")?;
|
||||||
|
|
||||||
|
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(positions)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/departments/{dept_id}/positions",
|
||||||
|
params(("dept_id" = Uuid, Path, description = "部门ID")),
|
||||||
|
request_body = CreatePositionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "创建成功", body = ApiResponse<PositionResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/departments/{dept_id}/positions
|
||||||
|
///
|
||||||
|
/// Create a new position under the specified department.
|
||||||
|
/// Requires the `position.create` permission.
|
||||||
|
pub async fn create_position<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(dept_id): Path<Uuid>,
|
||||||
|
Json(req): Json<CreatePositionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "position.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let pos = PositionService::create(
|
||||||
|
dept_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(pos)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/positions/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "岗位ID")),
|
||||||
|
request_body = UpdatePositionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "更新成功", body = ApiResponse<PositionResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "岗位不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/positions/{id}
|
||||||
|
///
|
||||||
|
/// Update editable position fields (name, code, level, sort_order).
|
||||||
|
/// Requires the `position.update` permission.
|
||||||
|
pub async fn update_position<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdatePositionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "position.update")?;
|
||||||
|
|
||||||
|
let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(pos)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/positions/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "岗位ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "岗位已删除"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "岗位不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "组织管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/positions/{id}
|
||||||
|
///
|
||||||
|
/// Soft-delete a position by ID.
|
||||||
|
/// Requires the `position.delete` permission.
|
||||||
|
pub async fn delete_position<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "position.delete")?;
|
||||||
|
|
||||||
|
PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("岗位已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
320
crates/erp-auth/src/handler/role_handler.rs
Normal file
320
crates/erp-auth/src/handler/role_handler.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
||||||
|
use crate::service::permission_service::PermissionService;
|
||||||
|
use crate::service::role_service::RoleService;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/roles",
|
||||||
|
params(Pagination),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<RoleResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/roles
|
||||||
|
///
|
||||||
|
/// List roles within the current tenant with pagination.
|
||||||
|
/// Requires the `role.list` permission.
|
||||||
|
pub async fn list_roles<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.list")?;
|
||||||
|
|
||||||
|
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||||
|
|
||||||
|
let page = pagination.page.unwrap_or(1);
|
||||||
|
let page_size = pagination.limit();
|
||||||
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: roles,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/roles",
|
||||||
|
request_body = CreateRoleReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "创建成功", body = ApiResponse<RoleResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/roles
|
||||||
|
///
|
||||||
|
/// Create a new role within the current tenant.
|
||||||
|
/// Requires the `role.create` permission.
|
||||||
|
pub async fn create_role<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateRoleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = RoleService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.name,
|
||||||
|
&req.code,
|
||||||
|
&req.description,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(role)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/roles/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "角色ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<RoleResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "角色不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/roles/:id
|
||||||
|
///
|
||||||
|
/// Fetch a single role by ID within the current tenant.
|
||||||
|
/// Requires the `role.read` permission.
|
||||||
|
pub async fn get_role<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.read")?;
|
||||||
|
|
||||||
|
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(role)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/roles/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "角色ID")),
|
||||||
|
request_body = UpdateRoleReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "更新成功", body = ApiResponse<RoleResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "角色不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/roles/:id
|
||||||
|
///
|
||||||
|
/// Update editable role fields (name, description).
|
||||||
|
/// Requires the `role.update` permission.
|
||||||
|
pub async fn update_role<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateRoleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.update")?;
|
||||||
|
|
||||||
|
let role = RoleService::update(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.name,
|
||||||
|
&req.description,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(ApiResponse::ok(role)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/roles/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "角色ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "角色已删除"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "角色不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/roles/:id
|
||||||
|
///
|
||||||
|
/// Soft-delete a role by ID within the current tenant.
|
||||||
|
/// System roles cannot be deleted.
|
||||||
|
/// Requires the `role.delete` permission.
|
||||||
|
pub async fn delete_role<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.delete")?;
|
||||||
|
|
||||||
|
RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("角色已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/roles/{id}/permissions",
|
||||||
|
params(("id" = Uuid, Path, description = "角色ID")),
|
||||||
|
request_body = AssignPermissionsReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "权限分配成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "角色不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/roles/:id/permissions
|
||||||
|
///
|
||||||
|
/// Replace all permission assignments for a role.
|
||||||
|
/// Requires the `role.update` permission.
|
||||||
|
pub async fn assign_permissions<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<AssignPermissionsReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.update")?;
|
||||||
|
|
||||||
|
RoleService::assign_permissions(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.permission_ids,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("权限分配成功".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/roles/{id}/permissions",
|
||||||
|
params(("id" = Uuid, Path, description = "角色ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "角色不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "角色管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/roles/:id/permissions
|
||||||
|
///
|
||||||
|
/// Fetch all permissions assigned to a role.
|
||||||
|
/// Requires the `role.read` permission.
|
||||||
|
pub async fn get_role_permissions<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "role.read")?;
|
||||||
|
|
||||||
|
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(perms)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/permissions",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "权限管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/permissions
|
||||||
|
///
|
||||||
|
/// List all permissions within the current tenant.
|
||||||
|
/// Requires the `permission.list` permission.
|
||||||
|
pub async fn list_permissions<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "permission.list")?;
|
||||||
|
|
||||||
|
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(perms)))
|
||||||
|
}
|
||||||
322
crates/erp-auth/src/handler/user_handler.rs
Normal file
322
crates/erp-auth/src/handler/user_handler.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{CreateUserReq, ResetPasswordReq, RoleResp, UpdateUserReq, UserResp};
|
||||||
|
use crate::service::user_service::UserService;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
|
||||||
|
/// Query parameters for user list endpoint.
|
||||||
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
|
pub struct UserListParams {
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
/// Optional search term — filters by username (case-insensitive contains).
|
||||||
|
pub search: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/users",
|
||||||
|
params(UserListParams),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<UserResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/users
|
||||||
|
///
|
||||||
|
/// List users within the current tenant with pagination and optional search.
|
||||||
|
/// Requires the `user.list` permission.
|
||||||
|
pub async fn list_users<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(params): Query<UserListParams>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<UserResp>>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.list")?;
|
||||||
|
|
||||||
|
let pagination = Pagination {
|
||||||
|
page: params.page,
|
||||||
|
page_size: params.page_size,
|
||||||
|
};
|
||||||
|
let (users, total) = UserService::list(
|
||||||
|
ctx.tenant_id,
|
||||||
|
&pagination,
|
||||||
|
params.search.as_deref(),
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let page = pagination.page.unwrap_or(1);
|
||||||
|
let page_size = pagination.limit();
|
||||||
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: users,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/users",
|
||||||
|
request_body = CreateUserReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "创建成功", body = ApiResponse<UserResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/users
|
||||||
|
///
|
||||||
|
/// Create a new user within the current tenant.
|
||||||
|
/// Requires the `user.create` permission.
|
||||||
|
pub async fn create_user<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(mut req): Json<CreateUserReq>,
|
||||||
|
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
req.sanitize();
|
||||||
|
|
||||||
|
let user = UserService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/users/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "用户ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<UserResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "用户不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/users/:id
|
||||||
|
///
|
||||||
|
/// Fetch a single user by ID within the current tenant.
|
||||||
|
/// Requires the `user.read` permission.
|
||||||
|
pub async fn get_user<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.read")?;
|
||||||
|
|
||||||
|
let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/users/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "用户ID")),
|
||||||
|
request_body = UpdateUserReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "更新成功", body = ApiResponse<UserResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "用户不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/users/:id
|
||||||
|
///
|
||||||
|
/// Update editable user fields.
|
||||||
|
/// Requires the `user.update` permission.
|
||||||
|
pub async fn update_user<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(mut req): Json<UpdateUserReq>,
|
||||||
|
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.update")?;
|
||||||
|
|
||||||
|
req.sanitize();
|
||||||
|
|
||||||
|
let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/users/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "用户ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "用户已删除"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "用户不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/users/:id
|
||||||
|
///
|
||||||
|
/// Soft-delete a user by ID within the current tenant.
|
||||||
|
/// Requires the `user.delete` permission.
|
||||||
|
pub async fn delete_user<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.delete")?;
|
||||||
|
|
||||||
|
UserService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("用户已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign roles request body.
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct AssignRolesReq {
|
||||||
|
pub role_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign roles response.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct AssignRolesResp {
|
||||||
|
pub roles: Vec<RoleResp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/users/{id}/roles",
|
||||||
|
params(("id" = Uuid, Path, description = "用户ID")),
|
||||||
|
request_body = AssignRolesReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "角色分配成功", body = ApiResponse<AssignRolesResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "用户不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/users/:id/roles
|
||||||
|
///
|
||||||
|
/// Replace all role assignments for a user within the current tenant.
|
||||||
|
/// Requires the `user.update` permission.
|
||||||
|
pub async fn assign_roles<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<AssignRolesReq>,
|
||||||
|
) -> Result<Json<ApiResponse<AssignRolesResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.update")?;
|
||||||
|
|
||||||
|
let roles =
|
||||||
|
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/users/{id}/reset-password",
|
||||||
|
params(("id" = Uuid, Path, description = "用户ID")),
|
||||||
|
request_body = ResetPasswordReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "密码重置成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
(status = 404, description = "用户不存在"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "用户管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/users/{id}/reset-password
|
||||||
|
///
|
||||||
|
/// 管理员重置指定用户密码。需要 `user.reset-password` 权限。
|
||||||
|
pub async fn reset_password<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<ResetPasswordReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "user.reset-password")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
UserService::reset_password(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.new_password,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("密码重置成功".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
86
crates/erp-auth/src/handler/wechat_handler.rs
Normal file
86
crates/erp-auth/src/handler/wechat_handler.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use axum::extract::{FromRef, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::ApiResponse;
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{LoginResp, WechatBindPhoneReq, WechatLoginReq, WechatLoginResp};
|
||||||
|
use crate::service::wechat_service::WechatService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/wechat/login",
|
||||||
|
request_body = WechatLoginReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "微信登录成功", body = ApiResponse<WechatLoginResp>),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/wechat/login
|
||||||
|
///
|
||||||
|
/// 微信小程序登录:用 code 换 openid,查询绑定状态。
|
||||||
|
/// 已绑定用户直接返回 JWT,未绑定用户返回 openid 供后续绑定。
|
||||||
|
pub async fn wechat_login<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Json(req): Json<WechatLoginReq>,
|
||||||
|
) -> Result<Json<ApiResponse<WechatLoginResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
code = %req.code,
|
||||||
|
tenant_id = %state.default_tenant_id,
|
||||||
|
has_appid = !state.wechat_appid.is_empty(),
|
||||||
|
has_secret = !state.wechat_secret.is_empty(),
|
||||||
|
"微信登录请求"
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
|
||||||
|
let tenant_id = state.default_tenant_id;
|
||||||
|
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
|
||||||
|
tracing::info!(
|
||||||
|
bound = resp.bound,
|
||||||
|
has_token = resp.token.is_some(),
|
||||||
|
"微信登录结果"
|
||||||
|
);
|
||||||
|
Ok(Json(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/wechat/bind-phone",
|
||||||
|
request_body = WechatBindPhoneReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "绑定成功", body = ApiResponse<LoginResp>),
|
||||||
|
(status = 400, description = "请求参数错误"),
|
||||||
|
),
|
||||||
|
tag = "认证"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/auth/wechat/bind-phone
|
||||||
|
///
|
||||||
|
/// 微信手机号绑定:解密手机号,创建/关联 user,签发 JWT。
|
||||||
|
pub async fn wechat_bind_phone<S>(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Json(req): Json<WechatBindPhoneReq>,
|
||||||
|
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||||
|
where
|
||||||
|
AuthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// TODO: 多租户微信登录需要设计租户解析策略
|
||||||
|
let tenant_id = state.default_tenant_id;
|
||||||
|
let resp =
|
||||||
|
WechatService::bind_phone(&state, tenant_id, &req.openid, &req.encrypted_data, &req.iv)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
11
crates/erp-auth/src/lib.rs
Normal file
11
crates/erp-auth/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod auth_state;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod entity;
|
||||||
|
pub mod error;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod module;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use auth_state::AuthState;
|
||||||
|
pub use module::AuthModule;
|
||||||
273
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
273
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::Request;
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::Response;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::request_info::REQUEST_INFO;
|
||||||
|
use erp_core::request_info::RequestInfo;
|
||||||
|
use erp_core::types::{DataScope, TenantContext};
|
||||||
|
|
||||||
|
use crate::service::token_service::TokenService;
|
||||||
|
|
||||||
|
type DeptIds = Vec<uuid::Uuid>;
|
||||||
|
type DataScopes = std::collections::HashMap<String, DataScope>;
|
||||||
|
type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
|
||||||
|
|
||||||
|
/// 用户权限数据缓存(user_id -> (department_ids, data_scopes, cached_at))
|
||||||
|
/// DashMap 分片并发,读写无锁竞争
|
||||||
|
static USER_SCOPE_CACHE: std::sync::LazyLock<DashMap<uuid::Uuid, ScopeCacheEntry>> =
|
||||||
|
std::sync::LazyLock::new(DashMap::new);
|
||||||
|
|
||||||
|
/// Access Token 吊销黑名单(token_hash -> 过期时间戳)
|
||||||
|
/// key = SHA-256(token) 前 16 字符,value = token 的 exp 时间戳
|
||||||
|
/// 惰性清理:检查时自动移除过期条目
|
||||||
|
static TOKEN_BLACKLIST: std::sync::LazyLock<DashMap<String, i64>> =
|
||||||
|
std::sync::LazyLock::new(DashMap::new);
|
||||||
|
|
||||||
|
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// 吊销单个 access token(直到其自然过期)
|
||||||
|
pub fn revoke_access_token(token: &str, exp: i64) {
|
||||||
|
let hash = token_hash(token);
|
||||||
|
TOKEN_BLACKLIST.insert(hash, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 吊销用户所有 token(清除权限缓存,强制下次请求重新认证)
|
||||||
|
pub fn revoke_all_user_tokens(user_id: uuid::Uuid) {
|
||||||
|
USER_SCOPE_CACHE.remove(&user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 token 是否已被吊销
|
||||||
|
fn is_token_revoked(token: &str, _exp: i64) -> bool {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
// 惰性清理过期条目
|
||||||
|
if TOKEN_BLACKLIST.len() > 10_000 {
|
||||||
|
TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now);
|
||||||
|
}
|
||||||
|
let hash = token_hash(token);
|
||||||
|
match TOKEN_BLACKLIST.get(&hash) {
|
||||||
|
Some(exp_ts) => {
|
||||||
|
if *exp_ts <= now {
|
||||||
|
drop(exp_ts);
|
||||||
|
TOKEN_BLACKLIST.remove(&hash);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_hash(token: &str) -> String {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
token.hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWT authentication middleware function.
|
||||||
|
///
|
||||||
|
/// Extracts the `Bearer` token from the `Authorization` header, validates it
|
||||||
|
/// using `TokenService::decode_token`, and injects a `TenantContext` into the
|
||||||
|
/// request extensions so downstream handlers can access tenant/user identity.
|
||||||
|
///
|
||||||
|
/// 同时提取请求的 IP 地址和 User-Agent,通过 task_local 传递给审计服务,
|
||||||
|
/// 使所有审计日志自动记录来源信息。
|
||||||
|
///
|
||||||
|
/// The `jwt_secret` parameter is passed explicitly by the server crate at
|
||||||
|
/// middleware construction time, avoiding any circular dependency between
|
||||||
|
/// erp-auth and erp-server.
|
||||||
|
///
|
||||||
|
/// When `db` is provided, the middleware queries `user_departments` to populate
|
||||||
|
/// `department_ids` in the `TenantContext`. If `db` is `None` or the query fails,
|
||||||
|
/// `department_ids` defaults to an empty list (equivalent to "all" data scope).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `AppError::Unauthorized` if:
|
||||||
|
/// - The `Authorization` header is missing
|
||||||
|
/// - The header value does not start with `"Bearer "`
|
||||||
|
/// - The token cannot be decoded or has expired
|
||||||
|
/// - The token type is not "access"
|
||||||
|
pub async fn jwt_auth_middleware_fn(
|
||||||
|
jwt_secret: String,
|
||||||
|
db: Option<sea_orm::DatabaseConnection>,
|
||||||
|
req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// 优先从 Authorization 头提取 token;
|
||||||
|
// 回退到 URL query parameter ?token=xxx(SSE/EventSource 无法设置自定义头)
|
||||||
|
let token = req
|
||||||
|
.headers()
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|h| h.strip_prefix("Bearer "))
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| {
|
||||||
|
req.uri().query().and_then(|q| {
|
||||||
|
q.split('&')
|
||||||
|
.find_map(|pair| pair.strip_prefix("token="))
|
||||||
|
.map(String::from)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
let claims =
|
||||||
|
TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
// 检查 token 是否已被吊销(密码修改/管理员强制下线)
|
||||||
|
if is_token_revoked(&token, claims.exp) {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify this is an access token, not a refresh token
|
||||||
|
if claims.token_type != "access" {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询用户所属部门 ID 列表 + 权限数据范围(带 60 秒缓存)
|
||||||
|
let cached = USER_SCOPE_CACHE.get(&claims.sub).and_then(|entry| {
|
||||||
|
let (_, _, at) = entry.value();
|
||||||
|
if at.elapsed() < SCOPE_CACHE_TTL {
|
||||||
|
let (depts, scopes, _) = entry.value();
|
||||||
|
Some((depts.clone(), scopes.clone()))
|
||||||
|
} else {
|
||||||
|
drop(entry);
|
||||||
|
USER_SCOPE_CACHE.remove(&claims.sub);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (department_ids, permission_data_scopes) = match cached {
|
||||||
|
Some(hit) => hit,
|
||||||
|
None => fetch_and_cache_scopes(claims.sub, claims.tid, &db).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取请求来源信息(IP + User-Agent),用于审计日志
|
||||||
|
let request_info = RequestInfo::from_headers(req.headers());
|
||||||
|
|
||||||
|
let ctx = TenantContext {
|
||||||
|
tenant_id: claims.tid,
|
||||||
|
user_id: claims.sub,
|
||||||
|
roles: claims.roles,
|
||||||
|
permissions: claims.permissions,
|
||||||
|
department_ids,
|
||||||
|
permission_data_scopes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reconstruct the request with the TenantContext injected into extensions.
|
||||||
|
// We cannot borrow `req` mutably after reading headers, so we rebuild.
|
||||||
|
let (parts, body) = req.into_parts();
|
||||||
|
let mut req = Request::from_parts(parts, body);
|
||||||
|
req.extensions_mut().insert(ctx);
|
||||||
|
|
||||||
|
// 在 task_local scope 中运行后续处理,审计服务可自动读取请求信息
|
||||||
|
Ok(REQUEST_INFO.scope(request_info, next.run(req)).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询用户所属的所有部门 ID(通过 user_departments 关联表)
|
||||||
|
async fn fetch_user_department_ids(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> Vec<uuid::Uuid> {
|
||||||
|
use crate::entity::user_department;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
|
||||||
|
user_department::Entity::find()
|
||||||
|
.filter(user_department::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_department::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.into_iter().map(|r| r.department_id).collect())
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(error = %e, "查询用户部门列表失败,默认为空");
|
||||||
|
vec![]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询用户每个权限的数据范围(从 role_permissions 表)
|
||||||
|
async fn fetch_permission_data_scopes(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> std::collections::HashMap<String, DataScope> {
|
||||||
|
use sea_orm::ConnectionTrait;
|
||||||
|
|
||||||
|
let sql = r#"
|
||||||
|
SELECT p.code, MIN(
|
||||||
|
CASE rp.data_scope
|
||||||
|
WHEN 'all' THEN 0
|
||||||
|
WHEN 'department_tree' THEN 1
|
||||||
|
WHEN 'department' THEN 2
|
||||||
|
WHEN 'self' THEN 3
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) AS scope_rank,
|
||||||
|
MIN(rp.data_scope) AS data_scope
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN role_permissions rp ON ur.role_id = rp.role_id AND ur.tenant_id = rp.tenant_id
|
||||||
|
JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE ur.user_id = $1
|
||||||
|
AND ur.tenant_id = $2
|
||||||
|
AND ur.deleted_at IS NULL
|
||||||
|
AND rp.deleted_at IS NULL
|
||||||
|
GROUP BY p.code
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[user_id.into(), tenant_id.into()],
|
||||||
|
);
|
||||||
|
|
||||||
|
match db.query_all(stmt).await {
|
||||||
|
Ok(rows) => {
|
||||||
|
let mut scopes = std::collections::HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
if let (Ok(code), Ok(scope)) = (
|
||||||
|
row.try_get_by_index::<String>(0),
|
||||||
|
row.try_get_by_index::<String>(2),
|
||||||
|
) {
|
||||||
|
scopes.insert(code, DataScope::parse_scope(&scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "查询权限数据范围失败,默认全部 All");
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 DB 查询部门 + 权限范围,并写入缓存
|
||||||
|
async fn fetch_and_cache_scopes(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
db: &Option<sea_orm::DatabaseConnection>,
|
||||||
|
) -> (
|
||||||
|
Vec<uuid::Uuid>,
|
||||||
|
std::collections::HashMap<String, DataScope>,
|
||||||
|
) {
|
||||||
|
let depts = match db {
|
||||||
|
Some(conn) => fetch_user_department_ids(user_id, tenant_id, conn).await,
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
let scopes = match db {
|
||||||
|
Some(conn) => fetch_permission_data_scopes(user_id, tenant_id, conn).await,
|
||||||
|
None => std::collections::HashMap::new(),
|
||||||
|
};
|
||||||
|
USER_SCOPE_CACHE.insert(
|
||||||
|
user_id,
|
||||||
|
(depts.clone(), scopes.clone(), std::time::Instant::now()),
|
||||||
|
);
|
||||||
|
// 惰性淘汰过期条目,防止 DashMap 无限增长
|
||||||
|
if USER_SCOPE_CACHE.len() > 500 {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
USER_SCOPE_CACHE.retain(|_, (_, _, at)| now.duration_since(*at) < SCOPE_CACHE_TTL);
|
||||||
|
}
|
||||||
|
(depts, scopes)
|
||||||
|
}
|
||||||
4
crates/erp-auth/src/middleware/mod.rs
Normal file
4
crates/erp-auth/src/middleware/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod jwt_auth;
|
||||||
|
|
||||||
|
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||||
|
pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens};
|
||||||
372
crates/erp-auth/src/module.rs
Normal file
372
crates/erp-auth/src/module.rs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
use axum::Router;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::error::AppResult;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||||
|
|
||||||
|
use crate::handler::{auth_handler, org_handler, role_handler, user_handler, wechat_handler};
|
||||||
|
|
||||||
|
/// Auth module implementing the `ErpModule` trait.
|
||||||
|
///
|
||||||
|
/// Manages identity, authentication, and user CRUD within the ERP platform.
|
||||||
|
/// This module has no dependencies on other business modules.
|
||||||
|
pub struct AuthModule;
|
||||||
|
|
||||||
|
impl AuthModule {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build public (unauthenticated) routes for the auth module.
|
||||||
|
///
|
||||||
|
/// These routes do not require a valid JWT token.
|
||||||
|
/// The caller wraps this into whatever state type the application uses.
|
||||||
|
pub fn public_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new()
|
||||||
|
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||||
|
.route(
|
||||||
|
"/auth/wechat/login",
|
||||||
|
axum::routing::post(wechat_handler::wechat_login),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/auth/wechat/bind-phone",
|
||||||
|
axum::routing::post(wechat_handler::wechat_bind_phone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh token routes — public but with higher rate limit (30/min vs 5/min for login).
|
||||||
|
pub fn refresh_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new().route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build protected (authenticated) routes for the auth module.
|
||||||
|
///
|
||||||
|
/// These routes require a valid JWT token, verified by the middleware layer.
|
||||||
|
/// The caller wraps this into whatever state type the application uses.
|
||||||
|
pub fn protected_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new()
|
||||||
|
.route("/auth/logout", axum::routing::post(auth_handler::logout))
|
||||||
|
.route(
|
||||||
|
"/auth/change-password",
|
||||||
|
axum::routing::post(auth_handler::change_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users",
|
||||||
|
axum::routing::get(user_handler::list_users).post(user_handler::create_user),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}",
|
||||||
|
axum::routing::get(user_handler::get_user)
|
||||||
|
.put(user_handler::update_user)
|
||||||
|
.delete(user_handler::delete_user),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/roles",
|
||||||
|
axum::routing::post(user_handler::assign_roles),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/reset-password",
|
||||||
|
axum::routing::post(user_handler::reset_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/roles",
|
||||||
|
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
|
||||||
|
)
|
||||||
|
// 精确匹配 /roles/permissions,必须在 /roles/{id} 之前注册
|
||||||
|
.route(
|
||||||
|
"/roles/permissions",
|
||||||
|
axum::routing::get(role_handler::list_permissions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/roles/{id}",
|
||||||
|
axum::routing::get(role_handler::get_role)
|
||||||
|
.put(role_handler::update_role)
|
||||||
|
.delete(role_handler::delete_role),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/roles/{id}/permissions",
|
||||||
|
axum::routing::get(role_handler::get_role_permissions)
|
||||||
|
.post(role_handler::assign_permissions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/permissions",
|
||||||
|
axum::routing::get(role_handler::list_permissions),
|
||||||
|
)
|
||||||
|
// Organization routes
|
||||||
|
.route(
|
||||||
|
"/organizations",
|
||||||
|
axum::routing::get(org_handler::list_organizations)
|
||||||
|
.post(org_handler::create_organization),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/organizations/{id}",
|
||||||
|
axum::routing::put(org_handler::update_organization)
|
||||||
|
.delete(org_handler::delete_organization),
|
||||||
|
)
|
||||||
|
// Department routes (nested under organization)
|
||||||
|
.route(
|
||||||
|
"/organizations/{org_id}/departments",
|
||||||
|
axum::routing::get(org_handler::list_departments)
|
||||||
|
.post(org_handler::create_department),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/departments/{id}",
|
||||||
|
axum::routing::put(org_handler::update_department)
|
||||||
|
.delete(org_handler::delete_department),
|
||||||
|
)
|
||||||
|
// Position routes (nested under department)
|
||||||
|
.route(
|
||||||
|
"/departments/{dept_id}/positions",
|
||||||
|
axum::routing::get(org_handler::list_positions).post(org_handler::create_position),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/positions/{id}",
|
||||||
|
axum::routing::put(org_handler::update_position)
|
||||||
|
.delete(org_handler::delete_position),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthModule {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ErpModule for AuthModule {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version(&self) -> &str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<&str> {
|
||||||
|
// Auth is a foundational module with no business-module dependencies.
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||||
|
// Auth 模块暂无跨模块事件订阅需求
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_tenant_created(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
_event_bus: &EventBus,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD").map_err(|_| {
|
||||||
|
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
|
||||||
|
erp_core::error::AppError::Internal("ERP__SUPER_ADMIN_PASSWORD 未设置".to_string())
|
||||||
|
})?;
|
||||||
|
crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||||
|
tracing::info!(tenant_id = %tenant_id, "Tenant auth initialized");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_tenant_deleted(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
// 软删除该租户下所有用户
|
||||||
|
let users = crate::entity::user::Entity::find()
|
||||||
|
.filter(crate::entity::user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(crate::entity::user::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
for user_model in users {
|
||||||
|
let current_version = user_model.version;
|
||||||
|
let active: crate::entity::user::ActiveModel = user_model.into();
|
||||||
|
let mut to_update: crate::entity::user::ActiveModel = active;
|
||||||
|
to_update.deleted_at = Set(Some(now));
|
||||||
|
to_update.updated_at = Set(now);
|
||||||
|
to_update.version = Set(current_version + 1);
|
||||||
|
let _ = to_update
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(tenant_id = %tenant_id, "Tenant users soft-deleted");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||||
|
vec![
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "user.list".into(),
|
||||||
|
name: "查看用户列表".into(),
|
||||||
|
description: "查看用户列表".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "user.create".into(),
|
||||||
|
name: "创建用户".into(),
|
||||||
|
description: "创建新用户".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "user.read".into(),
|
||||||
|
name: "查看用户详情".into(),
|
||||||
|
description: "查看用户信息".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "user.update".into(),
|
||||||
|
name: "编辑用户".into(),
|
||||||
|
description: "编辑用户信息".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "user.delete".into(),
|
||||||
|
name: "删除用户".into(),
|
||||||
|
description: "软删除用户".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "user.reset-password".into(),
|
||||||
|
name: "重置用户密码".into(),
|
||||||
|
description: "管理员重置指定用户密码".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "role.list".into(),
|
||||||
|
name: "查看角色列表".into(),
|
||||||
|
description: "查看角色列表".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "role.create".into(),
|
||||||
|
name: "创建角色".into(),
|
||||||
|
description: "创建新角色".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "role.read".into(),
|
||||||
|
name: "查看角色详情".into(),
|
||||||
|
description: "查看角色信息".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "role.update".into(),
|
||||||
|
name: "编辑角色".into(),
|
||||||
|
description: "编辑角色".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "role.delete".into(),
|
||||||
|
name: "删除角色".into(),
|
||||||
|
description: "删除角色".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "permission.list".into(),
|
||||||
|
name: "查看权限".into(),
|
||||||
|
description: "查看权限列表".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "organization.list".into(),
|
||||||
|
name: "查看组织列表".into(),
|
||||||
|
description: "查看组织列表".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "organization.create".into(),
|
||||||
|
name: "创建组织".into(),
|
||||||
|
description: "创建组织".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "organization.update".into(),
|
||||||
|
name: "编辑组织".into(),
|
||||||
|
description: "编辑组织".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "organization.delete".into(),
|
||||||
|
name: "删除组织".into(),
|
||||||
|
description: "删除组织".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "department.list".into(),
|
||||||
|
name: "查看部门列表".into(),
|
||||||
|
description: "查看部门列表".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "department.create".into(),
|
||||||
|
name: "创建部门".into(),
|
||||||
|
description: "创建部门".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "department.update".into(),
|
||||||
|
name: "编辑部门".into(),
|
||||||
|
description: "编辑部门".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "department.delete".into(),
|
||||||
|
name: "删除部门".into(),
|
||||||
|
description: "删除部门".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "position.list".into(),
|
||||||
|
name: "查看岗位列表".into(),
|
||||||
|
description: "查看岗位列表".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "position.create".into(),
|
||||||
|
name: "创建岗位".into(),
|
||||||
|
description: "创建岗位".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "position.update".into(),
|
||||||
|
name: "编辑岗位".into(),
|
||||||
|
description: "编辑岗位".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "position.delete".into(),
|
||||||
|
name: "删除岗位".into(),
|
||||||
|
description: "删除岗位".into(),
|
||||||
|
module: "auth".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn std::any::Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
414
crates/erp-auth/src/service/auth_service.rs
Normal file
414
crates/erp-auth/src/service/auth_service.rs
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{LoginResp, RoleResp, UserResp};
|
||||||
|
use crate::entity::{role, user, user_credential, user_role};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache;
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
use super::password;
|
||||||
|
use super::token_service::TokenService;
|
||||||
|
|
||||||
|
/// 请求来源信息,用于审计日志记录。
|
||||||
|
pub struct RequestInfo {
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWT configuration needed for token signing.
|
||||||
|
pub struct JwtConfig<'a> {
|
||||||
|
pub secret: &'a str,
|
||||||
|
pub access_ttl_secs: i64,
|
||||||
|
pub refresh_ttl_secs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication service handling login, token refresh, and logout.
|
||||||
|
pub struct AuthService;
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
/// Authenticate a user and issue access + refresh tokens.
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
/// 1. Look up user by tenant + username (soft-delete aware)
|
||||||
|
/// 2. Verify user status is "active"
|
||||||
|
/// 3. Fetch the stored password credential
|
||||||
|
/// 4. Verify password hash
|
||||||
|
/// 5. Collect roles and permissions
|
||||||
|
/// 6. Sign JWT tokens
|
||||||
|
/// 7. Update last_login_at
|
||||||
|
/// 8. Publish login event
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn login(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
password_plain: &str,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
jwt: &JwtConfig<'_>,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
req_info: Option<&RequestInfo>,
|
||||||
|
client_type: Option<&str>,
|
||||||
|
) -> AuthResult<LoginResp> {
|
||||||
|
// 1. Find user by tenant_id + username
|
||||||
|
let user_model = match user::Entity::find()
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::Username.eq(username))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
{
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
// 审计:用户不存在(登录失败)
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, None, "user.login_failed", "user").with_request_info(
|
||||||
|
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||||
|
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Check user status
|
||||||
|
if user_model.status != "active" {
|
||||||
|
return Err(AuthError::UserDisabled(user_model.status.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find password credential
|
||||||
|
let cred = user_credential::Entity::find()
|
||||||
|
.filter(user_credential::Column::UserId.eq(user_model.id))
|
||||||
|
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||||
|
.filter(user_credential::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
// 4. Verify password
|
||||||
|
let stored_hash = cred
|
||||||
|
.credential_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||||
|
.ok_or(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
if !password::verify_password(password_plain, stored_hash)? {
|
||||||
|
// 审计:密码错误(登录失败)
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user")
|
||||||
|
.with_request_info(
|
||||||
|
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||||
|
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Get roles and permissions
|
||||||
|
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 纯患者角色不允许登录管理端(同时拥有医护角色则放行)
|
||||||
|
// 小程序端 (client_type=miniprogram) 允许患者登录
|
||||||
|
let medical_roles = ["doctor", "nurse", "admin", "health_manager", "operator"];
|
||||||
|
let is_pure_patient =
|
||||||
|
roles.iter().all(|r| r == "patient") && roles.iter().any(|r| r == "patient");
|
||||||
|
let has_medical_role = roles.iter().any(|r| medical_roles.contains(&r.as_str()));
|
||||||
|
let is_miniprogram = client_type == Some("miniprogram");
|
||||||
|
if is_pure_patient && !has_medical_role && !is_miniprogram {
|
||||||
|
return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序端仅允许患者角色登录,医护角色请使用管理端
|
||||||
|
let has_patient_role = roles.iter().any(|r| r == "patient");
|
||||||
|
if is_miniprogram && !has_patient_role {
|
||||||
|
return Err(AuthError::Forbidden("医护账号请使用管理端登录".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 6. Sign tokens
|
||||||
|
let access_token = TokenService::sign_access_token(
|
||||||
|
user_model.id,
|
||||||
|
tenant_id,
|
||||||
|
roles.clone(),
|
||||||
|
permissions,
|
||||||
|
jwt.secret,
|
||||||
|
jwt.access_ttl_secs,
|
||||||
|
)?;
|
||||||
|
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||||
|
user_model.id,
|
||||||
|
tenant_id,
|
||||||
|
db,
|
||||||
|
jwt.secret,
|
||||||
|
jwt.refresh_ttl_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 7. Update last_login_at
|
||||||
|
let mut user_active: user::ActiveModel = user_model.clone().into();
|
||||||
|
user_active.last_login_at = Set(Some(Utc::now()));
|
||||||
|
user_active.updated_at = Set(Utc::now());
|
||||||
|
user_active.version = Set(user_active.version.take().unwrap_or(0) + 1);
|
||||||
|
user_active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 8. Build response
|
||||||
|
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
|
||||||
|
let user_resp = UserResp {
|
||||||
|
id: user_model.id,
|
||||||
|
username: user_model.username.clone(),
|
||||||
|
email: user_model.email,
|
||||||
|
phone: user_model.phone,
|
||||||
|
display_name: user_model.display_name,
|
||||||
|
avatar_url: user_model.avatar_url,
|
||||||
|
status: user_model.status,
|
||||||
|
roles: role_resps,
|
||||||
|
version: user_model.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 9. Publish event
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"user.login",
|
||||||
|
tenant_id,
|
||||||
|
erp_core::events::build_event_payload(serde_json::json!({ "user_id": user_model.id, "username": user_model.username })),
|
||||||
|
), db).await;
|
||||||
|
|
||||||
|
// 审计:登录成功
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(user_model.id), "user.login", "user")
|
||||||
|
.with_resource_id(user_model.id)
|
||||||
|
.with_request_info(
|
||||||
|
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||||
|
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(LoginResp {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_in: jwt.access_ttl_secs as u64,
|
||||||
|
user: user_resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
|
||||||
|
pub async fn refresh(
|
||||||
|
refresh_token_str: &str,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
jwt: &JwtConfig<'_>,
|
||||||
|
) -> AuthResult<LoginResp> {
|
||||||
|
// Atomically validate and revoke the old refresh token (prevents TOCTOU race)
|
||||||
|
let claims =
|
||||||
|
TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?;
|
||||||
|
|
||||||
|
// Fetch fresh roles and permissions
|
||||||
|
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||||
|
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
||||||
|
|
||||||
|
// Sign new token pair
|
||||||
|
let access_token = TokenService::sign_access_token(
|
||||||
|
claims.sub,
|
||||||
|
claims.tid,
|
||||||
|
roles.clone(),
|
||||||
|
permissions,
|
||||||
|
jwt.secret,
|
||||||
|
jwt.access_ttl_secs,
|
||||||
|
)?;
|
||||||
|
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
||||||
|
claims.sub,
|
||||||
|
claims.tid,
|
||||||
|
db,
|
||||||
|
jwt.secret,
|
||||||
|
jwt.refresh_ttl_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Fetch user for the response
|
||||||
|
let user_model = user::Entity::find_by_id(claims.sub)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::TokenRevoked)?;
|
||||||
|
|
||||||
|
// 验证用户属于 JWT 中声明的租户
|
||||||
|
if user_model.tenant_id != claims.tid {
|
||||||
|
tracing::warn!(
|
||||||
|
user_id = %claims.sub,
|
||||||
|
jwt_tenant = %claims.tid,
|
||||||
|
actual_tenant = %user_model.tenant_id,
|
||||||
|
"Token tenant_id 与用户实际租户不匹配"
|
||||||
|
);
|
||||||
|
return Err(AuthError::TokenRevoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
|
||||||
|
let user_resp = UserResp {
|
||||||
|
id: user_model.id,
|
||||||
|
username: user_model.username,
|
||||||
|
email: user_model.email,
|
||||||
|
phone: user_model.phone,
|
||||||
|
display_name: user_model.display_name,
|
||||||
|
avatar_url: user_model.avatar_url,
|
||||||
|
status: user_model.status,
|
||||||
|
roles: role_resps,
|
||||||
|
version: user_model.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(LoginResp {
|
||||||
|
access_token,
|
||||||
|
refresh_token: new_refresh_token,
|
||||||
|
expires_in: jwt.access_ttl_secs as u64,
|
||||||
|
user: user_resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
|
||||||
|
pub async fn logout(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
req_info: Option<&RequestInfo>,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 清除 access token 权限缓存,强制重新认证
|
||||||
|
revoke_access_token_cache(user_id);
|
||||||
|
|
||||||
|
// 审计:登出
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(user_id), "user.logout", "user")
|
||||||
|
.with_resource_id(user_id)
|
||||||
|
.with_request_info(
|
||||||
|
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||||
|
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change password for the authenticated user.
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
/// 1. Verify current password
|
||||||
|
/// 2. Hash the new password
|
||||||
|
/// 3. Update the credential record
|
||||||
|
/// 4. Revoke all existing refresh tokens (force re-login)
|
||||||
|
pub async fn change_password(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
current_password: &str,
|
||||||
|
new_password: &str,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
req_info: Option<&RequestInfo>,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
// 1. Find the user's password credential
|
||||||
|
let cred = user_credential::Entity::find()
|
||||||
|
.filter(user_credential::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_credential::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||||
|
.filter(user_credential::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
|
||||||
|
|
||||||
|
// 2. Verify current password
|
||||||
|
let stored_hash = cred
|
||||||
|
.credential_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?;
|
||||||
|
|
||||||
|
if !password::verify_password(current_password, stored_hash)? {
|
||||||
|
return Err(AuthError::Validation("当前密码不正确".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Hash new password and update credential
|
||||||
|
let new_hash = password::hash_password(new_password)?;
|
||||||
|
let current_version = cred.version;
|
||||||
|
let mut cred_active: user_credential::ActiveModel = cred.into();
|
||||||
|
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
|
||||||
|
cred_active.updated_at = Set(Utc::now());
|
||||||
|
cred_active.version = Set(current_version + 1);
|
||||||
|
cred_active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 4. Revoke all refresh tokens — force re-login on all devices
|
||||||
|
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效
|
||||||
|
revoke_access_token_cache(user_id);
|
||||||
|
|
||||||
|
// 审计:密码修改
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user")
|
||||||
|
.with_resource_id(user_id)
|
||||||
|
.with_request_info(
|
||||||
|
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||||
|
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user_id, "Password changed successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch role details for a user, returning RoleResp DTOs.
|
||||||
|
pub async fn get_user_role_resps(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<RoleResp>> {
|
||||||
|
let user_roles = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roles
|
||||||
|
.iter()
|
||||||
|
.map(|r| RoleResp {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name.clone(),
|
||||||
|
code: r.code.clone(),
|
||||||
|
description: r.description.clone(),
|
||||||
|
is_system: r.is_system,
|
||||||
|
version: r.version,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
414
crates/erp-auth/src/service/dept_service.rs
Normal file
414
crates/erp-auth/src/service/dept_service.rs
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
|
||||||
|
use crate::entity::department;
|
||||||
|
use crate::entity::organization;
|
||||||
|
use crate::entity::position;
|
||||||
|
use crate::entity::user_department;
|
||||||
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
/// Department CRUD service -- create, read, update, soft-delete departments
|
||||||
|
/// within an organization, supporting tree-structured hierarchy.
|
||||||
|
pub struct DeptService;
|
||||||
|
|
||||||
|
impl DeptService {
|
||||||
|
/// Fetch all departments for an organization as a nested tree.
|
||||||
|
///
|
||||||
|
/// Root departments (parent_id = None) form the top level.
|
||||||
|
pub async fn list_tree(
|
||||||
|
org_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<DepartmentResp>> {
|
||||||
|
// Verify the organization exists
|
||||||
|
let _org = organization::Entity::find_by_id(org_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||||
|
|
||||||
|
let items = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::OrgId.eq(org_id))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(build_dept_tree(&items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new department under the specified organization.
|
||||||
|
///
|
||||||
|
/// If `parent_id` is provided, computes `path` from the parent department.
|
||||||
|
/// Otherwise, path is computed from the organization root.
|
||||||
|
pub async fn create(
|
||||||
|
org_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateDepartmentReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<DepartmentResp> {
|
||||||
|
// Verify the organization exists
|
||||||
|
let org = organization::Entity::find_by_id(org_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Check code uniqueness within tenant if code is provided
|
||||||
|
if let Some(ref code) = req.code {
|
||||||
|
let existing = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::Code.eq(code.as_str()))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name uniqueness within the same organization
|
||||||
|
let name_exists = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::OrgId.eq(org_id))
|
||||||
|
.filter(department::Column::Name.eq(&req.name))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if name_exists.is_some() {
|
||||||
|
return Err(AuthError::Validation("部门名称已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute path from parent department or organization root
|
||||||
|
let path = if let Some(parent_id) = req.parent_id {
|
||||||
|
let parent = department::Entity::find_by_id(parent_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| {
|
||||||
|
d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none()
|
||||||
|
})
|
||||||
|
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
|
||||||
|
|
||||||
|
let parent_path = parent.path.clone().unwrap_or_default();
|
||||||
|
Some(format!("{}{}/", parent_path, parent.id))
|
||||||
|
} else {
|
||||||
|
// Root department under the organization
|
||||||
|
let org_path = org.path.clone().unwrap_or_default();
|
||||||
|
Some(format!("{}{}/", org_path, org.id))
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = department::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
org_id: Set(org_id),
|
||||||
|
name: Set(req.name.clone()),
|
||||||
|
code: Set(req.code.clone()),
|
||||||
|
parent_id: Set(req.parent_id),
|
||||||
|
manager_id: Set(req.manager_id),
|
||||||
|
path: Set(path),
|
||||||
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"department.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"department.create",
|
||||||
|
"department",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(DepartmentResp {
|
||||||
|
id,
|
||||||
|
org_id,
|
||||||
|
name: req.name.clone(),
|
||||||
|
code: req.code.clone(),
|
||||||
|
parent_id: req.parent_id,
|
||||||
|
manager_id: req.manager_id,
|
||||||
|
path: None,
|
||||||
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
|
children: vec![],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &UpdateDepartmentReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<DepartmentResp> {
|
||||||
|
let model = department::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||||
|
|
||||||
|
// If code is being changed, check uniqueness
|
||||||
|
if let Some(new_code) = &req.code
|
||||||
|
&& Some(new_code) != model.code.as_ref()
|
||||||
|
{
|
||||||
|
let existing = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::Code.eq(new_code.as_str()))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If name is being changed, check uniqueness within the same org (exclude self)
|
||||||
|
if let Some(ref new_name) = req.name
|
||||||
|
&& new_name != &model.name
|
||||||
|
{
|
||||||
|
let name_exists = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::OrgId.eq(model.org_id))
|
||||||
|
.filter(department::Column::Name.eq(new_name.as_str()))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.filter(department::Column::Id.ne(id))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if name_exists.is_some() {
|
||||||
|
return Err(AuthError::Validation("部门名称已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_ver = check_version(req.version, model.version)
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut active: department::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(n) = &req.name {
|
||||||
|
active.name = Set(n.clone());
|
||||||
|
}
|
||||||
|
if let Some(c) = &req.code {
|
||||||
|
active.code = Set(Some(c.clone()));
|
||||||
|
}
|
||||||
|
if let Some(mgr_id) = &req.manager_id {
|
||||||
|
active.manager_id = Set(Some(*mgr_id));
|
||||||
|
}
|
||||||
|
if let Some(so) = &req.sort_order {
|
||||||
|
active.sort_order = Set(*so);
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"department.update",
|
||||||
|
"department",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(DepartmentResp {
|
||||||
|
id: updated.id,
|
||||||
|
org_id: updated.org_id,
|
||||||
|
name: updated.name.clone(),
|
||||||
|
code: updated.code.clone(),
|
||||||
|
parent_id: updated.parent_id,
|
||||||
|
manager_id: updated.manager_id,
|
||||||
|
path: updated.path.clone(),
|
||||||
|
sort_order: updated.sort_order,
|
||||||
|
children: vec![],
|
||||||
|
version: updated.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a department by setting the `deleted_at` timestamp.
|
||||||
|
///
|
||||||
|
/// Will not delete if child departments exist.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let model = department::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Check for child departments
|
||||||
|
let children = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::ParentId.eq(id))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if children.is_some() {
|
||||||
|
return Err(AuthError::Validation(
|
||||||
|
"该部门下存在子部门,无法删除".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for positions under this department
|
||||||
|
let positions = position::Entity::find()
|
||||||
|
.filter(position::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(position::Column::DeptId.eq(id))
|
||||||
|
.filter(position::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if positions.is_some() {
|
||||||
|
return Err(AuthError::Validation(
|
||||||
|
"该部门下存在岗位,无法删除".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for users assigned to this department
|
||||||
|
let users = user_department::Entity::find()
|
||||||
|
.filter(user_department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_department::Column::DepartmentId.eq(id))
|
||||||
|
.filter(user_department::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if users.is_some() {
|
||||||
|
return Err(AuthError::Validation(
|
||||||
|
"该部门下存在用户,无法删除".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
|
let mut active: department::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"department.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "dept_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"department.delete",
|
||||||
|
"department",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a nested tree of `DepartmentResp` from a flat list of models.
|
||||||
|
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
|
||||||
|
for item in items {
|
||||||
|
children_map.entry(item.parent_id).or_default().push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_node(
|
||||||
|
item: &department::Model,
|
||||||
|
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
|
||||||
|
) -> DepartmentResp {
|
||||||
|
let children = map
|
||||||
|
.get(&Some(item.id))
|
||||||
|
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
DepartmentResp {
|
||||||
|
id: item.id,
|
||||||
|
org_id: item.org_id,
|
||||||
|
name: item.name.clone(),
|
||||||
|
code: item.code.clone(),
|
||||||
|
parent_id: item.parent_id,
|
||||||
|
manager_id: item.manager_id,
|
||||||
|
path: item.path.clone(),
|
||||||
|
sort_order: item.sort_order,
|
||||||
|
children,
|
||||||
|
version: item.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children_map
|
||||||
|
.get(&None)
|
||||||
|
.map(|root_items| {
|
||||||
|
root_items
|
||||||
|
.iter()
|
||||||
|
.map(|item| build_node(item, &children_map))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
11
crates/erp-auth/src/service/mod.rs
Normal file
11
crates/erp-auth/src/service/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod auth_service;
|
||||||
|
pub mod dept_service;
|
||||||
|
pub mod org_service;
|
||||||
|
pub mod password;
|
||||||
|
pub mod permission_service;
|
||||||
|
pub mod position_service;
|
||||||
|
pub mod role_service;
|
||||||
|
pub mod seed;
|
||||||
|
pub mod token_service;
|
||||||
|
pub mod user_service;
|
||||||
|
pub mod wechat_service;
|
||||||
494
crates/erp-auth/src/service/org_service.rs
Normal file
494
crates/erp-auth/src/service/org_service.rs
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
||||||
|
use crate::entity::department;
|
||||||
|
use crate::entity::organization;
|
||||||
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
||||||
|
/// within a tenant, supporting tree-structured hierarchy with path and level.
|
||||||
|
pub struct OrgService;
|
||||||
|
|
||||||
|
impl OrgService {
|
||||||
|
/// Fetch all organizations for a tenant as a flat list (not deleted).
|
||||||
|
pub async fn list_flat(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<organization::Model>> {
|
||||||
|
let items = organization::Entity::find()
|
||||||
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all organizations for a tenant as a nested tree.
|
||||||
|
///
|
||||||
|
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
|
||||||
|
pub async fn get_tree(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<OrganizationResp>> {
|
||||||
|
let items = Self::list_flat(tenant_id, db).await?;
|
||||||
|
Ok(build_org_tree(&items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new organization within the current tenant.
|
||||||
|
///
|
||||||
|
/// If `parent_id` is provided, computes `path` from the parent's path and id,
|
||||||
|
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateOrganizationReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<OrganizationResp> {
|
||||||
|
// Check code uniqueness within tenant if code is provided
|
||||||
|
if let Some(ref code) = req.code {
|
||||||
|
let existing = organization::Entity::find()
|
||||||
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(organization::Column::Code.eq(code.as_str()))
|
||||||
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check name uniqueness within tenant
|
||||||
|
let name_exists = organization::Entity::find()
|
||||||
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(organization::Column::Name.eq(&req.name))
|
||||||
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if name_exists.is_some() {
|
||||||
|
return Err(AuthError::Validation("组织名称已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, level) = if let Some(parent_id) = req.parent_id {
|
||||||
|
let parent = organization::Entity::find_by_id(parent_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
|
||||||
|
|
||||||
|
let parent_path = parent.path.clone().unwrap_or_default();
|
||||||
|
let computed_path = format!("{}{}/", parent_path, parent.id);
|
||||||
|
(Some(computed_path), parent.level + 1)
|
||||||
|
} else {
|
||||||
|
(None, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = organization::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(req.name.clone()),
|
||||||
|
code: Set(req.code.clone()),
|
||||||
|
parent_id: Set(req.parent_id),
|
||||||
|
path: Set(path),
|
||||||
|
level: Set(level),
|
||||||
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"organization.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "org_id": id, "name": req.name }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"organization.create",
|
||||||
|
"organization",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(OrganizationResp {
|
||||||
|
id,
|
||||||
|
name: req.name.clone(),
|
||||||
|
code: req.code.clone(),
|
||||||
|
parent_id: req.parent_id,
|
||||||
|
path: None,
|
||||||
|
level,
|
||||||
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
|
children: vec![],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable organization fields (name, code, sort_order).
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &UpdateOrganizationReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<OrganizationResp> {
|
||||||
|
let model = organization::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||||
|
|
||||||
|
// If code is being changed, check uniqueness
|
||||||
|
if let Some(ref new_code) = req.code
|
||||||
|
&& Some(new_code) != model.code.as_ref()
|
||||||
|
{
|
||||||
|
let existing = organization::Entity::find()
|
||||||
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(organization::Column::Code.eq(new_code.as_str()))
|
||||||
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If name is being changed, check uniqueness (exclude self)
|
||||||
|
if let Some(ref new_name) = req.name
|
||||||
|
&& new_name != &model.name
|
||||||
|
{
|
||||||
|
let name_exists = organization::Entity::find()
|
||||||
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(organization::Column::Name.eq(new_name.as_str()))
|
||||||
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
|
.filter(organization::Column::Id.ne(id))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if name_exists.is_some() {
|
||||||
|
return Err(AuthError::Validation("组织名称已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_ver = check_version(req.version, model.version)
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut active: organization::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(ref name) = req.name {
|
||||||
|
active.name = Set(name.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref code) = req.code {
|
||||||
|
active.code = Set(Some(code.clone()));
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = req.sort_order {
|
||||||
|
active.sort_order = Set(sort_order);
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"organization.update",
|
||||||
|
"organization",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(OrganizationResp {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name.clone(),
|
||||||
|
code: updated.code.clone(),
|
||||||
|
parent_id: updated.parent_id,
|
||||||
|
path: updated.path.clone(),
|
||||||
|
level: updated.level,
|
||||||
|
sort_order: updated.sort_order,
|
||||||
|
children: vec![],
|
||||||
|
version: updated.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete an organization by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let model = organization::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Check for child organizations
|
||||||
|
let children = organization::Entity::find()
|
||||||
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(organization::Column::ParentId.eq(id))
|
||||||
|
.filter(organization::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if children.is_some() {
|
||||||
|
return Err(AuthError::Validation(
|
||||||
|
"该组织下存在子组织,无法删除".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for departments under this organization
|
||||||
|
let depts = department::Entity::find()
|
||||||
|
.filter(department::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(department::Column::OrgId.eq(id))
|
||||||
|
.filter(department::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if depts.is_some() {
|
||||||
|
return Err(AuthError::Validation(
|
||||||
|
"该组织下存在部门,无法删除".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
|
let mut active: organization::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"organization.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "org_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"organization.delete",
|
||||||
|
"organization",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a nested tree of `OrganizationResp` from a flat list of models.
|
||||||
|
///
|
||||||
|
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
||||||
|
/// includes its children grouped by parent_id.
|
||||||
|
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
||||||
|
for item in items {
|
||||||
|
children_map.entry(item.parent_id).or_default().push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_node(
|
||||||
|
item: &organization::Model,
|
||||||
|
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
|
||||||
|
) -> OrganizationResp {
|
||||||
|
let children = map
|
||||||
|
.get(&Some(item.id))
|
||||||
|
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
OrganizationResp {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name.clone(),
|
||||||
|
code: item.code.clone(),
|
||||||
|
parent_id: item.parent_id,
|
||||||
|
path: item.path.clone(),
|
||||||
|
level: item.level,
|
||||||
|
sort_order: item.sort_order,
|
||||||
|
children,
|
||||||
|
version: item.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children_map
|
||||||
|
.get(&None)
|
||||||
|
.map(|root_items| {
|
||||||
|
root_items
|
||||||
|
.iter()
|
||||||
|
.map(|item| build_node(item, &children_map))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entity::organization;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_org(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
level: i32,
|
||||||
|
version: i32,
|
||||||
|
) -> organization::Model {
|
||||||
|
organization::Model {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
code: None,
|
||||||
|
parent_id,
|
||||||
|
path: None,
|
||||||
|
level,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
created_by: Uuid::now_v7(),
|
||||||
|
updated_by: Uuid::now_v7(),
|
||||||
|
deleted_at: None,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_org_tree_empty() {
|
||||||
|
let tree = build_org_tree(&[]);
|
||||||
|
assert!(tree.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_org_tree_single_root() {
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)];
|
||||||
|
|
||||||
|
let tree = build_org_tree(&items);
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
assert_eq!(tree[0].name, "总公司");
|
||||||
|
assert!(tree[0].children.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_org_tree_multiple_roots() {
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let items = vec![
|
||||||
|
make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1),
|
||||||
|
make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
let tree = build_org_tree(&items);
|
||||||
|
assert_eq!(tree.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_org_tree_nested_children() {
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let child1_id = Uuid::now_v7();
|
||||||
|
let child2_id = Uuid::now_v7();
|
||||||
|
let grandchild_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let items = vec![
|
||||||
|
make_org(root_id, tid, "总公司", None, 1, 1),
|
||||||
|
make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1),
|
||||||
|
make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1),
|
||||||
|
make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
let tree = build_org_tree(&items);
|
||||||
|
assert_eq!(tree.len(), 1); // one root
|
||||||
|
assert_eq!(tree[0].children.len(), 2); // two children
|
||||||
|
assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild
|
||||||
|
assert_eq!(tree[0].children[0].children[0].name, "部门A1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_org_tree_deep_nesting() {
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let l1 = Uuid::now_v7();
|
||||||
|
let l2 = Uuid::now_v7();
|
||||||
|
let l3 = Uuid::now_v7();
|
||||||
|
let l4 = Uuid::now_v7();
|
||||||
|
|
||||||
|
let items = vec![
|
||||||
|
make_org(l1, tid, "L1", None, 1, 1),
|
||||||
|
make_org(l2, tid, "L2", Some(l1), 2, 1),
|
||||||
|
make_org(l3, tid, "L3", Some(l2), 3, 1),
|
||||||
|
make_org(l4, tid, "L4", Some(l3), 4, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
let tree = build_org_tree(&items);
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
assert_eq!(tree[0].children[0].children[0].children[0].name, "L4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_org_tree_preserves_version() {
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let items = vec![make_org(root_id, tid, "测试", None, 1, 5)];
|
||||||
|
|
||||||
|
let tree = build_org_tree(&items);
|
||||||
|
assert_eq!(tree[0].version, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
crates/erp-auth/src/service/password.rs
Normal file
56
crates/erp-auth/src/service/password.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
|
||||||
|
/// Hash a plaintext password using Argon2 with a random salt.
|
||||||
|
///
|
||||||
|
/// Returns a PHC-format string suitable for database storage.
|
||||||
|
pub fn hash_password(plain: &str) -> AuthResult<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(plain.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AuthError::HashError(e.to_string()))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext password against a stored PHC-format hash.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
|
||||||
|
pub fn verify_password(plain: &str, hash: &str) -> AuthResult<bool> {
|
||||||
|
let parsed = PasswordHash::new(hash).map_err(|e| AuthError::HashError(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_and_verify() {
|
||||||
|
let hash = hash_password("test123").unwrap();
|
||||||
|
assert!(
|
||||||
|
verify_password("test123", &hash).unwrap(),
|
||||||
|
"Correct password should verify"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!verify_password("wrong", &hash).unwrap(),
|
||||||
|
"Wrong password should not verify"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_is_unique() {
|
||||||
|
let hash1 = hash_password("same_password").unwrap();
|
||||||
|
let hash2 = hash_password("same_password").unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
hash1, hash2,
|
||||||
|
"Two hashes of the same password should differ (different salts)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
crates/erp-auth/src/service/permission_service.rs
Normal file
38
crates/erp-auth/src/service/permission_service.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::PermissionResp;
|
||||||
|
use crate::entity::permission;
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
/// Permission read-only service — list permissions within a tenant.
|
||||||
|
///
|
||||||
|
/// Permissions are seeded by the system and not typically created via API.
|
||||||
|
pub struct PermissionService;
|
||||||
|
|
||||||
|
impl PermissionService {
|
||||||
|
/// List all active permissions within a tenant.
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<PermissionResp>> {
|
||||||
|
let perms = permission::Entity::find()
|
||||||
|
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(permission::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(perms
|
||||||
|
.iter()
|
||||||
|
.map(|p| PermissionResp {
|
||||||
|
id: p.id,
|
||||||
|
code: p.code.clone(),
|
||||||
|
name: p.name.clone(),
|
||||||
|
resource: p.resource.clone(),
|
||||||
|
action: p.action.clone(),
|
||||||
|
description: p.description.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
259
crates/erp-auth/src/service/position_service.rs
Normal file
259
crates/erp-auth/src/service/position_service.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
|
||||||
|
use crate::entity::department;
|
||||||
|
use crate::entity::position;
|
||||||
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
/// Position CRUD service -- create, read, update, soft-delete positions
|
||||||
|
/// within a department.
|
||||||
|
pub struct PositionService;
|
||||||
|
|
||||||
|
impl PositionService {
|
||||||
|
/// List all positions for a department within the given tenant.
|
||||||
|
pub async fn list(
|
||||||
|
dept_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<PositionResp>> {
|
||||||
|
// Verify the department exists
|
||||||
|
let _dept = department::Entity::find_by_id(dept_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||||
|
|
||||||
|
let items = position::Entity::find()
|
||||||
|
.filter(position::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(position::Column::DeptId.eq(dept_id))
|
||||||
|
.filter(position::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items
|
||||||
|
.iter()
|
||||||
|
.map(|p| PositionResp {
|
||||||
|
id: p.id,
|
||||||
|
dept_id: p.dept_id,
|
||||||
|
name: p.name.clone(),
|
||||||
|
code: p.code.clone(),
|
||||||
|
level: p.level,
|
||||||
|
sort_order: p.sort_order,
|
||||||
|
version: p.version,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new position under the specified department.
|
||||||
|
pub async fn create(
|
||||||
|
dept_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreatePositionReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<PositionResp> {
|
||||||
|
// Verify the department exists
|
||||||
|
let _dept = department::Entity::find_by_id(dept_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Check code uniqueness within tenant if code is provided
|
||||||
|
if let Some(ref code) = req.code {
|
||||||
|
let existing = position::Entity::find()
|
||||||
|
.filter(position::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(position::Column::Code.eq(code.as_str()))
|
||||||
|
.filter(position::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = position::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
dept_id: Set(dept_id),
|
||||||
|
name: Set(req.name.clone()),
|
||||||
|
code: Set(req.code.clone()),
|
||||||
|
level: Set(req.level.unwrap_or(1)),
|
||||||
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"position.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "position.create", "position")
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(PositionResp {
|
||||||
|
id,
|
||||||
|
dept_id,
|
||||||
|
name: req.name.clone(),
|
||||||
|
code: req.code.clone(),
|
||||||
|
level: req.level.unwrap_or(1),
|
||||||
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable position fields (name, code, level, sort_order).
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &UpdatePositionReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<PositionResp> {
|
||||||
|
let model = position::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||||
|
|
||||||
|
// If code is being changed, check uniqueness
|
||||||
|
if let Some(new_code) = &req.code
|
||||||
|
&& Some(new_code) != model.code.as_ref()
|
||||||
|
{
|
||||||
|
let existing = position::Entity::find()
|
||||||
|
.filter(position::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(position::Column::Code.eq(new_code.as_str()))
|
||||||
|
.filter(position::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_ver = check_version(req.version, model.version)
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut active: position::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(n) = &req.name {
|
||||||
|
active.name = Set(n.clone());
|
||||||
|
}
|
||||||
|
if let Some(c) = &req.code {
|
||||||
|
active.code = Set(Some(c.clone()));
|
||||||
|
}
|
||||||
|
if let Some(l) = &req.level {
|
||||||
|
active.level = Set(*l);
|
||||||
|
}
|
||||||
|
if let Some(so) = &req.sort_order {
|
||||||
|
active.sort_order = Set(*so);
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "position.update", "position")
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(PositionResp {
|
||||||
|
id: updated.id,
|
||||||
|
dept_id: updated.dept_id,
|
||||||
|
name: updated.name.clone(),
|
||||||
|
code: updated.code.clone(),
|
||||||
|
level: updated.level,
|
||||||
|
sort_order: updated.sort_order,
|
||||||
|
version: updated.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a position by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let model = position::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
|
let mut active: position::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"position.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "position_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position")
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
370
crates/erp-auth/src/service/role_service.rs
Normal file
370
crates/erp-auth/src/service/role_service.rs
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{PermissionResp, RoleResp};
|
||||||
|
use crate::entity::{permission, role, role_permission};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// Role CRUD service — create, read, update, soft-delete roles within a tenant,
|
||||||
|
/// and manage role-permission assignments.
|
||||||
|
pub struct RoleService;
|
||||||
|
|
||||||
|
impl RoleService {
|
||||||
|
/// List roles within a tenant with pagination.
|
||||||
|
///
|
||||||
|
/// Returns `(roles, total_count)`.
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<(Vec<RoleResp>, u64)> {
|
||||||
|
let paginator = role::Entity::find()
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resps: Vec<RoleResp> = models
|
||||||
|
.iter()
|
||||||
|
.map(|m| RoleResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name.clone(),
|
||||||
|
code: m.code.clone(),
|
||||||
|
description: m.description.clone(),
|
||||||
|
is_system: m.is_system,
|
||||||
|
version: m.version,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single role by ID, scoped to the given tenant.
|
||||||
|
pub async fn get_by_id(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<RoleResp> {
|
||||||
|
let model = role::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||||
|
|
||||||
|
Ok(RoleResp {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name.clone(),
|
||||||
|
code: model.code.clone(),
|
||||||
|
description: model.description.clone(),
|
||||||
|
is_system: model.is_system,
|
||||||
|
version: model.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new role within the current tenant.
|
||||||
|
///
|
||||||
|
/// Validates code uniqueness, then inserts the record and publishes
|
||||||
|
/// a `role.created` domain event.
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
code: &str,
|
||||||
|
description: &Option<String>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<RoleResp> {
|
||||||
|
// Check code uniqueness within tenant
|
||||||
|
let existing = role::Entity::find()
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role::Column::Code.eq(code))
|
||||||
|
.filter(role::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("角色编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = role::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
description: Set(description.clone()),
|
||||||
|
is_system: Set(false),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"role.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "role_id": id, "code": code }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "role.create", "role").with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(RoleResp {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
code: code.to_string(),
|
||||||
|
description: description.clone(),
|
||||||
|
is_system: false,
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable role fields (name and description).
|
||||||
|
///
|
||||||
|
/// Code and is_system cannot be changed after creation.
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
name: &Option<String>,
|
||||||
|
description: &Option<String>,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<RoleResp> {
|
||||||
|
let model = role::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||||
|
|
||||||
|
let old_json = serde_json::to_value(&model).unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
let next_ver = check_version(version, model.version)
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut active: role::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(name) = name {
|
||||||
|
active.name = Set(name.clone());
|
||||||
|
}
|
||||||
|
if let Some(desc) = description {
|
||||||
|
active.description = Set(Some(desc.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "role.update", "role")
|
||||||
|
.with_resource_id(id)
|
||||||
|
.with_changes(Some(old_json), Some(new_json)),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(RoleResp {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name.clone(),
|
||||||
|
code: updated.code.clone(),
|
||||||
|
description: updated.description.clone(),
|
||||||
|
is_system: updated.is_system,
|
||||||
|
version: updated.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a role by setting the `deleted_at` timestamp.
|
||||||
|
///
|
||||||
|
/// System roles cannot be deleted.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let model = role::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||||
|
|
||||||
|
if model.is_system {
|
||||||
|
return Err(AuthError::Validation("系统角色不可删除".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_version = model.version;
|
||||||
|
let mut active: role::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"role.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "role_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role").with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace all permission assignments for a role.
|
||||||
|
///
|
||||||
|
/// Soft-deletes existing assignments and creates new ones.
|
||||||
|
pub async fn assign_permissions(
|
||||||
|
role_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
permission_ids: &[Uuid],
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
// Verify the role exists and belongs to this tenant
|
||||||
|
let _role = role::Entity::find_by_id(role_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Soft-delete existing role_permission rows
|
||||||
|
let existing = role_permission::Entity::find()
|
||||||
|
.filter(role_permission::Column::RoleId.eq(role_id))
|
||||||
|
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role_permission::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
for rp in existing {
|
||||||
|
let mut active: role_permission::ActiveModel = rp.into();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new role_permission rows
|
||||||
|
for perm_id in permission_ids {
|
||||||
|
let rp = role_permission::ActiveModel {
|
||||||
|
role_id: Set(role_id),
|
||||||
|
permission_id: Set(*perm_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
data_scope: Set("all".to_string()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
rp.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all permissions assigned to a role.
|
||||||
|
///
|
||||||
|
/// Resolves through the role_permission join table.
|
||||||
|
pub async fn get_role_permissions(
|
||||||
|
role_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<PermissionResp>> {
|
||||||
|
let rp_rows = role_permission::Entity::find()
|
||||||
|
.filter(role_permission::Column::RoleId.eq(role_id))
|
||||||
|
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role_permission::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
|
||||||
|
if perm_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let perms = permission::Entity::find()
|
||||||
|
.filter(permission::Column::Id.is_in(perm_ids))
|
||||||
|
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(perms
|
||||||
|
.iter()
|
||||||
|
.map(|p| PermissionResp {
|
||||||
|
id: p.id,
|
||||||
|
code: p.code.clone(),
|
||||||
|
name: p.name.clone(),
|
||||||
|
resource: p.resource.clone(),
|
||||||
|
action: p.action.clone(),
|
||||||
|
description: p.description.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
547
crates/erp-auth/src/service/seed.rs
Normal file
547
crates/erp-auth/src/service/seed.rs
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
use sea_orm::{ActiveModelTrait, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entity::{permission, role, role_permission, user, user_credential, user_role};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
|
||||||
|
use super::password;
|
||||||
|
|
||||||
|
/// Permission definitions to seed for every new tenant.
|
||||||
|
/// Each tuple is: (code, name, resource, action, description)
|
||||||
|
///
|
||||||
|
/// 编码使用点分隔 (`resource.action`),与 handler 中的 `require_permission` 调用保持一致。
|
||||||
|
const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||||
|
// === Auth module ===
|
||||||
|
("user.list", "查看用户列表", "user", "list", "查看用户列表"),
|
||||||
|
("user.create", "创建用户", "user", "create", "创建新用户"),
|
||||||
|
("user.read", "查看用户详情", "user", "read", "查看用户信息"),
|
||||||
|
("user.update", "编辑用户", "user", "update", "编辑用户信息"),
|
||||||
|
("user.delete", "删除用户", "user", "delete", "软删除用户"),
|
||||||
|
("role.list", "查看角色列表", "role", "list", "查看角色列表"),
|
||||||
|
("role.create", "创建角色", "role", "create", "创建新角色"),
|
||||||
|
("role.read", "查看角色详情", "role", "read", "查看角色信息"),
|
||||||
|
("role.update", "编辑角色", "role", "update", "编辑角色"),
|
||||||
|
("role.delete", "删除角色", "role", "delete", "删除角色"),
|
||||||
|
(
|
||||||
|
"permission.list",
|
||||||
|
"查看权限",
|
||||||
|
"permission",
|
||||||
|
"list",
|
||||||
|
"查看权限列表",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organization.list",
|
||||||
|
"查看组织列表",
|
||||||
|
"organization",
|
||||||
|
"list",
|
||||||
|
"查看组织列表",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organization.create",
|
||||||
|
"创建组织",
|
||||||
|
"organization",
|
||||||
|
"create",
|
||||||
|
"创建组织",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organization.update",
|
||||||
|
"编辑组织",
|
||||||
|
"organization",
|
||||||
|
"update",
|
||||||
|
"编辑组织",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organization.delete",
|
||||||
|
"删除组织",
|
||||||
|
"organization",
|
||||||
|
"delete",
|
||||||
|
"删除组织",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"department.list",
|
||||||
|
"查看部门列表",
|
||||||
|
"department",
|
||||||
|
"list",
|
||||||
|
"查看部门列表",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"department.create",
|
||||||
|
"创建部门",
|
||||||
|
"department",
|
||||||
|
"create",
|
||||||
|
"创建部门",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"department.update",
|
||||||
|
"编辑部门",
|
||||||
|
"department",
|
||||||
|
"update",
|
||||||
|
"编辑部门",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"department.delete",
|
||||||
|
"删除部门",
|
||||||
|
"department",
|
||||||
|
"delete",
|
||||||
|
"删除部门",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"position.list",
|
||||||
|
"查看岗位列表",
|
||||||
|
"position",
|
||||||
|
"list",
|
||||||
|
"查看岗位列表",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"position.create",
|
||||||
|
"创建岗位",
|
||||||
|
"position",
|
||||||
|
"create",
|
||||||
|
"创建岗位",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"position.update",
|
||||||
|
"编辑岗位",
|
||||||
|
"position",
|
||||||
|
"update",
|
||||||
|
"编辑岗位",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"position.delete",
|
||||||
|
"删除岗位",
|
||||||
|
"position",
|
||||||
|
"delete",
|
||||||
|
"删除岗位",
|
||||||
|
),
|
||||||
|
// === Config module ===
|
||||||
|
(
|
||||||
|
"dictionary.list",
|
||||||
|
"查看字典",
|
||||||
|
"dictionary",
|
||||||
|
"list",
|
||||||
|
"查看数据字典",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dictionary.create",
|
||||||
|
"创建字典",
|
||||||
|
"dictionary",
|
||||||
|
"create",
|
||||||
|
"创建数据字典",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dictionary.update",
|
||||||
|
"编辑字典",
|
||||||
|
"dictionary",
|
||||||
|
"update",
|
||||||
|
"编辑数据字典",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dictionary.delete",
|
||||||
|
"删除字典",
|
||||||
|
"dictionary",
|
||||||
|
"delete",
|
||||||
|
"删除数据字典",
|
||||||
|
),
|
||||||
|
("menu.list", "查看菜单", "menu", "list", "查看菜单配置"),
|
||||||
|
("menu.update", "编辑菜单", "menu", "update", "编辑菜单配置"),
|
||||||
|
(
|
||||||
|
"setting.read",
|
||||||
|
"查看配置",
|
||||||
|
"setting",
|
||||||
|
"read",
|
||||||
|
"查看系统参数",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"setting.update",
|
||||||
|
"编辑配置",
|
||||||
|
"setting",
|
||||||
|
"update",
|
||||||
|
"编辑系统参数",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"setting.delete",
|
||||||
|
"删除配置",
|
||||||
|
"setting",
|
||||||
|
"delete",
|
||||||
|
"删除系统参数",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"numbering.list",
|
||||||
|
"查看编号规则",
|
||||||
|
"numbering",
|
||||||
|
"list",
|
||||||
|
"查看编号规则",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"numbering.create",
|
||||||
|
"创建编号规则",
|
||||||
|
"numbering",
|
||||||
|
"create",
|
||||||
|
"创建编号规则",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"numbering.update",
|
||||||
|
"编辑编号规则",
|
||||||
|
"numbering",
|
||||||
|
"update",
|
||||||
|
"编辑编号规则",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"numbering.delete",
|
||||||
|
"删除编号规则",
|
||||||
|
"numbering",
|
||||||
|
"delete",
|
||||||
|
"删除编号规则",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"numbering.generate",
|
||||||
|
"生成编号",
|
||||||
|
"numbering",
|
||||||
|
"generate",
|
||||||
|
"生成文档编号",
|
||||||
|
),
|
||||||
|
("theme.read", "查看主题", "theme", "read", "查看主题设置"),
|
||||||
|
(
|
||||||
|
"theme.update",
|
||||||
|
"编辑主题",
|
||||||
|
"theme",
|
||||||
|
"update",
|
||||||
|
"编辑主题设置",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language.list",
|
||||||
|
"查看语言",
|
||||||
|
"language",
|
||||||
|
"list",
|
||||||
|
"查看语言配置",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language.update",
|
||||||
|
"编辑语言",
|
||||||
|
"language",
|
||||||
|
"update",
|
||||||
|
"编辑语言设置",
|
||||||
|
),
|
||||||
|
// === Workflow module ===
|
||||||
|
(
|
||||||
|
"workflow.create",
|
||||||
|
"创建流程",
|
||||||
|
"workflow",
|
||||||
|
"create",
|
||||||
|
"创建流程定义",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.list",
|
||||||
|
"查看流程",
|
||||||
|
"workflow",
|
||||||
|
"list",
|
||||||
|
"查看流程列表",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.read",
|
||||||
|
"查看流程详情",
|
||||||
|
"workflow",
|
||||||
|
"read",
|
||||||
|
"查看流程定义详情",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.update",
|
||||||
|
"编辑流程",
|
||||||
|
"workflow",
|
||||||
|
"update",
|
||||||
|
"编辑流程定义",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.publish",
|
||||||
|
"发布流程",
|
||||||
|
"workflow",
|
||||||
|
"publish",
|
||||||
|
"发布流程定义",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.start",
|
||||||
|
"发起流程",
|
||||||
|
"workflow",
|
||||||
|
"start",
|
||||||
|
"发起流程实例",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.approve",
|
||||||
|
"审批任务",
|
||||||
|
"workflow",
|
||||||
|
"approve",
|
||||||
|
"审批流程任务",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow.delegate",
|
||||||
|
"委派任务",
|
||||||
|
"workflow",
|
||||||
|
"delegate",
|
||||||
|
"委派流程任务",
|
||||||
|
),
|
||||||
|
// === Message module ===
|
||||||
|
(
|
||||||
|
"message.list",
|
||||||
|
"查看消息",
|
||||||
|
"message",
|
||||||
|
"list",
|
||||||
|
"查看消息列表",
|
||||||
|
),
|
||||||
|
("message.send", "发送消息", "message", "send", "发送新消息"),
|
||||||
|
(
|
||||||
|
"message.template.list",
|
||||||
|
"查看消息模板",
|
||||||
|
"message.template",
|
||||||
|
"list",
|
||||||
|
"查看消息模板列表",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"message.template.create",
|
||||||
|
"创建消息模板",
|
||||||
|
"message.template",
|
||||||
|
"create",
|
||||||
|
"创建消息模板",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"message.template.manage",
|
||||||
|
"管理消息模板",
|
||||||
|
"message.template",
|
||||||
|
"manage",
|
||||||
|
"编辑、删除消息模板",
|
||||||
|
),
|
||||||
|
// === Plugin module ===
|
||||||
|
(
|
||||||
|
"plugin.admin",
|
||||||
|
"插件管理",
|
||||||
|
"plugin",
|
||||||
|
"admin",
|
||||||
|
"管理插件全生命周期",
|
||||||
|
),
|
||||||
|
("plugin.list", "查看插件", "plugin", "list", "查看插件列表"),
|
||||||
|
// === Server level ===
|
||||||
|
(
|
||||||
|
"tenant.manage",
|
||||||
|
"租户管理",
|
||||||
|
"tenant",
|
||||||
|
"manage",
|
||||||
|
"管理租户级设置(密钥轮换等)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||||
|
const READ_PERM_INDICES: &[usize] = &[
|
||||||
|
0, // user.list
|
||||||
|
2, // user.read
|
||||||
|
5, // role.list
|
||||||
|
7, // role.read
|
||||||
|
10, // permission.list
|
||||||
|
11, // organization.list
|
||||||
|
15, // department.list
|
||||||
|
19, // position.list
|
||||||
|
23, // dictionary.list
|
||||||
|
28, // menu.list
|
||||||
|
30, // setting.read
|
||||||
|
32, // numbering.list
|
||||||
|
37, // theme.read
|
||||||
|
39, // language.list
|
||||||
|
43, // workflow.list
|
||||||
|
44, // workflow.read
|
||||||
|
49, // message.list
|
||||||
|
51, // message.template.list
|
||||||
|
54, // plugin.list
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Seed default auth data for a new tenant.
|
||||||
|
///
|
||||||
|
/// Creates:
|
||||||
|
/// - 56 permissions covering auth/config/workflow/message/plugin modules
|
||||||
|
/// - An "admin" system role with all permissions
|
||||||
|
/// - A "viewer" system role with read-only permissions
|
||||||
|
/// - A super-admin user with the admin role and a password credential
|
||||||
|
pub async fn seed_tenant_auth(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
super_admin_password: &str,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let system_user_id = Uuid::nil();
|
||||||
|
|
||||||
|
// 1. Create permissions
|
||||||
|
let mut perm_ids: Vec<Uuid> = Vec::with_capacity(DEFAULT_PERMISSIONS.len());
|
||||||
|
for (code, name, resource, action, desc) in DEFAULT_PERMISSIONS {
|
||||||
|
let perm_id = Uuid::now_v7();
|
||||||
|
perm_ids.push(perm_id);
|
||||||
|
|
||||||
|
let perm = permission::ActiveModel {
|
||||||
|
id: Set(perm_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
resource: Set(resource.to_string()),
|
||||||
|
action: Set(action.to_string()),
|
||||||
|
description: Set(Some(desc.to_string())),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
perm.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create "admin" role with all permissions
|
||||||
|
let admin_role_id = Uuid::now_v7();
|
||||||
|
let admin_role = role::ActiveModel {
|
||||||
|
id: Set(admin_role_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set("管理员".to_string()),
|
||||||
|
code: Set("admin".to_string()),
|
||||||
|
description: Set(Some("系统管理员,拥有所有权限".to_string())),
|
||||||
|
is_system: Set(true),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
admin_role
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Assign all permissions to admin role
|
||||||
|
for perm_id in &perm_ids {
|
||||||
|
let rp = role_permission::ActiveModel {
|
||||||
|
role_id: Set(admin_role_id),
|
||||||
|
permission_id: Set(*perm_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
data_scope: Set("all".to_string()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
rp.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create "viewer" role with read-only permissions
|
||||||
|
let viewer_role_id = Uuid::now_v7();
|
||||||
|
let viewer_role = role::ActiveModel {
|
||||||
|
id: Set(viewer_role_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set("查看者".to_string()),
|
||||||
|
code: Set("viewer".to_string()),
|
||||||
|
description: Set(Some("只读用户,可查看所有数据".to_string())),
|
||||||
|
is_system: Set(true),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
viewer_role
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Assign read permissions to viewer role
|
||||||
|
for idx in READ_PERM_INDICES {
|
||||||
|
if *idx < perm_ids.len() {
|
||||||
|
let rp = role_permission::ActiveModel {
|
||||||
|
role_id: Set(viewer_role_id),
|
||||||
|
permission_id: Set(perm_ids[*idx]),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
data_scope: Set("all".to_string()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
rp.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create super admin user
|
||||||
|
let admin_user_id = Uuid::now_v7();
|
||||||
|
let password_hash = password::hash_password(super_admin_password)?;
|
||||||
|
|
||||||
|
let admin_user = user::ActiveModel {
|
||||||
|
id: Set(admin_user_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
username: Set("admin".to_string()),
|
||||||
|
email: Set(None),
|
||||||
|
phone: Set(None),
|
||||||
|
display_name: Set(Some("系统管理员".to_string())),
|
||||||
|
avatar_url: Set(None),
|
||||||
|
status: Set("active".to_string()),
|
||||||
|
last_login_at: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
admin_user
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Create password credential for admin user
|
||||||
|
let cred = user_credential::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
user_id: Set(admin_user_id),
|
||||||
|
credential_type: Set("password".to_string()),
|
||||||
|
credential_data: Set(Some(serde_json::json!({ "hash": password_hash }))),
|
||||||
|
verified: Set(true),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
cred.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 5. Assign admin role to admin user
|
||||||
|
let user_role_assignment = user_role::ActiveModel {
|
||||||
|
user_id: Set(admin_user_id),
|
||||||
|
role_id: Set(admin_role_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(system_user_id),
|
||||||
|
updated_by: Set(system_user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
user_role_assignment
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
tenant_id = %tenant_id,
|
||||||
|
admin_user_id = %admin_user_id,
|
||||||
|
"Seeded tenant auth: admin user, 2 roles, {} permissions",
|
||||||
|
DEFAULT_PERMISSIONS.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
326
crates/erp-auth/src/service/token_service.rs
Normal file
326
crates/erp-auth/src/service/token_service.rs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entity::{permission, role, role_permission, user_role, user_token};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
/// JWT claims embedded in access and refresh tokens.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
/// Subject — the user ID
|
||||||
|
pub sub: Uuid,
|
||||||
|
/// Tenant ID
|
||||||
|
pub tid: Uuid,
|
||||||
|
/// Role codes assigned to this user
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
/// Permission codes granted to this user
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
/// Expiry (unix timestamp)
|
||||||
|
pub exp: i64,
|
||||||
|
/// Issued at (unix timestamp)
|
||||||
|
pub iat: i64,
|
||||||
|
/// Token type: "access" or "refresh"
|
||||||
|
pub token_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateless service for JWT token signing, validation, and revocation.
|
||||||
|
pub struct TokenService;
|
||||||
|
|
||||||
|
impl TokenService {
|
||||||
|
/// Sign a short-lived access token containing roles and permissions.
|
||||||
|
pub fn sign_access_token(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
roles: Vec<String>,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
secret: &str,
|
||||||
|
ttl_secs: i64,
|
||||||
|
) -> AuthResult<String> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
tid: tenant_id,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
exp: now.timestamp() + ttl_secs,
|
||||||
|
iat: now.timestamp(),
|
||||||
|
token_type: "access".to_string(),
|
||||||
|
};
|
||||||
|
let header = jsonwebtoken::Header::default();
|
||||||
|
let encoded = jsonwebtoken::encode(
|
||||||
|
&header,
|
||||||
|
&claims,
|
||||||
|
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a long-lived refresh token and persist its SHA-256 hash in the database.
|
||||||
|
///
|
||||||
|
/// Returns the raw token string (sent to client) and the database row ID.
|
||||||
|
pub async fn sign_refresh_token(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
secret: &str,
|
||||||
|
ttl_secs: i64,
|
||||||
|
) -> AuthResult<(String, Uuid)> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let token_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
tid: tenant_id,
|
||||||
|
roles: vec![],
|
||||||
|
permissions: vec![],
|
||||||
|
exp: now.timestamp() + ttl_secs,
|
||||||
|
iat: now.timestamp(),
|
||||||
|
token_type: "refresh".to_string(),
|
||||||
|
};
|
||||||
|
let raw_token = jsonwebtoken::encode(
|
||||||
|
&jsonwebtoken::Header::default(),
|
||||||
|
&claims,
|
||||||
|
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Store the SHA-256 hash — the raw token is never persisted.
|
||||||
|
let hash = sha256_hex(&raw_token);
|
||||||
|
|
||||||
|
let token_model = user_token::ActiveModel {
|
||||||
|
id: Set(token_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
token_hash: Set(hash),
|
||||||
|
token_type: Set("refresh".to_string()),
|
||||||
|
expires_at: Set(now + chrono::Duration::seconds(ttl_secs)),
|
||||||
|
revoked_at: Set(None),
|
||||||
|
device_info: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(user_id),
|
||||||
|
updated_by: Set(user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
token_model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((raw_token, token_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a refresh token against the database.
|
||||||
|
///
|
||||||
|
/// Returns the database row ID and decoded claims.
|
||||||
|
pub async fn validate_refresh_token(
|
||||||
|
token: &str,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
secret: &str,
|
||||||
|
) -> AuthResult<(Uuid, Claims)> {
|
||||||
|
let claims = Self::decode_token(token, secret)?;
|
||||||
|
if claims.token_type != "refresh" {
|
||||||
|
return Err(AuthError::Validation("不是 refresh token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = sha256_hex(token);
|
||||||
|
let token_row = user_token::Entity::find()
|
||||||
|
.filter(user_token::Column::TokenHash.eq(hash))
|
||||||
|
.filter(user_token::Column::TenantId.eq(claims.tid))
|
||||||
|
.filter(user_token::Column::RevokedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::TokenRevoked)?;
|
||||||
|
|
||||||
|
Ok((token_row.id, claims))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and validate any JWT token, returning the claims.
|
||||||
|
pub fn decode_token(token: &str, secret: &str) -> AuthResult<Claims> {
|
||||||
|
let data = jsonwebtoken::decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&jsonwebtoken::Validation::default(),
|
||||||
|
)?;
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke a specific refresh token by database ID.
|
||||||
|
/// Verifies that the token belongs to the specified user for security.
|
||||||
|
pub async fn revoke_token(
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let token_row = user_token::Entity::find_by_id(token_id)
|
||||||
|
.filter(user_token::Column::UserId.eq(user_id))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::TokenRevoked)?;
|
||||||
|
|
||||||
|
let mut active: user_token::ActiveModel = token_row.into();
|
||||||
|
active.revoked_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically validate and revoke a refresh token by hash.
|
||||||
|
/// This prevents TOCTOU race conditions during concurrent refresh requests.
|
||||||
|
/// Returns the decoded claims on success, or TokenRevoked if already consumed.
|
||||||
|
pub async fn validate_and_revoke_atomic(
|
||||||
|
token: &str,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
secret: &str,
|
||||||
|
) -> AuthResult<Claims> {
|
||||||
|
let claims = Self::decode_token(token, secret)?;
|
||||||
|
if claims.token_type != "refresh" {
|
||||||
|
return Err(AuthError::Validation("不是 refresh token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = sha256_hex(token);
|
||||||
|
let now = Utc::now();
|
||||||
|
let result = user_token::Entity::update_many()
|
||||||
|
.col_expr(
|
||||||
|
user_token::Column::RevokedAt,
|
||||||
|
sea_orm::sea_query::Expr::value(Some(now.naive_utc())),
|
||||||
|
)
|
||||||
|
.col_expr(
|
||||||
|
user_token::Column::UpdatedAt,
|
||||||
|
sea_orm::sea_query::Expr::value(now.naive_utc()),
|
||||||
|
)
|
||||||
|
.col_expr(
|
||||||
|
user_token::Column::Version,
|
||||||
|
sea_orm::sea_query::Expr::col(user_token::Column::Version).add(1),
|
||||||
|
)
|
||||||
|
.filter(user_token::Column::TokenHash.eq(&hash))
|
||||||
|
.filter(user_token::Column::UserId.eq(claims.sub))
|
||||||
|
.filter(user_token::Column::TenantId.eq(claims.tid))
|
||||||
|
.filter(user_token::Column::RevokedAt.is_null())
|
||||||
|
.exec(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected == 0 {
|
||||||
|
return Err(AuthError::TokenRevoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke all non-revoked refresh tokens for a given user within a tenant.
|
||||||
|
pub async fn revoke_all_user_tokens(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let tokens = user_token::Entity::find()
|
||||||
|
.filter(user_token::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_token::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_token::Column::RevokedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
for token in tokens {
|
||||||
|
let mut active: user_token::ActiveModel = token.into();
|
||||||
|
active.revoked_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a user's permission codes through user_roles -> role_permissions -> permissions.
|
||||||
|
pub async fn get_user_permissions(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<String>> {
|
||||||
|
let user_role_rows = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_perm_rows = role_permission::Entity::find()
|
||||||
|
.filter(role_permission::Column::RoleId.is_in(role_ids))
|
||||||
|
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role_permission::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let perm_ids: Vec<Uuid> = role_perm_rows.iter().map(|rp| rp.permission_id).collect();
|
||||||
|
if perm_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let perms = permission::Entity::find()
|
||||||
|
.filter(permission::Column::Id.is_in(perm_ids))
|
||||||
|
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(perms.iter().map(|p| p.code.clone()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a user's role codes through user_roles -> roles.
|
||||||
|
pub async fn get_user_roles(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<String>> {
|
||||||
|
let user_role_rows = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roles.iter().map(|r| r.code.clone()).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a SHA-256 hex digest of the input string.
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
let hash = Sha256::digest(input.as_bytes());
|
||||||
|
format!("{:x}", hash)
|
||||||
|
}
|
||||||
629
crates/erp-auth/src/service/user_service.rs
Normal file
629
crates/erp-auth/src/service/user_service.rs
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
||||||
|
use crate::entity::{role, user, user_credential, user_role};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
use super::password;
|
||||||
|
|
||||||
|
/// User CRUD service — create, read, update, soft-delete users within a tenant.
|
||||||
|
pub struct UserService;
|
||||||
|
|
||||||
|
impl UserService {
|
||||||
|
/// Create a new user with a password credential.
|
||||||
|
///
|
||||||
|
/// Validates username uniqueness within the tenant, hashes the password,
|
||||||
|
/// and publishes a `user.created` event.
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateUserReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<UserResp> {
|
||||||
|
// Check username uniqueness within tenant
|
||||||
|
let existing = user::Entity::find()
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::Username.eq(&req.username))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("用户名已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let user_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
// Insert user record
|
||||||
|
let user_model = user::ActiveModel {
|
||||||
|
id: Set(user_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
username: Set(req.username.clone()),
|
||||||
|
email: Set(req.email.clone()),
|
||||||
|
phone: Set(req.phone.clone()),
|
||||||
|
display_name: Set(req.display_name.clone()),
|
||||||
|
avatar_url: Set(None),
|
||||||
|
status: Set("active".to_string()),
|
||||||
|
last_login_at: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
user_model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Insert password credential
|
||||||
|
let hash = password::hash_password(&req.password)?;
|
||||||
|
let cred = user_credential::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
credential_type: Set("password".to_string()),
|
||||||
|
credential_data: Set(Some(serde_json::json!({ "hash": hash }))),
|
||||||
|
verified: Set(true),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
cred.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Publish domain event
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"user.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "user_id": user_id, "username": req.username }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "user.create", "user")
|
||||||
|
.with_resource_id(user_id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(UserResp {
|
||||||
|
id: user_id,
|
||||||
|
username: req.username.clone(),
|
||||||
|
email: req.email.clone(),
|
||||||
|
phone: req.phone.clone(),
|
||||||
|
display_name: req.display_name.clone(),
|
||||||
|
avatar_url: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
roles: vec![],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single user by ID, scoped to the given tenant.
|
||||||
|
pub async fn get_by_id(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<UserResp> {
|
||||||
|
let user_model = user::Entity::find()
|
||||||
|
.filter(user::Column::Id.eq(id))
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
|
||||||
|
Ok(model_to_resp(&user_model, roles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List users within a tenant with pagination and optional search.
|
||||||
|
///
|
||||||
|
/// Returns `(users, total_count)`. When `search` is provided, filters
|
||||||
|
/// by username using case-insensitive substring match.
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
search: Option<&str>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<(Vec<UserResp>, u64)> {
|
||||||
|
let mut query = user::Entity::find()
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
if let Some(term) = search
|
||||||
|
&& !term.is_empty()
|
||||||
|
{
|
||||||
|
use sea_orm::sea_query::Expr;
|
||||||
|
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let paginator = query.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut resps = Vec::with_capacity(models.len());
|
||||||
|
// 批量查询所有用户的角色(N+1 → 3 固定查询)
|
||||||
|
let user_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||||||
|
let role_map = Self::fetch_batch_user_role_resps(&user_ids, tenant_id, db).await;
|
||||||
|
|
||||||
|
for m in models {
|
||||||
|
let roles = role_map.get(&m.id).cloned().unwrap_or_default();
|
||||||
|
resps.push(model_to_resp(&m, roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable user fields.
|
||||||
|
///
|
||||||
|
/// Supports updating email, phone, display_name, and status.
|
||||||
|
/// Status must be one of: "active", "disabled", "locked".
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &UpdateUserReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<UserResp> {
|
||||||
|
let user_model = user::Entity::find()
|
||||||
|
.filter(user::Column::Id.eq(id))
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let old_json = serde_json::to_value(&user_model).unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
let next_ver = check_version(req.version, user_model.version)
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut active: user::ActiveModel = user_model.into();
|
||||||
|
|
||||||
|
if let Some(email) = &req.email {
|
||||||
|
active.email = Set(Some(email.clone()));
|
||||||
|
}
|
||||||
|
if let Some(phone) = &req.phone {
|
||||||
|
active.phone = Set(Some(phone.clone()));
|
||||||
|
}
|
||||||
|
if let Some(display_name) = &req.display_name {
|
||||||
|
active.display_name = Set(Some(display_name.clone()));
|
||||||
|
}
|
||||||
|
if let Some(status) = &req.status {
|
||||||
|
if !["active", "disabled", "locked"].contains(&status.as_str()) {
|
||||||
|
return Err(AuthError::Validation("无效的状态值".to_string()));
|
||||||
|
}
|
||||||
|
active.status = Set(status.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
|
||||||
|
.with_resource_id(id)
|
||||||
|
.with_changes(Some(old_json), Some(new_json)),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
|
||||||
|
Ok(model_to_resp(&updated, roles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a user by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let user_model = user::Entity::find()
|
||||||
|
.filter(user::Column::Id.eq(id))
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let current_version = user_model.version;
|
||||||
|
let mut active: user::ActiveModel = user_model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(current_version + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"user.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "user_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user").with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace all role assignments for a user within a tenant.
|
||||||
|
pub async fn assign_roles(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
role_ids: &[Uuid],
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<RoleResp>> {
|
||||||
|
// 验证用户存在
|
||||||
|
let _user = user::Entity::find()
|
||||||
|
.filter(user::Column::Id.eq(user_id))
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
// 验证所有角色存在且属于当前租户
|
||||||
|
if !role_ids.is_empty() {
|
||||||
|
let found = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids.iter().copied()))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if found.len() != role_ids.len() {
|
||||||
|
return Err(AuthError::Validation(
|
||||||
|
"部分角色不存在或不属于当前租户".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧的角色分配
|
||||||
|
user_role::Entity::delete_many()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.exec(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 创建新的角色分配
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
for &role_id in role_ids {
|
||||||
|
let assignment = user_role::ActiveModel {
|
||||||
|
user_id: Set(user_id),
|
||||||
|
role_id: Set(role_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
assignment
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "user.assign_roles", "user")
|
||||||
|
.with_resource_id(user_id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Self::fetch_user_role_resps(user_id, tenant_id, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量查询多用户的角色,返回 user_id → RoleResp 映射。
|
||||||
|
///
|
||||||
|
/// 使用 3 次固定查询替代 N+1:用户角色关联 → 角色 → 分组组装。
|
||||||
|
async fn fetch_batch_user_role_resps(
|
||||||
|
user_ids: &[Uuid],
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> HashMap<Uuid, Vec<RoleResp>> {
|
||||||
|
if user_ids.is_empty() {
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 批量查询 user_role 关联
|
||||||
|
let user_roles: Vec<user_role::Model> = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.is_in(user_ids.iter().copied()))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||||
|
|
||||||
|
// 2. 批量查询角色
|
||||||
|
let roles: Vec<role::Model> = if role_ids.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids.iter().copied()))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let role_map: HashMap<Uuid, &role::Model> = roles.iter().map(|r| (r.id, r)).collect();
|
||||||
|
|
||||||
|
// 3. 按 user_id 分组
|
||||||
|
let mut result: HashMap<Uuid, Vec<RoleResp>> = HashMap::new();
|
||||||
|
for ur in &user_roles {
|
||||||
|
let resp = role_map
|
||||||
|
.get(&ur.role_id)
|
||||||
|
.map(|r| RoleResp {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name.clone(),
|
||||||
|
code: r.code.clone(),
|
||||||
|
description: r.description.clone(),
|
||||||
|
is_system: r.is_system,
|
||||||
|
version: r.version,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| RoleResp {
|
||||||
|
id: ur.role_id,
|
||||||
|
name: "Unknown".into(),
|
||||||
|
code: "unknown".into(),
|
||||||
|
description: None,
|
||||||
|
is_system: false,
|
||||||
|
version: 0,
|
||||||
|
});
|
||||||
|
result.entry(ur.user_id).or_default().push(resp);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 管理员重置指定用户密码。
|
||||||
|
pub async fn reset_password(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
new_password: &str,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
// 1. 验证用户存在且属于当前租户
|
||||||
|
let user_model = user::Entity::find()
|
||||||
|
.filter(user::Column::Id.eq(user_id))
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let _next_version = check_version(version, user_model.version)
|
||||||
|
.map_err(|_| AuthError::Validation("版本冲突,请刷新后重试".to_string()))?;
|
||||||
|
|
||||||
|
// 2. 查找密码凭证
|
||||||
|
let cred = user_credential::Entity::find()
|
||||||
|
.filter(user_credential::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_credential::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||||
|
.filter(user_credential::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
|
||||||
|
|
||||||
|
// 3. 哈希新密码并更新凭证
|
||||||
|
let new_hash = password::hash_password(new_password)?;
|
||||||
|
let cred_version = cred.version;
|
||||||
|
let mut cred_active: user_credential::ActiveModel = cred.into();
|
||||||
|
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
|
||||||
|
cred_active.updated_at = Set(Utc::now());
|
||||||
|
cred_active.updated_by = Set(operator_id);
|
||||||
|
cred_active.version = Set(cred_version + 1);
|
||||||
|
cred_active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 4. 吊销所有 refresh token
|
||||||
|
super::token_service::TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 5. 审计日志
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "user.reset_password", "user")
|
||||||
|
.with_resource_id(user_id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user_id, operator_id = %operator_id, "Password reset by admin");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch role details for a single user, returning RoleResp DTOs.
|
||||||
|
async fn fetch_user_role_resps(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<RoleResp>> {
|
||||||
|
let user_roles = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roles
|
||||||
|
.iter()
|
||||||
|
.map(|r| RoleResp {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name.clone(),
|
||||||
|
code: r.code.clone(),
|
||||||
|
description: r.description.clone(),
|
||||||
|
is_system: r.is_system,
|
||||||
|
version: r.version,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
|
||||||
|
pub(crate) fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
||||||
|
UserResp {
|
||||||
|
id: m.id,
|
||||||
|
username: m.username.clone(),
|
||||||
|
email: m.email.clone(),
|
||||||
|
phone: m.phone.clone(),
|
||||||
|
display_name: m.display_name.clone(),
|
||||||
|
avatar_url: m.avatar_url.clone(),
|
||||||
|
status: m.status.clone(),
|
||||||
|
roles,
|
||||||
|
version: m.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::RoleResp;
|
||||||
|
use crate::entity::user;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_user_model(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
status: &str,
|
||||||
|
version: i32,
|
||||||
|
) -> user::Model {
|
||||||
|
user::Model {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
username: username.to_string(),
|
||||||
|
email: None,
|
||||||
|
phone: None,
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
status: status.to_string(),
|
||||||
|
last_login_at: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
created_by: Uuid::now_v7(),
|
||||||
|
updated_by: Uuid::now_v7(),
|
||||||
|
deleted_at: None,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_maps_basic_fields() {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let m = make_user_model(id, tid, "alice", "active", 1);
|
||||||
|
let resp = model_to_resp(&m, vec![]);
|
||||||
|
assert_eq!(resp.id, id);
|
||||||
|
assert_eq!(resp.username, "alice");
|
||||||
|
assert_eq!(resp.status, "active");
|
||||||
|
assert_eq!(resp.version, 1);
|
||||||
|
assert!(resp.roles.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_includes_roles() {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let tid = Uuid::now_v7();
|
||||||
|
let m = make_user_model(id, tid, "bob", "active", 2);
|
||||||
|
let roles = vec![
|
||||||
|
RoleResp {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
name: "管理员".to_string(),
|
||||||
|
code: "admin".to_string(),
|
||||||
|
description: None,
|
||||||
|
is_system: true,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
RoleResp {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
name: "用户".to_string(),
|
||||||
|
code: "user".to_string(),
|
||||||
|
description: None,
|
||||||
|
is_system: false,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let resp = model_to_resp(&m, roles);
|
||||||
|
assert_eq!(resp.roles.len(), 2);
|
||||||
|
assert_eq!(resp.roles[0].code, "admin");
|
||||||
|
assert_eq!(resp.version, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
575
crates/erp-auth/src/service/wechat_service.rs
Normal file
575
crates/erp-auth/src/service/wechat_service.rs
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
|
||||||
|
use base64::Engine;
|
||||||
|
use cbc::Decryptor;
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth_state::AuthState;
|
||||||
|
use crate::dto::{LoginResp, UserResp, WechatLoginResp};
|
||||||
|
use crate::entity::wechat_user;
|
||||||
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
use crate::service::auth_service::JwtConfig;
|
||||||
|
use crate::service::token_service::TokenService;
|
||||||
|
use erp_core::sanitize::sanitize_string;
|
||||||
|
|
||||||
|
type Aes128CbcDec = Decryptor<aes::Aes128>;
|
||||||
|
|
||||||
|
/// 内存降级缓存(Redis 不可用时使用)
|
||||||
|
struct SessionEntry {
|
||||||
|
session_key: String,
|
||||||
|
created_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MEMORY_FALLBACK: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
const SESSION_TTL_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// Redis key 前缀
|
||||||
|
const REDIS_KEY_PREFIX: &str = "wechat:session:";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WechatSessionResp {
|
||||||
|
openid: Option<String>,
|
||||||
|
session_key: Option<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
unionid: Option<String>,
|
||||||
|
errcode: Option<i32>,
|
||||||
|
errmsg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WechatService;
|
||||||
|
|
||||||
|
impl WechatService {
|
||||||
|
pub async fn login(
|
||||||
|
state: &AuthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
code: &str,
|
||||||
|
) -> AuthResult<WechatLoginResp> {
|
||||||
|
tracing::info!(
|
||||||
|
appid = %state.wechat_appid,
|
||||||
|
code = %code,
|
||||||
|
"fetch_session 开始"
|
||||||
|
);
|
||||||
|
let session = fetch_session(
|
||||||
|
&state.wechat_appid,
|
||||||
|
&state.wechat_secret,
|
||||||
|
code,
|
||||||
|
state.wechat_dev_mode,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let openid = session
|
||||||
|
.openid
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
||||||
|
|
||||||
|
// 缓存 session_key(Redis 优先,内存降级)
|
||||||
|
if let Some(sk) = &session.session_key
|
||||||
|
&& let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await
|
||||||
|
{
|
||||||
|
tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
|
||||||
|
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||||
|
cache.insert(
|
||||||
|
openid.clone(),
|
||||||
|
SessionEntry {
|
||||||
|
session_key: sk.clone(),
|
||||||
|
created_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = wechat_user::Entity::find()
|
||||||
|
.filter(wechat_user::Column::Openid.eq(&openid))
|
||||||
|
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(wechat_user::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(wu) = existing {
|
||||||
|
let token = build_login_resp(
|
||||||
|
&state.db,
|
||||||
|
wu.user_id,
|
||||||
|
tenant_id,
|
||||||
|
&JwtConfig {
|
||||||
|
secret: &state.jwt_secret,
|
||||||
|
access_ttl_secs: state.access_ttl_secs,
|
||||||
|
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(WechatLoginResp {
|
||||||
|
bound: true,
|
||||||
|
openid,
|
||||||
|
token: Some(token),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(WechatLoginResp {
|
||||||
|
bound: false,
|
||||||
|
openid,
|
||||||
|
token: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn bind_phone(
|
||||||
|
state: &AuthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
openid: &str,
|
||||||
|
encrypted_data: &str,
|
||||||
|
iv: &str,
|
||||||
|
) -> AuthResult<LoginResp> {
|
||||||
|
// Dev 模式:mock session_key 无法解密真实微信加密数据,直接使用 mock 手机号
|
||||||
|
let phone = if state.wechat_dev_mode {
|
||||||
|
let hash = openid
|
||||||
|
.bytes()
|
||||||
|
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
|
||||||
|
let suffix = hash % 10000;
|
||||||
|
tracing::warn!(%openid, mock_phone = format!("1380000{suffix:04}"), "开发模式:跳过手机号解密,使用 mock 手机号");
|
||||||
|
format!("1380000{suffix:04}")
|
||||||
|
} else {
|
||||||
|
let session_key = Self::get_session_key(&state.redis, openid).await?;
|
||||||
|
decrypt_phone_number(&session_key, encrypted_data, iv)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing = wechat_user::Entity::find()
|
||||||
|
.filter(wechat_user::Column::Openid.eq(openid))
|
||||||
|
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(wechat_user::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("该微信已绑定账号".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone, &state.crypto).await?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let wu = wechat_user::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
openid: Set(openid.to_string()),
|
||||||
|
union_id: Set(None),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
phone: Set(Some(phone.clone())),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(Some(user_id)),
|
||||||
|
updated_by: Set(Some(user_id)),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
wu.insert(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
build_login_resp(
|
||||||
|
&state.db,
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
&JwtConfig {
|
||||||
|
secret: &state.jwt_secret,
|
||||||
|
access_ttl_secs: state.access_ttl_secs,
|
||||||
|
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_or_create_user_by_phone(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
phone: &str,
|
||||||
|
crypto: &erp_core::crypto::PiiCrypto,
|
||||||
|
) -> AuthResult<Uuid> {
|
||||||
|
use crate::entity::user;
|
||||||
|
|
||||||
|
let existing = user::Entity::find()
|
||||||
|
.filter(user::Column::Phone.eq(phone))
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(u) = existing {
|
||||||
|
return Ok(u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let user_id = Uuid::now_v7();
|
||||||
|
let suffix = &phone[phone.len().saturating_sub(4)..];
|
||||||
|
|
||||||
|
let new_user = user::ActiveModel {
|
||||||
|
id: Set(user_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
username: Set(format!("wx_{}", suffix)),
|
||||||
|
display_name: Set(Some(sanitize_string(&format!("微信用户{}", suffix)))),
|
||||||
|
phone: Set(Some(phone.to_string())),
|
||||||
|
email: Set(None),
|
||||||
|
avatar_url: Set(None),
|
||||||
|
status: Set("active".to_string()),
|
||||||
|
last_login_at: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(user_id),
|
||||||
|
updated_by: Set(user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
new_user
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
// 自动分配 patient 角色
|
||||||
|
Self::assign_patient_role(db, tenant_id, user_id).await?;
|
||||||
|
|
||||||
|
// 自动创建或关联 patient 记录
|
||||||
|
Self::ensure_patient_record(db, tenant_id, user_id, phone, crypto).await?;
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assign_patient_role(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
use crate::entity::role;
|
||||||
|
use crate::entity::user_role;
|
||||||
|
|
||||||
|
let patient_role = role::Entity::find()
|
||||||
|
.filter(role::Column::Code.eq("patient"))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(role::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(r) = patient_role {
|
||||||
|
let now = Utc::now();
|
||||||
|
let ur = user_role::ActiveModel {
|
||||||
|
user_id: Set(user_id),
|
||||||
|
role_id: Set(r.id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(user_id),
|
||||||
|
updated_by: Set(user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
ur.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
tracing::info!(%user_id, role_id = %r.id, "已为新用户分配 patient 角色");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(%tenant_id, "patient 角色不存在,跳过角色分配");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自动创建或关联 patient 记录。
|
||||||
|
///
|
||||||
|
/// 1. 如果已有 user_id 关联的 patient → 跳过
|
||||||
|
/// 2. 如果手机号盲索引匹配到未绑定的已有患者 → 合并(关联 user_id)
|
||||||
|
/// 3. 否则 → 创建新的 patient 记录
|
||||||
|
async fn ensure_patient_record(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
phone: &str,
|
||||||
|
crypto: &erp_core::crypto::PiiCrypto,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
use sea_orm::{ConnectionTrait, Statement};
|
||||||
|
|
||||||
|
// 使用 raw SQL 避免跨 crate 依赖 erp-health 的 entity
|
||||||
|
let result: Option<sea_orm::QueryResult> = db
|
||||||
|
.query_one(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"SELECT id FROM patient WHERE user_id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||||||
|
[user_id.into(), tenant_id.into()],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
if result.is_some() {
|
||||||
|
tracing::debug!(%user_id, "patient 记录已存在,跳过创建");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能合并:用手机号盲索引查找未绑定的已有患者(管理员/护士建档)
|
||||||
|
let phone_hash = erp_core::crypto::hmac_hash(crypto.hmac_key(), phone);
|
||||||
|
let blind_match: Option<sea_orm::QueryResult> = db
|
||||||
|
.query_one(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
r#"SELECT bi.entity_id AS patient_id
|
||||||
|
FROM blind_index bi
|
||||||
|
JOIN patient p ON p.id = bi.entity_id AND p.tenant_id = $2 AND p.deleted_at IS NULL
|
||||||
|
WHERE bi.entity_type = 'patient'
|
||||||
|
AND bi.field_name = 'emergency_contact_phone'
|
||||||
|
AND bi.blind_hash = $1
|
||||||
|
AND bi.tenant_id = $2
|
||||||
|
AND p.user_id IS NULL
|
||||||
|
LIMIT 1"#,
|
||||||
|
[phone_hash.as_str().into(), tenant_id.into()],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(row) = blind_match {
|
||||||
|
let patient_id: Uuid = row
|
||||||
|
.try_get("", "patient_id")
|
||||||
|
.map_err(|e| AuthError::DbError(format!("blind_index parse: {}", e)))?;
|
||||||
|
db.execute(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"UPDATE patient SET user_id = $1, updated_at = NOW(), updated_by = $1 WHERE id = $2 AND user_id IS NULL",
|
||||||
|
[user_id.into(), patient_id.into()],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
tracing::info!(%user_id, %patient_id, "手机号盲索引合并 patient");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = &phone[phone.len().saturating_sub(4)..];
|
||||||
|
let patient_id = Uuid::now_v7();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.execute(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
r#"INSERT INTO patient (id, tenant_id, user_id, name, gender, status, verification_status, source, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||||
|
VALUES ($1, $2, $3, $4, NULL, 'active', 'pending', 'wechat_miniprogram', $5, $5, $3, $3, NULL, 1)
|
||||||
|
ON CONFLICT DO NOTHING"#,
|
||||||
|
[
|
||||||
|
patient_id.into(),
|
||||||
|
tenant_id.into(),
|
||||||
|
user_id.into(),
|
||||||
|
sanitize_string(&format!("微信用户{}", suffix)).into(),
|
||||||
|
now.into(),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(%user_id, %patient_id, "已自动创建 patient 记录");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_session_key_redis(
|
||||||
|
redis: &Option<redis::Client>,
|
||||||
|
openid: &str,
|
||||||
|
session_key: &str,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let client = redis
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AuthError::DbError("Redis 未配置".into()))?;
|
||||||
|
let mut conn = client
|
||||||
|
.get_multiplexed_async_connection()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(format!("Redis 连接失败: {e}")))?;
|
||||||
|
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg(session_key)
|
||||||
|
.arg("EX")
|
||||||
|
.arg(SESSION_TTL_SECS)
|
||||||
|
.query_async::<String>(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(format!("Redis SET 失败: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session_key(redis: &Option<redis::Client>, openid: &str) -> AuthResult<String> {
|
||||||
|
// 1. 尝试 Redis
|
||||||
|
if let Some(client) = redis
|
||||||
|
&& let Ok(mut conn) = client.get_multiplexed_async_connection().await
|
||||||
|
{
|
||||||
|
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||||
|
let result: Option<String> = redis::cmd("GETDEL")
|
||||||
|
.arg(&key)
|
||||||
|
.query_async::<Option<String>>(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
if let Some(sk) = result {
|
||||||
|
return Ok(sk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 降级到内存
|
||||||
|
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||||
|
if let Some(entry) = cache.get(openid) {
|
||||||
|
if entry.created_at.elapsed().as_secs() < SESSION_TTL_SECS {
|
||||||
|
let sk = entry.session_key.clone();
|
||||||
|
cache.remove(openid);
|
||||||
|
return Ok(sk);
|
||||||
|
}
|
||||||
|
cache.remove(openid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AuthError::Validation(
|
||||||
|
"未找到 session_key,请重新登录".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-128-CBC 解密微信手机号
|
||||||
|
fn decrypt_phone_number(session_key: &str, encrypted_data: &str, iv: &str) -> AuthResult<String> {
|
||||||
|
let engine = base64::engine::general_purpose::STANDARD;
|
||||||
|
|
||||||
|
let key_bytes = engine
|
||||||
|
.decode(session_key)
|
||||||
|
.map_err(|e| AuthError::Validation(format!("session_key base64 解码失败: {}", e)))?;
|
||||||
|
let iv_bytes = engine
|
||||||
|
.decode(iv)
|
||||||
|
.map_err(|e| AuthError::Validation(format!("iv base64 解码失败: {}", e)))?;
|
||||||
|
let ciphertext = engine
|
||||||
|
.decode(encrypted_data)
|
||||||
|
.map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?;
|
||||||
|
|
||||||
|
if key_bytes.len() != 16 {
|
||||||
|
return Err(AuthError::Validation("session_key 长度不正确".to_string()));
|
||||||
|
}
|
||||||
|
if iv_bytes.len() != 16 {
|
||||||
|
return Err(AuthError::Validation("iv 长度不正确".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let decryptor = Aes128CbcDec::new_from_slices(&key_bytes, &iv_bytes)
|
||||||
|
.map_err(|e| AuthError::Validation(format!("AES 初始化失败: {}", e)))?;
|
||||||
|
|
||||||
|
let mut buf = ciphertext;
|
||||||
|
let decrypted = decryptor
|
||||||
|
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||||
|
.map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?;
|
||||||
|
|
||||||
|
let plaintext = String::from_utf8(decrypted.to_vec())
|
||||||
|
.map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?;
|
||||||
|
|
||||||
|
// 微信返回的 JSON 包含 watermark 等字段,提取 phone_number
|
||||||
|
let info: serde_json::Value = serde_json::from_str(&plaintext)
|
||||||
|
.map_err(|e| AuthError::Validation(format!("解密结果 JSON 解析失败: {}", e)))?;
|
||||||
|
|
||||||
|
info.get("phoneNumber")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| AuthError::Validation("解密结果中无 phoneNumber".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_login_resp(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
jwt: &JwtConfig<'_>,
|
||||||
|
) -> AuthResult<LoginResp> {
|
||||||
|
use crate::entity::user;
|
||||||
|
use crate::service::auth_service::AuthService;
|
||||||
|
|
||||||
|
let user_model = user::Entity::find_by_id(user_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(e.to_string()))?
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let roles = TokenService::get_user_roles(user_id, tenant_id, db).await?;
|
||||||
|
let permissions = TokenService::get_user_permissions(user_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
let access_token = TokenService::sign_access_token(
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
roles.clone(),
|
||||||
|
permissions,
|
||||||
|
jwt.secret,
|
||||||
|
jwt.access_ttl_secs,
|
||||||
|
)?;
|
||||||
|
let (refresh_token, _) =
|
||||||
|
TokenService::sign_refresh_token(user_id, tenant_id, db, jwt.secret, jwt.refresh_ttl_secs)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
Ok(LoginResp {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_in: jwt.access_ttl_secs as u64,
|
||||||
|
user: UserResp {
|
||||||
|
id: user_model.id,
|
||||||
|
username: user_model.username,
|
||||||
|
email: user_model.email,
|
||||||
|
phone: user_model.phone,
|
||||||
|
display_name: user_model.display_name,
|
||||||
|
avatar_url: user_model.avatar_url,
|
||||||
|
status: user_model.status,
|
||||||
|
roles: role_resps,
|
||||||
|
version: user_model.version,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_session(
|
||||||
|
appid: &str,
|
||||||
|
secret: &str,
|
||||||
|
code: &str,
|
||||||
|
dev_mode: bool,
|
||||||
|
) -> AuthResult<WechatSessionResp> {
|
||||||
|
// 开发模式降级:跳过 jscode2session,为 DevTools 模拟器生成确定性 mock openid
|
||||||
|
if dev_mode {
|
||||||
|
let mock_openid = format!("dev_mock_{}", &code[..8.min(code.len())]);
|
||||||
|
tracing::warn!(%mock_openid, "开发模式:使用 mock openid(跳过 jscode2session)");
|
||||||
|
return Ok(WechatSessionResp {
|
||||||
|
openid: Some(mock_openid),
|
||||||
|
session_key: Some("dev_mock_session_key".to_string()),
|
||||||
|
unionid: None,
|
||||||
|
errcode: None,
|
||||||
|
errmsg: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get("https://api.weixin.qq.com/sns/jscode2session")
|
||||||
|
.query(&[
|
||||||
|
("appid", appid),
|
||||||
|
("secret", secret),
|
||||||
|
("js_code", code),
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(format!("微信 API 请求失败: {}", e)))?;
|
||||||
|
|
||||||
|
let session: WechatSessionResp = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(errcode) = session.errcode
|
||||||
|
&& errcode != 0
|
||||||
|
{
|
||||||
|
let msg = session.errmsg.clone().unwrap_or_default();
|
||||||
|
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
|
||||||
|
return Err(AuthError::Validation(format!(
|
||||||
|
"微信登录失败 ({}): {}",
|
||||||
|
errcode, msg
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
has_openid = session.openid.is_some(),
|
||||||
|
has_session_key = session.session_key.is_some(),
|
||||||
|
"微信 jscode2session 成功"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
20
crates/erp-config/Cargo.toml
Normal file
20
crates/erp-config/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "erp-config"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
erp-core.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
sea-orm.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
validator.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
11
crates/erp-config/src/config_state.rs
Normal file
11
crates/erp-config/src/config_state.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use erp_core::events::EventBus;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
/// Config-specific state extracted from the server's AppState via `FromRef`.
|
||||||
|
///
|
||||||
|
/// Contains the database connection and event bus needed by config handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConfigState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub event_bus: EventBus,
|
||||||
|
}
|
||||||
693
crates/erp-config/src/dto.rs
Normal file
693
crates/erp-config/src/dto.rs
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
// --- Dictionary DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DictionaryItemResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub dictionary_id: Uuid,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DictionaryResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub items: Vec<DictionaryItemResp>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateDictionaryReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "字典名称不能为空"))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(length(min = 1, max = 50, message = "字典编码不能为空"))]
|
||||||
|
pub code: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateDictionaryReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "字典名称不能为空且不超过100字符"))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateDictionaryItemReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "标签不能为空"))]
|
||||||
|
pub label: String,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "值不能为空"))]
|
||||||
|
pub value: String,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateDictionaryItemReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "标签不能为空且不超过100字符"))]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "值不能为空且不超过100字符"))]
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Menu DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema, Clone)]
|
||||||
|
pub struct MenuResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub visible: bool,
|
||||||
|
pub menu_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub children: Vec<MenuResp>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateMenuReq {
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
||||||
|
pub title: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
#[validate(length(min = 1, message = "菜单类型不能为空"))]
|
||||||
|
pub menu_type: Option<String>,
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateMenuReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空且不超过100字符"))]
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct BatchSaveMenusReq {
|
||||||
|
#[validate(length(min = 1, message = "菜单列表不能为空"), nested)]
|
||||||
|
pub menus: Vec<MenuItemReq>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct MenuItemReq {
|
||||||
|
pub id: Option<Uuid>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
||||||
|
pub title: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
pub menu_type: Option<String>,
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
/// 乐观锁版本号。更新已有菜单时必填。
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Setting DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct SettingResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub scope: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
pub setting_key: String,
|
||||||
|
pub setting_value: serde_json::Value,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateSettingReq {
|
||||||
|
pub setting_value: serde_json::Value,
|
||||||
|
/// 乐观锁版本号。更新已有设置时必填,创建新设置时忽略。
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内部参数结构体,用于减少 SettingService::set 的参数数量。
|
||||||
|
pub struct SetSettingParams {
|
||||||
|
pub key: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
/// 乐观锁版本号。更新已有设置时用于校验。
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Numbering Rule DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct NumberingRuleResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
pub prefix: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: i32,
|
||||||
|
pub seq_start: i32,
|
||||||
|
pub seq_current: i64,
|
||||||
|
pub separator: String,
|
||||||
|
pub reset_cycle: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_reset_date: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateNumberingRuleReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "规则名称不能为空"))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(length(min = 1, max = 50, message = "规则编码不能为空"))]
|
||||||
|
pub code: String,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: Option<i32>,
|
||||||
|
pub seq_start: Option<i32>,
|
||||||
|
pub separator: Option<String>,
|
||||||
|
pub reset_cycle: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateNumberingRuleReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "规则名称不能为空且不超过100字符"))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: Option<i32>,
|
||||||
|
pub separator: Option<String>,
|
||||||
|
pub reset_cycle: Option<String>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct GenerateNumberResp {
|
||||||
|
pub number: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Theme DTOs (stored via settings) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
|
||||||
|
pub struct ThemeResp {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub primary_color: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sidebar_style: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub brand_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub brand_slogan: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub brand_features: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub brand_copyright: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 品牌信息公开响应(不含内部配置)
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
|
||||||
|
pub struct PublicBrandResp {
|
||||||
|
pub brand_name: String,
|
||||||
|
pub brand_slogan: String,
|
||||||
|
pub brand_features: String,
|
||||||
|
pub brand_copyright: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Language DTOs (stored via settings) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct LanguageResp {
|
||||||
|
pub code: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateLanguageReq {
|
||||||
|
pub is_active: bool,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "语言名称不能为空且不超过100字符"))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
// ---- CreateDictionaryReq 验证 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_req_valid() {
|
||||||
|
let req = CreateDictionaryReq {
|
||||||
|
name: "状态字典".to_string(),
|
||||||
|
code: "status".to_string(),
|
||||||
|
description: Some("通用状态".to_string()),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_req_empty_name_fails() {
|
||||||
|
let req = CreateDictionaryReq {
|
||||||
|
name: "".to_string(),
|
||||||
|
code: "status".to_string(),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_req_empty_code_fails() {
|
||||||
|
let req = CreateDictionaryReq {
|
||||||
|
name: "状态字典".to_string(),
|
||||||
|
code: "".to_string(),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_req_name_too_long_fails() {
|
||||||
|
let req = CreateDictionaryReq {
|
||||||
|
name: "x".repeat(101),
|
||||||
|
code: "status".to_string(),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_req_code_too_long_fails() {
|
||||||
|
let req = CreateDictionaryReq {
|
||||||
|
name: "状态字典".to_string(),
|
||||||
|
code: "x".repeat(51),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_req_max_boundary_ok() {
|
||||||
|
let req = CreateDictionaryReq {
|
||||||
|
name: "x".repeat(100),
|
||||||
|
code: "x".repeat(50),
|
||||||
|
description: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreateDictionaryItemReq 验证 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_valid() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "启用".to_string(),
|
||||||
|
value: "active".to_string(),
|
||||||
|
sort_order: Some(1),
|
||||||
|
color: Some("#00FF00".to_string()),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_empty_label_fails() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "".to_string(),
|
||||||
|
value: "active".to_string(),
|
||||||
|
sort_order: None,
|
||||||
|
color: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_empty_value_fails() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "启用".to_string(),
|
||||||
|
value: "".to_string(),
|
||||||
|
sort_order: None,
|
||||||
|
color: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_label_too_long_fails() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "x".repeat(101),
|
||||||
|
value: "active".to_string(),
|
||||||
|
sort_order: None,
|
||||||
|
color: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_value_too_long_fails() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "启用".to_string(),
|
||||||
|
value: "x".repeat(101),
|
||||||
|
sort_order: None,
|
||||||
|
color: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_min_boundary_ok() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "x".to_string(),
|
||||||
|
value: "x".to_string(),
|
||||||
|
sort_order: None,
|
||||||
|
color: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_dictionary_item_req_max_boundary_ok() {
|
||||||
|
let req = CreateDictionaryItemReq {
|
||||||
|
label: "x".repeat(100),
|
||||||
|
value: "x".repeat(100),
|
||||||
|
sort_order: Some(99),
|
||||||
|
color: Some("#FFFFFF".to_string()),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreateMenuReq 验证 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_menu_req_valid() {
|
||||||
|
let req = CreateMenuReq {
|
||||||
|
parent_id: None,
|
||||||
|
title: "系统设置".to_string(),
|
||||||
|
path: Some("/settings".to_string()),
|
||||||
|
icon: Some("SettingOutlined".to_string()),
|
||||||
|
sort_order: Some(1),
|
||||||
|
visible: Some(true),
|
||||||
|
menu_type: Some("menu".to_string()),
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_menu_req_empty_title_fails() {
|
||||||
|
let req = CreateMenuReq {
|
||||||
|
parent_id: None,
|
||||||
|
title: "".to_string(),
|
||||||
|
path: None,
|
||||||
|
icon: None,
|
||||||
|
sort_order: None,
|
||||||
|
visible: None,
|
||||||
|
menu_type: None,
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_menu_req_title_too_long_fails() {
|
||||||
|
let req = CreateMenuReq {
|
||||||
|
parent_id: None,
|
||||||
|
title: "x".repeat(101),
|
||||||
|
path: None,
|
||||||
|
icon: None,
|
||||||
|
sort_order: None,
|
||||||
|
visible: None,
|
||||||
|
menu_type: None,
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_menu_req_title_max_boundary_ok() {
|
||||||
|
let req = CreateMenuReq {
|
||||||
|
parent_id: None,
|
||||||
|
title: "x".repeat(100),
|
||||||
|
path: None,
|
||||||
|
icon: None,
|
||||||
|
sort_order: None,
|
||||||
|
visible: None,
|
||||||
|
menu_type: None,
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BatchSaveMenusReq 验证 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_save_menus_req_valid() {
|
||||||
|
let req = BatchSaveMenusReq {
|
||||||
|
menus: vec![MenuItemReq {
|
||||||
|
id: None,
|
||||||
|
parent_id: None,
|
||||||
|
title: "首页".to_string(),
|
||||||
|
path: Some("/home".to_string()),
|
||||||
|
icon: None,
|
||||||
|
sort_order: Some(0),
|
||||||
|
visible: Some(true),
|
||||||
|
menu_type: Some("menu".to_string()),
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
version: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_save_menus_req_empty_list_fails() {
|
||||||
|
let req = BatchSaveMenusReq { menus: vec![] };
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_save_menus_req_item_empty_title_fails() {
|
||||||
|
let req = BatchSaveMenusReq {
|
||||||
|
menus: vec![MenuItemReq {
|
||||||
|
id: None,
|
||||||
|
parent_id: None,
|
||||||
|
title: "".to_string(),
|
||||||
|
path: None,
|
||||||
|
icon: None,
|
||||||
|
sort_order: None,
|
||||||
|
visible: None,
|
||||||
|
menu_type: None,
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
version: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_save_menus_req_item_title_too_long_fails() {
|
||||||
|
let req = BatchSaveMenusReq {
|
||||||
|
menus: vec![MenuItemReq {
|
||||||
|
id: None,
|
||||||
|
parent_id: None,
|
||||||
|
title: "x".repeat(101),
|
||||||
|
path: None,
|
||||||
|
icon: None,
|
||||||
|
sort_order: None,
|
||||||
|
visible: None,
|
||||||
|
menu_type: None,
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
version: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_save_menus_req_multiple_items_ok() {
|
||||||
|
let req = BatchSaveMenusReq {
|
||||||
|
menus: vec![
|
||||||
|
MenuItemReq {
|
||||||
|
id: None,
|
||||||
|
parent_id: None,
|
||||||
|
title: "菜单A".to_string(),
|
||||||
|
path: Some("/a".to_string()),
|
||||||
|
icon: None,
|
||||||
|
sort_order: Some(0),
|
||||||
|
visible: Some(true),
|
||||||
|
menu_type: Some("menu".to_string()),
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
version: None,
|
||||||
|
},
|
||||||
|
MenuItemReq {
|
||||||
|
id: None,
|
||||||
|
parent_id: None,
|
||||||
|
title: "菜单B".to_string(),
|
||||||
|
path: Some("/b".to_string()),
|
||||||
|
icon: None,
|
||||||
|
sort_order: Some(1),
|
||||||
|
visible: Some(true),
|
||||||
|
menu_type: Some("menu".to_string()),
|
||||||
|
permission: None,
|
||||||
|
role_ids: None,
|
||||||
|
version: Some(1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreateNumberingRuleReq 验证 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_numbering_rule_req_valid() {
|
||||||
|
let req = CreateNumberingRuleReq {
|
||||||
|
name: "订单编号".to_string(),
|
||||||
|
code: "ORDER".to_string(),
|
||||||
|
prefix: Some("ORD".to_string()),
|
||||||
|
date_format: Some("%Y%m%d".to_string()),
|
||||||
|
seq_length: Some(4),
|
||||||
|
seq_start: Some(1),
|
||||||
|
separator: Some("-".to_string()),
|
||||||
|
reset_cycle: Some("daily".to_string()),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_numbering_rule_req_empty_name_fails() {
|
||||||
|
let req = CreateNumberingRuleReq {
|
||||||
|
name: "".to_string(),
|
||||||
|
code: "ORDER".to_string(),
|
||||||
|
prefix: None,
|
||||||
|
date_format: None,
|
||||||
|
seq_length: None,
|
||||||
|
seq_start: None,
|
||||||
|
separator: None,
|
||||||
|
reset_cycle: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_numbering_rule_req_empty_code_fails() {
|
||||||
|
let req = CreateNumberingRuleReq {
|
||||||
|
name: "订单编号".to_string(),
|
||||||
|
code: "".to_string(),
|
||||||
|
prefix: None,
|
||||||
|
date_format: None,
|
||||||
|
seq_length: None,
|
||||||
|
seq_start: None,
|
||||||
|
separator: None,
|
||||||
|
reset_cycle: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_numbering_rule_req_name_too_long_fails() {
|
||||||
|
let req = CreateNumberingRuleReq {
|
||||||
|
name: "x".repeat(101),
|
||||||
|
code: "ORDER".to_string(),
|
||||||
|
prefix: None,
|
||||||
|
date_format: None,
|
||||||
|
seq_length: None,
|
||||||
|
seq_start: None,
|
||||||
|
separator: None,
|
||||||
|
reset_cycle: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_numbering_rule_req_code_too_long_fails() {
|
||||||
|
let req = CreateNumberingRuleReq {
|
||||||
|
name: "订单编号".to_string(),
|
||||||
|
code: "x".repeat(51),
|
||||||
|
prefix: None,
|
||||||
|
date_format: None,
|
||||||
|
seq_length: None,
|
||||||
|
seq_start: None,
|
||||||
|
separator: None,
|
||||||
|
reset_cycle: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_numbering_rule_req_max_boundary_ok() {
|
||||||
|
let req = CreateNumberingRuleReq {
|
||||||
|
name: "x".repeat(100),
|
||||||
|
code: "x".repeat(50),
|
||||||
|
prefix: None,
|
||||||
|
date_format: None,
|
||||||
|
seq_length: None,
|
||||||
|
seq_start: None,
|
||||||
|
separator: None,
|
||||||
|
reset_cycle: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UpdateSettingReq 验证 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_setting_req_valid() {
|
||||||
|
let req = UpdateSettingReq {
|
||||||
|
setting_value: serde_json::json!({"key": "value"}),
|
||||||
|
version: Some(1),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_setting_req_without_version_ok() {
|
||||||
|
let req = UpdateSettingReq {
|
||||||
|
setting_value: serde_json::json!("hello"),
|
||||||
|
version: None,
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
35
crates/erp-config/src/entity/dictionary.rs
Normal file
35
crates/erp-config/src/entity/dictionary.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "dictionaries")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::dictionary_item::Entity")]
|
||||||
|
DictionaryItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::dictionary_item::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::DictionaryItem.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
42
crates/erp-config/src/entity/dictionary_item.rs
Normal file
42
crates/erp-config/src/entity/dictionary_item.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "dictionary_items")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub dictionary_id: Uuid,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::dictionary::Entity",
|
||||||
|
from = "Column::DictionaryId",
|
||||||
|
to = "super::dictionary::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Dictionary,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::dictionary::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Dictionary.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
43
crates/erp-config/src/entity/menu.rs
Normal file
43
crates/erp-config/src/entity/menu.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "menus")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub visible: bool,
|
||||||
|
pub menu_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::menu_role::Entity")]
|
||||||
|
MenuRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::menu_role::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MenuRole.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
38
crates/erp-config/src/entity/menu_role.rs
Normal file
38
crates/erp-config/src/entity/menu_role.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "menu_roles")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub menu_id: Uuid,
|
||||||
|
pub role_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::menu::Entity",
|
||||||
|
from = "Column::MenuId",
|
||||||
|
to = "super::menu::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::menu::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Menu.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
6
crates/erp-config/src/entity/mod.rs
Normal file
6
crates/erp-config/src/entity/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod dictionary;
|
||||||
|
pub mod dictionary_item;
|
||||||
|
pub mod menu;
|
||||||
|
pub mod menu_role;
|
||||||
|
pub mod numbering_rule;
|
||||||
|
pub mod setting;
|
||||||
34
crates/erp-config/src/entity/numbering_rule.rs
Normal file
34
crates/erp-config/src/entity/numbering_rule.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "numbering_rules")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
pub prefix: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: i32,
|
||||||
|
pub seq_start: i32,
|
||||||
|
pub seq_current: i64,
|
||||||
|
pub separator: String,
|
||||||
|
pub reset_cycle: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_reset_date: Option<chrono::NaiveDate>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
27
crates/erp-config/src/entity/setting.rs
Normal file
27
crates/erp-config/src/entity/setting.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub scope: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
pub setting_key: String,
|
||||||
|
pub setting_value: serde_json::Value,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
143
crates/erp-config/src/error.rs
Normal file
143
crates/erp-config/src/error.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use erp_core::error::AppError;
|
||||||
|
|
||||||
|
/// Config module error types.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("验证失败: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("资源未找到: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("键已存在: {0}")]
|
||||||
|
DuplicateKey(String),
|
||||||
|
|
||||||
|
#[error("编号序列耗尽: {0}")]
|
||||||
|
NumberingExhausted(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
|
||||||
|
fn from(err: sea_orm::TransactionError<ConfigError>) -> Self {
|
||||||
|
match err {
|
||||||
|
sea_orm::TransactionError::Connection(err) => ConfigError::Validation(err.to_string()),
|
||||||
|
sea_orm::TransactionError::Transaction(inner) => inner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::DbErr> for ConfigError {
|
||||||
|
fn from(err: sea_orm::DbErr) -> Self {
|
||||||
|
ConfigError::Validation(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConfigError> for AppError {
|
||||||
|
fn from(err: ConfigError) -> Self {
|
||||||
|
match err {
|
||||||
|
ConfigError::Validation(s) => AppError::Validation(s),
|
||||||
|
ConfigError::NotFound(s) => AppError::NotFound(s),
|
||||||
|
ConfigError::DuplicateKey(s) => AppError::Conflict(s),
|
||||||
|
ConfigError::NumberingExhausted(s) => AppError::Internal(s),
|
||||||
|
ConfigError::VersionMismatch => AppError::VersionMismatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ConfigResult<T> = Result<T, ConfigError>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_error_validation_maps_to_app_validation() {
|
||||||
|
let app: AppError = ConfigError::Validation("字段不能为空".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::Validation(msg) => assert_eq!(msg, "字段不能为空"),
|
||||||
|
other => panic!("期望 Validation,实际得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_error_not_found_maps_to_app_not_found() {
|
||||||
|
let app: AppError = ConfigError::NotFound("字典不存在".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::NotFound(msg) => assert_eq!(msg, "字典不存在"),
|
||||||
|
other => panic!("期望 NotFound,实际得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_error_duplicate_key_maps_to_app_conflict() {
|
||||||
|
let app: AppError = ConfigError::DuplicateKey("编码已存在".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::Conflict(msg) => assert_eq!(msg, "编码已存在"),
|
||||||
|
other => panic!("期望 Conflict,实际得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_error_numbering_exhausted_maps_to_app_internal() {
|
||||||
|
let app: AppError = ConfigError::NumberingExhausted("序列已耗尽".to_string()).into();
|
||||||
|
match app {
|
||||||
|
AppError::Internal(msg) => assert!(msg.contains("序列已耗尽")),
|
||||||
|
other => panic!("期望 Internal,实际得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_error_version_mismatch_maps_to_app_version_mismatch() {
|
||||||
|
let app: AppError = ConfigError::VersionMismatch.into();
|
||||||
|
match app {
|
||||||
|
AppError::VersionMismatch => {}
|
||||||
|
other => panic!("期望 VersionMismatch,实际得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_error_display_messages() {
|
||||||
|
// 验证各变体的 Display 输出包含中文描述
|
||||||
|
assert!(
|
||||||
|
ConfigError::Validation("test".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("验证失败")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ConfigError::NotFound("test".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("资源未找到")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ConfigError::DuplicateKey("test".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("键已存在")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ConfigError::NumberingExhausted("test".into())
|
||||||
|
.to_string()
|
||||||
|
.contains("编号序列耗尽")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ConfigError::VersionMismatch
|
||||||
|
.to_string()
|
||||||
|
.contains("版本冲突")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_error_connection_maps_to_validation() {
|
||||||
|
// TransactionError::Connection 应该转换为 ConfigError::Validation
|
||||||
|
let config_err: ConfigError = sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn(
|
||||||
|
sea_orm::RuntimeErr::Internal("连接失败".to_string()),
|
||||||
|
))
|
||||||
|
.into();
|
||||||
|
match config_err {
|
||||||
|
ConfigError::Validation(msg) => assert!(msg.contains("连接失败")),
|
||||||
|
other => panic!("期望 Validation,实际得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
360
crates/erp-config/src/handler/dictionary_handler.rs
Normal file
360
crates/erp-config/src/handler/dictionary_handler.rs
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{
|
||||||
|
CreateDictionaryItemReq, CreateDictionaryReq, DictionaryItemResp, DictionaryResp,
|
||||||
|
UpdateDictionaryItemReq, UpdateDictionaryReq,
|
||||||
|
};
|
||||||
|
use crate::service::dictionary_service::DictionaryService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/dictionaries",
|
||||||
|
params(Pagination),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<DictionaryResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/dictionaries
|
||||||
|
///
|
||||||
|
/// 分页查询当前租户下的字典列表。
|
||||||
|
/// 每个字典包含其关联的字典项。
|
||||||
|
/// 需要 `dictionary.list` 权限。
|
||||||
|
pub async fn list_dictionaries<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<DictionaryResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.list")?;
|
||||||
|
|
||||||
|
let (dictionaries, total) =
|
||||||
|
DictionaryService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||||
|
|
||||||
|
let page = pagination.page.unwrap_or(1);
|
||||||
|
let page_size = pagination.limit();
|
||||||
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: dictionaries,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/dictionaries",
|
||||||
|
request_body = CreateDictionaryReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<DictionaryResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/dictionaries
|
||||||
|
///
|
||||||
|
/// 在当前租户下创建新字典。
|
||||||
|
/// 字典编码在租户内必须唯一。
|
||||||
|
/// 需要 `dictionary.create` 权限。
|
||||||
|
pub async fn create_dictionary<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateDictionaryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DictionaryResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let dictionary = DictionaryService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.name,
|
||||||
|
&req.code,
|
||||||
|
&req.description,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(dictionary)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/dictionaries/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "字典ID")),
|
||||||
|
request_body = UpdateDictionaryReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<DictionaryResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/dictionaries/:id
|
||||||
|
///
|
||||||
|
/// 更新字典的可编辑字段(名称、描述)。
|
||||||
|
/// 编码创建后不可更改。
|
||||||
|
/// 需要 `dictionary.update` 权限。
|
||||||
|
pub async fn update_dictionary<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateDictionaryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DictionaryResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.update")?;
|
||||||
|
|
||||||
|
let dictionary =
|
||||||
|
DictionaryService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(dictionary)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/dictionaries/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "字典ID")),
|
||||||
|
request_body = DeleteVersionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/dictionaries/:id
|
||||||
|
///
|
||||||
|
/// 软删除字典,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
|
/// 需要 `dictionary.delete` 权限。
|
||||||
|
pub async fn delete_dictionary<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<DeleteVersionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.delete")?;
|
||||||
|
|
||||||
|
DictionaryService::delete(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("字典已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/dictionaries/items-by-code",
|
||||||
|
params(("code" = String, Query, description = "字典编码")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<DictionaryItemResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/dictionaries/items-by-code?code=xxx
|
||||||
|
///
|
||||||
|
/// 根据字典编码查询所有字典项。
|
||||||
|
/// 用于前端下拉框和枚举值查找。
|
||||||
|
/// 需要 `dictionary.list` 权限。
|
||||||
|
pub async fn list_items_by_code<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(query): Query<ItemsByCodeQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<DictionaryItemResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.list")?;
|
||||||
|
|
||||||
|
let items =
|
||||||
|
DictionaryService::list_items_by_code(&query.code, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/dictionaries/{dict_id}/items",
|
||||||
|
params(("dict_id" = Uuid, Path, description = "字典ID")),
|
||||||
|
request_body = CreateDictionaryItemReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<DictionaryItemResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/dictionaries/:dict_id/items
|
||||||
|
///
|
||||||
|
/// 向指定字典添加新的字典项。
|
||||||
|
/// 字典项的 value 在同一字典内必须唯一。
|
||||||
|
/// 需要 `dictionary.create` 权限。
|
||||||
|
pub async fn create_item<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(dict_id): Path<Uuid>,
|
||||||
|
Json(req): Json<CreateDictionaryItemReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DictionaryItemResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let item =
|
||||||
|
DictionaryService::add_item(dict_id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/dictionaries/{dict_id}/items/{item_id}",
|
||||||
|
params(
|
||||||
|
("dict_id" = Uuid, Path, description = "字典ID"),
|
||||||
|
("item_id" = Uuid, Path, description = "字典项ID"),
|
||||||
|
),
|
||||||
|
request_body = UpdateDictionaryItemReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<DictionaryItemResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/dictionaries/:dict_id/items/:item_id
|
||||||
|
///
|
||||||
|
/// 更新字典项的可编辑字段(label、value、sort_order、color)。
|
||||||
|
/// 需要 `dictionary.update` 权限。
|
||||||
|
pub async fn update_item<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path((dict_id, item_id)): Path<(Uuid, Uuid)>,
|
||||||
|
Json(req): Json<UpdateDictionaryItemReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DictionaryItemResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.update")?;
|
||||||
|
|
||||||
|
// 验证 item_id 属于 dict_id
|
||||||
|
let item = DictionaryService::update_item(item_id, ctx.tenant_id, ctx.user_id, &req, &state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 确保 item 属于指定的 dictionary
|
||||||
|
if item.dictionary_id != dict_id {
|
||||||
|
return Err(AppError::Validation("字典项不属于指定的字典".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/dictionaries/{dict_id}/items/{item_id}",
|
||||||
|
params(
|
||||||
|
("dict_id" = Uuid, Path, description = "字典ID"),
|
||||||
|
("item_id" = Uuid, Path, description = "字典项ID"),
|
||||||
|
),
|
||||||
|
request_body = DeleteVersionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "字典管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/dictionaries/:dict_id/items/:item_id
|
||||||
|
///
|
||||||
|
/// 软删除字典项,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
|
/// 需要 `dictionary.delete` 权限。
|
||||||
|
pub async fn delete_item<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path((_dict_id, item_id)): Path<(Uuid, Uuid)>,
|
||||||
|
Json(req): Json<DeleteVersionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.delete")?;
|
||||||
|
|
||||||
|
DictionaryService::delete_item(item_id, ctx.tenant_id, ctx.user_id, req.version, &state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("字典项已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按编码查询字典项的查询参数。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct ItemsByCodeQuery {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除操作的乐观锁版本号。
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
142
crates/erp-config/src/handler/language_handler.rs
Normal file
142
crates/erp-config/src/handler/language_handler.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, Path, State};
|
||||||
|
use axum::response::Json as JsonResponse;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, Pagination, TenantContext};
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{LanguageResp, SetSettingParams, UpdateLanguageReq};
|
||||||
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/languages",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<LanguageResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "语言管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/languages
|
||||||
|
///
|
||||||
|
/// 获取当前租户的语言配置列表。
|
||||||
|
/// 查询 scope 为 "platform" 的设置,过滤 key 以 "language." 开头的记录。
|
||||||
|
/// 需要 `language.list` 权限。
|
||||||
|
pub async fn list_languages<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<Vec<LanguageResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "language.list")?;
|
||||||
|
|
||||||
|
let pagination = Pagination {
|
||||||
|
page: Some(1),
|
||||||
|
page_size: Some(100),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (settings, _total) =
|
||||||
|
SettingService::list_by_scope("platform", &None, ctx.tenant_id, &pagination, &state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let languages: Vec<LanguageResp> = settings
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.setting_key.starts_with("language."))
|
||||||
|
.filter_map(|s| {
|
||||||
|
let code = s.setting_key.strip_prefix("language.")?.to_string();
|
||||||
|
let name = s
|
||||||
|
.setting_value
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&code)
|
||||||
|
.to_string();
|
||||||
|
let is_active = s
|
||||||
|
.setting_value
|
||||||
|
.get("is_active")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
Some(LanguageResp {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
is_active,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(languages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/languages/{code}",
|
||||||
|
params(("code" = String, Path, description = "语言编码")),
|
||||||
|
request_body = UpdateLanguageReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<LanguageResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "语言管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/languages/:code
|
||||||
|
///
|
||||||
|
/// 更新指定语言配置的激活状态。
|
||||||
|
/// 语言配置存储在 settings 表中,key 为 "language.{code}",scope 为 "platform"。
|
||||||
|
/// 需要 `language.update` 权限。
|
||||||
|
pub async fn update_language<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
Json(req): Json<UpdateLanguageReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<LanguageResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "language.update")?;
|
||||||
|
|
||||||
|
let key = format!("language.{}", code);
|
||||||
|
let mut value = serde_json::json!({"is_active": req.is_active});
|
||||||
|
if let Some(ref name) = req.name {
|
||||||
|
value["name"] = serde_json::Value::String(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingService::set(
|
||||||
|
SetSettingParams {
|
||||||
|
key: key.clone(),
|
||||||
|
scope: "platform".to_string(),
|
||||||
|
scope_id: None,
|
||||||
|
value,
|
||||||
|
version: None,
|
||||||
|
},
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 从返回的 SettingResp 中读取实际值
|
||||||
|
let updated = SettingService::get(&key, "platform", &None, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
// 尝试从 value 中提取 name,否则用 code 作为默认名称
|
||||||
|
let name = updated
|
||||||
|
.setting_value
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&code)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(LanguageResp {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
is_active: req.is_active,
|
||||||
|
})))
|
||||||
|
}
|
||||||
263
crates/erp-config/src/handler/menu_handler.rs
Normal file
263
crates/erp-config/src/handler/menu_handler.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, Path, State};
|
||||||
|
use axum::response::Json as JsonResponse;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{BatchSaveMenusReq, CreateMenuReq, MenuResp, UpdateMenuReq};
|
||||||
|
use crate::service::menu_service::MenuService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/config/menus",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<MenuResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "菜单管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/config/menus
|
||||||
|
///
|
||||||
|
/// 获取当前租户下当前用户角色可见的菜单树。
|
||||||
|
pub async fn get_menus<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<Vec<MenuResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.list")?;
|
||||||
|
|
||||||
|
let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(menus)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/config/menus",
|
||||||
|
request_body = CreateMenuReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<MenuResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "菜单管理"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/config/menus
|
||||||
|
///
|
||||||
|
/// 创建单个菜单项。
|
||||||
|
pub async fn create_menu<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateMenuReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<MenuResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.update")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resp = MenuService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/config/menus/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "菜单ID")),
|
||||||
|
request_body = UpdateMenuReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<MenuResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "菜单管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/config/menus/{id}
|
||||||
|
///
|
||||||
|
/// 更新单个菜单项。
|
||||||
|
pub async fn update_menu<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateMenuReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<MenuResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.update")?;
|
||||||
|
|
||||||
|
let resp = MenuService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/config/menus/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "菜单ID")),
|
||||||
|
request_body = DeleteMenuVersionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "菜单管理"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/config/menus/{id}
|
||||||
|
///
|
||||||
|
/// 软删除单个菜单项。需要请求体包含 version 字段用于乐观锁校验。
|
||||||
|
pub async fn delete_menu<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<DeleteMenuVersionReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.update")?;
|
||||||
|
|
||||||
|
MenuService::delete(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/config/menus/batch",
|
||||||
|
request_body = BatchSaveMenusReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "菜单管理"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/config/menus/batch
|
||||||
|
///
|
||||||
|
/// 批量保存菜单列表。
|
||||||
|
pub async fn batch_save_menus<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<BatchSaveMenusReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.update")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
for item in &req.menus {
|
||||||
|
match item.id {
|
||||||
|
Some(id) => {
|
||||||
|
let version = item.version.unwrap_or(0);
|
||||||
|
let update_req = UpdateMenuReq {
|
||||||
|
title: Some(item.title.clone()),
|
||||||
|
path: item.path.clone(),
|
||||||
|
icon: item.icon.clone(),
|
||||||
|
sort_order: item.sort_order,
|
||||||
|
visible: item.visible,
|
||||||
|
permission: item.permission.clone(),
|
||||||
|
role_ids: item.role_ids.clone(),
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db).await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let create_req = CreateMenuReq {
|
||||||
|
parent_id: item.parent_id,
|
||||||
|
title: item.title.clone(),
|
||||||
|
path: item.path.clone(),
|
||||||
|
icon: item.icon.clone(),
|
||||||
|
sort_order: item.sort_order,
|
||||||
|
visible: item.visible,
|
||||||
|
menu_type: item.menu_type.clone(),
|
||||||
|
permission: item.permission.clone(),
|
||||||
|
role_ids: item.role_ids.clone(),
|
||||||
|
};
|
||||||
|
MenuService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&create_req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("菜单批量保存成功".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/menus/user",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<Vec<MenuResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "菜单管理"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/menus/user
|
||||||
|
///
|
||||||
|
/// 获取当前用户可见的菜单树(无需 menu.list 权限,仅需登录)。
|
||||||
|
pub async fn get_user_menus<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<Vec<MenuResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(menus)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除菜单的乐观锁版本号请求体。
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteMenuVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
6
crates/erp-config/src/handler/mod.rs
Normal file
6
crates/erp-config/src/handler/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod dictionary_handler;
|
||||||
|
pub mod language_handler;
|
||||||
|
pub mod menu_handler;
|
||||||
|
pub mod numbering_handler;
|
||||||
|
pub mod setting_handler;
|
||||||
|
pub mod theme_handler;
|
||||||
220
crates/erp-config/src/handler/numbering_handler.rs
Normal file
220
crates/erp-config/src/handler/numbering_handler.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{
|
||||||
|
CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp, UpdateNumberingRuleReq,
|
||||||
|
};
|
||||||
|
use crate::service::numbering_service::NumberingService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/numbering-rules",
|
||||||
|
params(Pagination),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<NumberingRuleResp>>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "编号规则"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/numbering-rules
|
||||||
|
///
|
||||||
|
/// 分页查询当前租户下的编号规则列表。
|
||||||
|
/// 需要 `numbering.list` 权限。
|
||||||
|
pub async fn list_numbering_rules<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<NumberingRuleResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.list")?;
|
||||||
|
|
||||||
|
let (rules, total) = NumberingService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||||
|
|
||||||
|
let page = pagination.page.unwrap_or(1);
|
||||||
|
let page_size = pagination.limit();
|
||||||
|
let total_pages = total.div_ceil(page_size);
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: rules,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/numbering-rules",
|
||||||
|
request_body = CreateNumberingRuleReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<NumberingRuleResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "编号规则"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/numbering-rules
|
||||||
|
///
|
||||||
|
/// 创建新的编号规则。
|
||||||
|
/// 规则编码在租户内必须唯一。
|
||||||
|
/// 需要 `numbering.create` 权限。
|
||||||
|
pub async fn create_numbering_rule<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateNumberingRuleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<NumberingRuleResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let rule = NumberingService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(rule)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/numbering-rules/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "编号规则ID")),
|
||||||
|
request_body = UpdateNumberingRuleReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<NumberingRuleResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "编号规则"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/numbering-rules/:id
|
||||||
|
///
|
||||||
|
/// 更新编号规则的可编辑字段。
|
||||||
|
/// 需要 `numbering.update` 权限。
|
||||||
|
pub async fn update_numbering_rule<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateNumberingRuleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<NumberingRuleResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.update")?;
|
||||||
|
|
||||||
|
let rule = NumberingService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(rule)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/numbering-rules/{id}/generate",
|
||||||
|
params(("id" = Uuid, Path, description = "编号规则ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<GenerateNumberResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "编号规则"
|
||||||
|
)]
|
||||||
|
/// POST /api/v1/numbering-rules/:id/generate
|
||||||
|
///
|
||||||
|
/// 根据编号规则生成新的编号。
|
||||||
|
/// 使用 PostgreSQL advisory lock 保证并发安全。
|
||||||
|
/// 需要 `numbering.generate` 权限。
|
||||||
|
pub async fn generate_number<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<GenerateNumberResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.generate")?;
|
||||||
|
|
||||||
|
let result = NumberingService::generate_number(id, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/numbering-rules/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "编号规则ID")),
|
||||||
|
request_body = DeleteNumberingVersionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "编号规则"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/numbering-rules/:id
|
||||||
|
///
|
||||||
|
/// 软删除编号规则,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
|
/// 需要 `numbering.delete` 权限。
|
||||||
|
pub async fn delete_numbering_rule<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<DeleteNumberingVersionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.delete")?;
|
||||||
|
|
||||||
|
NumberingService::delete(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("编号规则已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除编号规则的乐观锁版本号请求体。
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteNumberingVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
169
crates/erp-config/src/handler/setting_handler.rs
Normal file
169
crates/erp-config/src/handler/setting_handler.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{SetSettingParams, SettingResp, UpdateSettingReq};
|
||||||
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/settings/{key}",
|
||||||
|
params(
|
||||||
|
("key" = String, Path, description = "设置键名"),
|
||||||
|
("scope" = Option<String>, Query, description = "作用域"),
|
||||||
|
("scope_id" = Option<Uuid>, Query, description = "作用域ID"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<SettingResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "系统设置"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx
|
||||||
|
///
|
||||||
|
/// 获取设置值,支持分层回退查找。
|
||||||
|
/// 解析顺序:精确匹配 -> 按作用域层级向上回退。
|
||||||
|
/// 需要 `setting.read` 权限。
|
||||||
|
pub async fn get_setting<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Query(query): Query<SettingQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<SettingResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "setting.read")?;
|
||||||
|
|
||||||
|
let scope = query.scope.unwrap_or_else(|| "tenant".to_string());
|
||||||
|
|
||||||
|
let setting =
|
||||||
|
SettingService::get(&key, &scope, &query.scope_id, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(setting)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/settings/{key}",
|
||||||
|
params(("key" = String, Path, description = "设置键名")),
|
||||||
|
request_body = UpdateSettingReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<SettingResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "系统设置"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/settings/:key
|
||||||
|
///
|
||||||
|
/// 创建或更新设置值。
|
||||||
|
/// 如果相同 (scope, scope_id, key) 的记录存在则更新,否则插入。
|
||||||
|
/// 需要 `setting.update` 权限。
|
||||||
|
pub async fn update_setting<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Json(req): Json<UpdateSettingReq>,
|
||||||
|
) -> Result<Json<ApiResponse<SettingResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "setting.update")?;
|
||||||
|
|
||||||
|
let setting = SettingService::set(
|
||||||
|
SetSettingParams {
|
||||||
|
key,
|
||||||
|
scope: "tenant".to_string(),
|
||||||
|
scope_id: None,
|
||||||
|
value: req.setting_value,
|
||||||
|
version: req.version,
|
||||||
|
},
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(setting)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置查询参数。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct SettingQuery {
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/v1/settings/{key}",
|
||||||
|
params(
|
||||||
|
("key" = String, Path, description = "设置键名"),
|
||||||
|
("scope" = Option<String>, Query, description = "作用域"),
|
||||||
|
("scope_id" = Option<Uuid>, Query, description = "作用域ID"),
|
||||||
|
),
|
||||||
|
request_body = DeleteSettingVersionReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功"),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "系统设置"
|
||||||
|
)]
|
||||||
|
/// DELETE /api/v1/settings/:key
|
||||||
|
///
|
||||||
|
/// 软删除设置值,设置 deleted_at 时间戳。
|
||||||
|
/// 需要请求体包含 version 字段用于乐观锁校验。
|
||||||
|
/// 需要 `setting.delete` 权限。
|
||||||
|
pub async fn delete_setting<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Query(query): Query<SettingQuery>,
|
||||||
|
Json(req): Json<DeleteSettingVersionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "setting.delete")?;
|
||||||
|
|
||||||
|
let scope = query.scope.unwrap_or_else(|| "tenant".to_string());
|
||||||
|
|
||||||
|
SettingService::delete(
|
||||||
|
&key,
|
||||||
|
&scope,
|
||||||
|
&query.scope_id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
req.version,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("设置已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除设置的乐观锁版本号请求体。
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteSettingVersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
176
crates/erp-config/src/handler/theme_handler.rs
Normal file
176
crates/erp-config/src/handler/theme_handler.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, State};
|
||||||
|
use axum::response::Json as JsonResponse;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{PublicBrandResp, SetSettingParams, ThemeResp};
|
||||||
|
use crate::error::ConfigError;
|
||||||
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
|
/// 默认主题配置。
|
||||||
|
fn default_theme() -> ThemeResp {
|
||||||
|
ThemeResp {
|
||||||
|
primary_color: None,
|
||||||
|
logo_url: None,
|
||||||
|
sidebar_style: None,
|
||||||
|
brand_name: Some("HMS 健康管理平台".into()),
|
||||||
|
brand_slogan: Some("新一代健康管理平台".into()),
|
||||||
|
brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||||||
|
brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/themes",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<ThemeResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "主题设置"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/theme
|
||||||
|
///
|
||||||
|
/// 获取当前租户的主题配置。
|
||||||
|
/// 主题配置存储在 settings 表中,key 为 "theme",scope 为 "tenant"。
|
||||||
|
/// 当没有任何主题配置时,返回默认主题(所有字段为 null)。
|
||||||
|
/// 需要 `theme.read` 权限。
|
||||||
|
pub async fn get_theme<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "theme.read")?;
|
||||||
|
|
||||||
|
let theme = match SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await
|
||||||
|
{
|
||||||
|
Ok(setting) => serde_json::from_value(setting.setting_value)
|
||||||
|
.map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?,
|
||||||
|
Err(ConfigError::NotFound(_)) => default_theme(),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(theme)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/v1/themes",
|
||||||
|
request_body = ThemeResp,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<ThemeResp>),
|
||||||
|
(status = 401, description = "未授权"),
|
||||||
|
(status = 403, description = "权限不足"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "主题设置"
|
||||||
|
)]
|
||||||
|
/// PUT /api/v1/theme
|
||||||
|
///
|
||||||
|
/// 更新当前租户的主题配置。
|
||||||
|
/// 将主题配置序列化为 JSON 存储到 settings 表。
|
||||||
|
/// 需要 `theme.update` 权限。
|
||||||
|
pub async fn update_theme<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<ThemeResp>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "theme.update")?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&req)
|
||||||
|
.map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?;
|
||||||
|
|
||||||
|
SettingService::set(
|
||||||
|
SetSettingParams {
|
||||||
|
key: "theme".to_string(),
|
||||||
|
scope: "tenant".to_string(),
|
||||||
|
scope_id: None,
|
||||||
|
value,
|
||||||
|
version: None,
|
||||||
|
},
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(req)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/public/brand",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "成功", body = ApiResponse<PublicBrandResp>),
|
||||||
|
),
|
||||||
|
tag = "主题设置"
|
||||||
|
)]
|
||||||
|
/// GET /api/v1/public/brand
|
||||||
|
///
|
||||||
|
/// 获取公开品牌信息(无需认证)。
|
||||||
|
pub async fn get_public_brand() -> JsonResponse<ApiResponse<PublicBrandResp>> {
|
||||||
|
let defaults = default_theme();
|
||||||
|
JsonResponse(ApiResponse::ok(PublicBrandResp {
|
||||||
|
brand_name: defaults
|
||||||
|
.brand_name
|
||||||
|
.unwrap_or_else(|| "HMS 健康管理平台".into()),
|
||||||
|
brand_slogan: defaults
|
||||||
|
.brand_slogan
|
||||||
|
.unwrap_or_else(|| "新一代健康管理平台".into()),
|
||||||
|
brand_features: defaults
|
||||||
|
.brand_features
|
||||||
|
.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()),
|
||||||
|
brand_copyright: defaults
|
||||||
|
.brand_copyright
|
||||||
|
.unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_theme_has_brand_defaults() {
|
||||||
|
let theme = default_theme();
|
||||||
|
assert!(theme.primary_color.is_none());
|
||||||
|
assert!(theme.logo_url.is_none());
|
||||||
|
assert!(theme.sidebar_style.is_none());
|
||||||
|
assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string()));
|
||||||
|
assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string()));
|
||||||
|
assert!(theme.brand_features.is_some());
|
||||||
|
assert!(theme.brand_copyright.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_resp_serde_roundtrip() {
|
||||||
|
let theme = ThemeResp {
|
||||||
|
primary_color: Some("#1890ff".to_string()),
|
||||||
|
logo_url: None,
|
||||||
|
sidebar_style: Some("dark".to_string()),
|
||||||
|
brand_name: Some("测试平台".to_string()),
|
||||||
|
brand_slogan: None,
|
||||||
|
brand_features: None,
|
||||||
|
brand_copyright: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&theme).unwrap();
|
||||||
|
let back: ThemeResp = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.primary_color, Some("#1890ff".to_string()));
|
||||||
|
assert_eq!(back.brand_name, Some("测试平台".to_string()));
|
||||||
|
assert!(back.brand_slogan.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/erp-config/src/lib.rs
Normal file
10
crates/erp-config/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod config_state;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod entity;
|
||||||
|
pub mod error;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod module;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use config_state::ConfigState;
|
||||||
|
pub use module::ConfigModule;
|
||||||
267
crates/erp-config/src/module.rs
Normal file
267
crates/erp-config/src/module.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
use axum::Router;
|
||||||
|
use axum::routing::{get, post, put};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::error::AppResult;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||||
|
|
||||||
|
use crate::handler::{
|
||||||
|
dictionary_handler, language_handler, menu_handler, numbering_handler, setting_handler,
|
||||||
|
theme_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Config module implementing the `ErpModule` trait.
|
||||||
|
///
|
||||||
|
/// Manages system configuration: dictionaries, menus, settings,
|
||||||
|
/// numbering rules, languages, and themes.
|
||||||
|
pub struct ConfigModule;
|
||||||
|
|
||||||
|
impl ConfigModule {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build protected (authenticated) routes for the config module.
|
||||||
|
pub fn protected_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::config_state::ConfigState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new()
|
||||||
|
// Dictionary routes
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries",
|
||||||
|
get(dictionary_handler::list_dictionaries)
|
||||||
|
.post(dictionary_handler::create_dictionary),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries/{id}",
|
||||||
|
put(dictionary_handler::update_dictionary)
|
||||||
|
.delete(dictionary_handler::delete_dictionary),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries/items",
|
||||||
|
get(dictionary_handler::list_items_by_code),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries/{dict_id}/items",
|
||||||
|
post(dictionary_handler::create_item),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries/{dict_id}/items/{item_id}",
|
||||||
|
put(dictionary_handler::update_item).delete(dictionary_handler::delete_item),
|
||||||
|
)
|
||||||
|
// Menu routes
|
||||||
|
.route(
|
||||||
|
"/config/menus",
|
||||||
|
get(menu_handler::get_menus)
|
||||||
|
.post(menu_handler::create_menu)
|
||||||
|
.put(menu_handler::batch_save_menus),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/menus/{id}",
|
||||||
|
put(menu_handler::update_menu).delete(menu_handler::delete_menu),
|
||||||
|
)
|
||||||
|
// User menu tree (no special permission required)
|
||||||
|
.route("/menus/user", get(menu_handler::get_user_menus))
|
||||||
|
// Setting routes
|
||||||
|
.route(
|
||||||
|
"/config/settings/{key}",
|
||||||
|
get(setting_handler::get_setting)
|
||||||
|
.put(setting_handler::update_setting)
|
||||||
|
.delete(setting_handler::delete_setting),
|
||||||
|
)
|
||||||
|
// Numbering rule routes
|
||||||
|
.route(
|
||||||
|
"/config/numbering-rules",
|
||||||
|
get(numbering_handler::list_numbering_rules)
|
||||||
|
.post(numbering_handler::create_numbering_rule),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/numbering-rules/{id}",
|
||||||
|
put(numbering_handler::update_numbering_rule)
|
||||||
|
.delete(numbering_handler::delete_numbering_rule),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/numbering-rules/{id}/generate",
|
||||||
|
post(numbering_handler::generate_number),
|
||||||
|
)
|
||||||
|
// Theme routes
|
||||||
|
.route(
|
||||||
|
"/config/themes",
|
||||||
|
get(theme_handler::get_theme).put(theme_handler::update_theme),
|
||||||
|
)
|
||||||
|
// Language routes
|
||||||
|
.route("/config/languages", get(language_handler::list_languages))
|
||||||
|
.route(
|
||||||
|
"/config/languages/{code}",
|
||||||
|
put(language_handler::update_language),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build public (unauthenticated) routes for the config module.
|
||||||
|
pub fn public_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new().route("/public/brand", get(theme_handler::get_public_brand))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigModule {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ErpModule for ConfigModule {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"config"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version(&self) -> &str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<&str> {
|
||||||
|
vec!["auth"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||||
|
|
||||||
|
async fn on_tenant_created(
|
||||||
|
&self,
|
||||||
|
_tenant_id: Uuid,
|
||||||
|
_db: &sea_orm::DatabaseConnection,
|
||||||
|
_event_bus: &EventBus,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_tenant_deleted(
|
||||||
|
&self,
|
||||||
|
_tenant_id: Uuid,
|
||||||
|
_db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||||
|
vec![
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "dictionary.list".into(),
|
||||||
|
name: "查看字典".into(),
|
||||||
|
description: "查看数据字典".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "dictionary.create".into(),
|
||||||
|
name: "创建字典".into(),
|
||||||
|
description: "创建数据字典".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "dictionary.update".into(),
|
||||||
|
name: "编辑字典".into(),
|
||||||
|
description: "编辑数据字典".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "dictionary.delete".into(),
|
||||||
|
name: "删除字典".into(),
|
||||||
|
description: "删除数据字典".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "menu.list".into(),
|
||||||
|
name: "查看菜单".into(),
|
||||||
|
description: "查看菜单配置".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "menu.update".into(),
|
||||||
|
name: "编辑菜单".into(),
|
||||||
|
description: "编辑菜单配置".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "setting.read".into(),
|
||||||
|
name: "查看配置".into(),
|
||||||
|
description: "查看系统参数".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "setting.update".into(),
|
||||||
|
name: "编辑配置".into(),
|
||||||
|
description: "编辑系统参数".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "setting.delete".into(),
|
||||||
|
name: "删除配置".into(),
|
||||||
|
description: "删除系统参数".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "numbering.list".into(),
|
||||||
|
name: "查看编号规则".into(),
|
||||||
|
description: "查看编号规则".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "numbering.create".into(),
|
||||||
|
name: "创建编号规则".into(),
|
||||||
|
description: "创建编号规则".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "numbering.update".into(),
|
||||||
|
name: "编辑编号规则".into(),
|
||||||
|
description: "编辑编号规则".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "numbering.delete".into(),
|
||||||
|
name: "删除编号规则".into(),
|
||||||
|
description: "删除编号规则".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "numbering.generate".into(),
|
||||||
|
name: "生成编号".into(),
|
||||||
|
description: "生成文档编号".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "theme.read".into(),
|
||||||
|
name: "查看主题".into(),
|
||||||
|
description: "查看主题设置".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "theme.update".into(),
|
||||||
|
name: "编辑主题".into(),
|
||||||
|
description: "编辑主题设置".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "language.list".into(),
|
||||||
|
name: "查看语言".into(),
|
||||||
|
description: "查看语言配置".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "language.update".into(),
|
||||||
|
name: "编辑语言".into(),
|
||||||
|
description: "编辑语言设置".into(),
|
||||||
|
module: "config".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn std::any::Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
628
crates/erp-config/src/service/dictionary_service.rs
Normal file
628
crates/erp-config/src/service/dictionary_service.rs
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{DictionaryItemResp, DictionaryResp};
|
||||||
|
use crate::entity::{dictionary, dictionary_item};
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// Dictionary CRUD service — manage dictionaries and their items within a tenant.
|
||||||
|
///
|
||||||
|
/// Dictionaries provide enumerated value sets (e.g. status codes, categories)
|
||||||
|
/// that can be referenced throughout the system by their unique `code`.
|
||||||
|
pub struct DictionaryService;
|
||||||
|
|
||||||
|
impl DictionaryService {
|
||||||
|
/// List dictionaries within a tenant with pagination.
|
||||||
|
///
|
||||||
|
/// Each dictionary includes its associated items.
|
||||||
|
/// Returns `(dictionaries, total_count)`.
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<(Vec<DictionaryResp>, u64)> {
|
||||||
|
let paginator = dictionary::Entity::find()
|
||||||
|
.filter(dictionary::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut resps = Vec::with_capacity(models.len());
|
||||||
|
for m in &models {
|
||||||
|
let items = Self::fetch_items(m.id, tenant_id, db).await?;
|
||||||
|
resps.push(dict_model_to_resp(m, items));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single dictionary by ID, scoped to the given tenant.
|
||||||
|
///
|
||||||
|
/// Includes all associated items.
|
||||||
|
pub async fn get_by_id(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryResp> {
|
||||||
|
let model = dictionary::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
let items = Self::fetch_items(model.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
Ok(dict_model_to_resp(&model, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new dictionary within the current tenant.
|
||||||
|
///
|
||||||
|
/// Validates code uniqueness, then inserts the record and publishes
|
||||||
|
/// a `dictionary.created` domain event.
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
code: &str,
|
||||||
|
description: &Option<String>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<DictionaryResp> {
|
||||||
|
// Check code uniqueness within tenant
|
||||||
|
let existing = dictionary::Entity::find()
|
||||||
|
.filter(dictionary::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary::Column::Code.eq(code))
|
||||||
|
.filter(dictionary::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(ConfigError::Validation("字典编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = dictionary::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
description: Set(description.clone()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"dictionary.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "dictionary_id": id, "code": code }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"dictionary.create",
|
||||||
|
"dictionary",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(DictionaryResp {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
code: code.to_string(),
|
||||||
|
description: description.clone(),
|
||||||
|
items: vec![],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable dictionary fields (name and description).
|
||||||
|
///
|
||||||
|
/// Code cannot be changed after creation.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::UpdateDictionaryReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryResp> {
|
||||||
|
let model = dictionary::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: dictionary::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(n) = &req.name {
|
||||||
|
active.name = Set(n.clone());
|
||||||
|
}
|
||||||
|
if let Some(d) = &req.description {
|
||||||
|
active.description = Set(Some(d.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = Self::fetch_items(updated.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"dictionary.update",
|
||||||
|
"dictionary",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(dict_model_to_resp(&updated, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = dictionary::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: dictionary::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"dictionary.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "dictionary_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"dictionary.delete",
|
||||||
|
"dictionary",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new item to a dictionary.
|
||||||
|
///
|
||||||
|
/// Validates that the item `value` is unique within the dictionary.
|
||||||
|
pub async fn add_item(
|
||||||
|
dictionary_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::CreateDictionaryItemReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryItemResp> {
|
||||||
|
// Verify the dictionary exists and belongs to this tenant
|
||||||
|
let _dict = dictionary::Entity::find_by_id(dictionary_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Check value uniqueness within dictionary
|
||||||
|
let existing = dictionary_item::Entity::find()
|
||||||
|
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
|
||||||
|
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary_item::Column::Value.eq(&req.value))
|
||||||
|
.filter(dictionary_item::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(ConfigError::Validation("字典项值已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let sort_order = req.sort_order.unwrap_or(0);
|
||||||
|
let model = dictionary_item::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
dictionary_id: Set(dictionary_id),
|
||||||
|
label: Set(req.label.clone()),
|
||||||
|
value: Set(req.value.clone()),
|
||||||
|
sort_order: Set(sort_order),
|
||||||
|
color: Set(req.color.clone()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"dictionary_item.create",
|
||||||
|
"dictionary_item",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(DictionaryItemResp {
|
||||||
|
id,
|
||||||
|
dictionary_id,
|
||||||
|
label: req.label.clone(),
|
||||||
|
value: req.value.clone(),
|
||||||
|
sort_order,
|
||||||
|
color: req.color.clone(),
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable dictionary item fields (label, value, sort_order, color).
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
|
pub async fn update_item(
|
||||||
|
item_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::UpdateDictionaryItemReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryItemResp> {
|
||||||
|
let model = dictionary_item::Entity::find_by_id(item_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(l) = &req.label {
|
||||||
|
active.label = Set(l.clone());
|
||||||
|
}
|
||||||
|
if let Some(v) = &req.value {
|
||||||
|
active.value = Set(v.clone());
|
||||||
|
}
|
||||||
|
if let Some(s) = req.sort_order {
|
||||||
|
active.sort_order = Set(s);
|
||||||
|
}
|
||||||
|
if let Some(c) = &req.color {
|
||||||
|
active.color = Set(Some(c.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"dictionary_item.update",
|
||||||
|
"dictionary_item",
|
||||||
|
)
|
||||||
|
.with_resource_id(item_id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(item_model_to_resp(&updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
|
pub async fn delete_item(
|
||||||
|
item_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = dictionary_item::Entity::find_by_id(item_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"dictionary_item.delete",
|
||||||
|
"dictionary_item",
|
||||||
|
)
|
||||||
|
.with_resource_id(item_id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a dictionary by its `code` and return all items.
|
||||||
|
///
|
||||||
|
/// Useful for frontend dropdowns and enum-like lookups.
|
||||||
|
pub async fn list_items_by_code(
|
||||||
|
code: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<DictionaryItemResp>> {
|
||||||
|
let dict = dictionary::Entity::find()
|
||||||
|
.filter(dictionary::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary::Column::Code.eq(code))
|
||||||
|
.filter(dictionary::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("字典编码 '{}' 不存在", code)))?;
|
||||||
|
|
||||||
|
Self::fetch_items(dict.id, tenant_id, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部辅助方法 ----
|
||||||
|
|
||||||
|
/// Fetch all non-deleted items for a given dictionary.
|
||||||
|
async fn fetch_items(
|
||||||
|
dictionary_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<DictionaryItemResp>> {
|
||||||
|
let items = dictionary_item::Entity::find()
|
||||||
|
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
|
||||||
|
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary_item::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items.iter().map(item_model_to_resp).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free function wrapping the private helper so the mapping logic is reusable
|
||||||
|
/// in both async methods and synchronous unit tests without a database.
|
||||||
|
fn item_model_to_resp(m: &dictionary_item::Model) -> DictionaryItemResp {
|
||||||
|
DictionaryItemResp {
|
||||||
|
id: m.id,
|
||||||
|
dictionary_id: m.dictionary_id,
|
||||||
|
label: m.label.clone(),
|
||||||
|
value: m.value.clone(),
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
color: m.color.clone(),
|
||||||
|
version: m.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free function for dictionary model -> response DTO mapping.
|
||||||
|
fn dict_model_to_resp(m: &dictionary::Model, items: Vec<DictionaryItemResp>) -> DictionaryResp {
|
||||||
|
DictionaryResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name.clone(),
|
||||||
|
code: m.code.clone(),
|
||||||
|
description: m.description.clone(),
|
||||||
|
items,
|
||||||
|
version: m.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn sample_dict_model() -> dictionary::Model {
|
||||||
|
dictionary::Model {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
tenant_id: Uuid::now_v7(),
|
||||||
|
name: "测试字典".to_string(),
|
||||||
|
code: "test_dict".to_string(),
|
||||||
|
description: Some("描述".to_string()),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
created_by: Uuid::now_v7(),
|
||||||
|
updated_by: Uuid::now_v7(),
|
||||||
|
deleted_at: None,
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_item_model() -> dictionary_item::Model {
|
||||||
|
dictionary_item::Model {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
tenant_id: Uuid::now_v7(),
|
||||||
|
dictionary_id: Uuid::now_v7(),
|
||||||
|
label: "选项A".to_string(),
|
||||||
|
value: "option_a".to_string(),
|
||||||
|
sort_order: 1,
|
||||||
|
color: Some("#FF0000".to_string()),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
created_by: Uuid::now_v7(),
|
||||||
|
updated_by: Uuid::now_v7(),
|
||||||
|
deleted_at: None,
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dict_model_to_resp ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dict_model_to_resp_with_items() {
|
||||||
|
let m = sample_dict_model();
|
||||||
|
let item = item_model_to_resp(&sample_item_model());
|
||||||
|
let resp = dict_model_to_resp(&m, vec![item]);
|
||||||
|
|
||||||
|
assert_eq!(resp.id, m.id);
|
||||||
|
assert_eq!(resp.name, "测试字典");
|
||||||
|
assert_eq!(resp.code, "test_dict");
|
||||||
|
assert_eq!(resp.description, Some("描述".to_string()));
|
||||||
|
assert_eq!(resp.version, 1);
|
||||||
|
assert_eq!(resp.items.len(), 1);
|
||||||
|
assert_eq!(resp.items[0].label, "选项A");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dict_model_to_resp_without_description() {
|
||||||
|
let mut m = sample_dict_model();
|
||||||
|
m.description = None;
|
||||||
|
let resp = dict_model_to_resp(&m, vec![]);
|
||||||
|
|
||||||
|
assert_eq!(resp.description, None);
|
||||||
|
assert!(resp.items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dict_model_to_resp_preserves_version() {
|
||||||
|
let mut m = sample_dict_model();
|
||||||
|
m.version = 42;
|
||||||
|
let resp = dict_model_to_resp(&m, vec![]);
|
||||||
|
|
||||||
|
assert_eq!(resp.version, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- item_model_to_resp ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_model_to_resp_all_fields() {
|
||||||
|
let m = sample_item_model();
|
||||||
|
let resp = item_model_to_resp(&m);
|
||||||
|
|
||||||
|
assert_eq!(resp.id, m.id);
|
||||||
|
assert_eq!(resp.dictionary_id, m.dictionary_id);
|
||||||
|
assert_eq!(resp.label, "选项A");
|
||||||
|
assert_eq!(resp.value, "option_a");
|
||||||
|
assert_eq!(resp.sort_order, 1);
|
||||||
|
assert_eq!(resp.color, Some("#FF0000".to_string()));
|
||||||
|
assert_eq!(resp.version, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_model_to_resp_without_color() {
|
||||||
|
let mut m = sample_item_model();
|
||||||
|
m.color = None;
|
||||||
|
let resp = item_model_to_resp(&m);
|
||||||
|
|
||||||
|
assert_eq!(resp.color, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_model_to_resp_default_sort_order() {
|
||||||
|
let mut m = sample_item_model();
|
||||||
|
m.sort_order = 0;
|
||||||
|
let resp = item_model_to_resp(&m);
|
||||||
|
|
||||||
|
assert_eq!(resp.sort_order, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_model_to_resp_preserves_version() {
|
||||||
|
let mut m = sample_item_model();
|
||||||
|
m.version = 7;
|
||||||
|
let resp = item_model_to_resp(&m);
|
||||||
|
|
||||||
|
assert_eq!(resp.version, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
600
crates/erp-config/src/service/menu_service.rs
Normal file
600
crates/erp-config/src/service/menu_service.rs
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateMenuReq, MenuResp};
|
||||||
|
use crate::entity::{menu, menu_role};
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
|
||||||
|
/// 以及管理菜单-角色关联。
|
||||||
|
pub struct MenuService;
|
||||||
|
|
||||||
|
impl MenuService {
|
||||||
|
/// 通过角色 code 列表查找对应的角色 ID 列表。
|
||||||
|
async fn resolve_role_ids(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
role_codes: &[String],
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<Uuid>> {
|
||||||
|
if role_codes.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let codes_csv: String = role_codes
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("'{}'", c.replace('\'', "''")))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id FROM roles WHERE tenant_id = '{}' AND code IN ({}) AND deleted_at IS NULL",
|
||||||
|
tenant_id, codes_csv
|
||||||
|
);
|
||||||
|
let stmt = sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql);
|
||||||
|
let rows = db.query_all(stmt).await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
let id: Uuid = row.try_get_by_index(0).ok()?;
|
||||||
|
Some(id)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_menu_tree(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
role_codes: &[String],
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<MenuResp>> {
|
||||||
|
// 0. admin 角色直接返回全部菜单,跳过 menu_roles 过滤
|
||||||
|
if role_codes.iter().any(|c| c == "admin") {
|
||||||
|
let all_menus = menu::Entity::find()
|
||||||
|
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(menu::Column::SortOrder)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
for m in &all_menus {
|
||||||
|
children_map.entry(m.parent_id).or_default().push(m);
|
||||||
|
}
|
||||||
|
let roots = children_map.get(&None).cloned().unwrap_or_default();
|
||||||
|
return Ok(Self::build_tree(&roots, &children_map));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 将角色 code 转换为 UUID
|
||||||
|
let role_ids = Self::resolve_role_ids(tenant_id, role_codes, db).await?;
|
||||||
|
|
||||||
|
// 2. 查询租户下所有未删除的菜单,按 sort_order 排序
|
||||||
|
let all_menus = menu::Entity::find()
|
||||||
|
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(menu::Column::SortOrder)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 3. 通过 menu_roles 表过滤
|
||||||
|
let visible_menu_ids: Option<Vec<Uuid>> = if !role_ids.is_empty() {
|
||||||
|
let mr_rows = menu_role::Entity::find()
|
||||||
|
.filter(menu_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu_role::Column::RoleId.is_in(role_ids.iter().copied()))
|
||||||
|
.filter(menu_role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let ids: Vec<Uuid> = mr_rows.iter().map(|mr| mr.menu_id).collect();
|
||||||
|
if ids.is_empty() {
|
||||||
|
Some(vec![]) // 无菜单关联 = 不显示
|
||||||
|
} else {
|
||||||
|
Some(ids)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(vec![]) // 无角色 = 不显示任何菜单
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 按 parent_id 分组构建 HashMap
|
||||||
|
let filtered: Vec<&menu::Model> = match &visible_menu_ids {
|
||||||
|
Some(ids) => all_menus.iter().filter(|m| ids.contains(&m.id)).collect(),
|
||||||
|
None => all_menus.iter().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
for m in &filtered {
|
||||||
|
children_map.entry(m.parent_id).or_default().push(*m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 递归构建树形结构(从 parent_id == None 的根节点开始)
|
||||||
|
let roots = children_map.get(&None).cloned().unwrap_or_default();
|
||||||
|
let tree = Self::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
Ok(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前租户下所有菜单的平铺列表(无角色过滤)。
|
||||||
|
pub async fn get_flat_list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<MenuResp>> {
|
||||||
|
let menus = menu::Entity::find()
|
||||||
|
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(menu::Column::SortOrder)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(menus
|
||||||
|
.iter()
|
||||||
|
.map(|m| MenuResp {
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id,
|
||||||
|
title: m.title.clone(),
|
||||||
|
path: m.path.clone(),
|
||||||
|
icon: m.icon.clone(),
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
visible: m.visible,
|
||||||
|
menu_type: m.menu_type.clone(),
|
||||||
|
permission: m.permission.clone(),
|
||||||
|
children: vec![],
|
||||||
|
version: m.version,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建菜单并可选地关联角色。
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateMenuReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<MenuResp> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let model = menu::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
parent_id: Set(req.parent_id),
|
||||||
|
title: Set(req.title.clone()),
|
||||||
|
path: Set(req.path.clone()),
|
||||||
|
icon: Set(req.icon.clone()),
|
||||||
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||||
|
visible: Set(req.visible.unwrap_or(true)),
|
||||||
|
menu_type: Set(req.menu_type.clone().unwrap_or_else(|| "menu".to_string())),
|
||||||
|
permission: Set(req.permission.clone()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 关联角色(如果提供了 role_ids)
|
||||||
|
if let Some(role_ids) = &req.role_ids
|
||||||
|
&& !role_ids.is_empty()
|
||||||
|
{
|
||||||
|
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"menu.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "menu_id": id, "title": req.title }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "menu.create", "menu").with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(MenuResp {
|
||||||
|
id,
|
||||||
|
parent_id: req.parent_id,
|
||||||
|
title: req.title.clone(),
|
||||||
|
path: req.path.clone(),
|
||||||
|
icon: req.icon.clone(),
|
||||||
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
|
visible: req.visible.unwrap_or(true),
|
||||||
|
menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()),
|
||||||
|
permission: req.permission.clone(),
|
||||||
|
children: vec![],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新菜单字段,并可选地重新关联角色。
|
||||||
|
/// 使用乐观锁校验版本。
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::UpdateMenuReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<MenuResp> {
|
||||||
|
let model = menu::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: menu::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(title) = &req.title {
|
||||||
|
active.title = Set(title.clone());
|
||||||
|
}
|
||||||
|
if let Some(path) = &req.path {
|
||||||
|
active.path = Set(Some(path.clone()));
|
||||||
|
}
|
||||||
|
if let Some(icon) = &req.icon {
|
||||||
|
active.icon = Set(Some(icon.clone()));
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = req.sort_order {
|
||||||
|
active.sort_order = Set(sort_order);
|
||||||
|
}
|
||||||
|
if let Some(visible) = req.visible {
|
||||||
|
active.visible = Set(visible);
|
||||||
|
}
|
||||||
|
if let Some(permission) = &req.permission {
|
||||||
|
active.permission = Set(Some(permission.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 如果提供了 role_ids,重新关联角色
|
||||||
|
if let Some(role_ids) = &req.role_ids {
|
||||||
|
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "menu.update", "menu").with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(MenuResp {
|
||||||
|
id: updated.id,
|
||||||
|
parent_id: updated.parent_id,
|
||||||
|
title: updated.title.clone(),
|
||||||
|
path: updated.path.clone(),
|
||||||
|
icon: updated.icon.clone(),
|
||||||
|
sort_order: updated.sort_order,
|
||||||
|
visible: updated.visible,
|
||||||
|
menu_type: updated.menu_type.clone(),
|
||||||
|
permission: updated.permission.clone(),
|
||||||
|
children: vec![],
|
||||||
|
version: updated.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 软删除菜单。使用乐观锁校验版本。
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = menu::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: menu::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"menu.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "menu_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu").with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 替换菜单的角色关联。
|
||||||
|
///
|
||||||
|
/// 软删除现有关联行,然后插入新关联(参考 RoleService::assign_permissions 模式)。
|
||||||
|
pub async fn assign_roles(
|
||||||
|
menu_id: Uuid,
|
||||||
|
role_ids: &[Uuid],
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
// 验证菜单存在且属于当前租户
|
||||||
|
let _menu = menu::Entity::find_by_id(menu_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {menu_id}")))?;
|
||||||
|
|
||||||
|
// 软删除现有关联
|
||||||
|
let existing = menu_role::Entity::find()
|
||||||
|
.filter(menu_role::Column::MenuId.eq(menu_id))
|
||||||
|
.filter(menu_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu_role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
for mr in existing {
|
||||||
|
let mut active: menu_role::ActiveModel = mr.into();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入新关联
|
||||||
|
for role_id in role_ids {
|
||||||
|
let mr = menu_role::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
menu_id: Set(menu_id),
|
||||||
|
role_id: Set(*role_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
mr.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归构建菜单树。
|
||||||
|
fn build_tree<'a>(
|
||||||
|
nodes: &[&'a menu::Model],
|
||||||
|
children_map: &HashMap<Option<Uuid>, Vec<&'a menu::Model>>,
|
||||||
|
) -> Vec<MenuResp> {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let children = children_map.get(&Some(m.id)).cloned().unwrap_or_default();
|
||||||
|
MenuResp {
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id,
|
||||||
|
title: m.title.clone(),
|
||||||
|
path: m.path.clone(),
|
||||||
|
icon: m.icon.clone(),
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
visible: m.visible,
|
||||||
|
menu_type: m.menu_type.clone(),
|
||||||
|
permission: m.permission.clone(),
|
||||||
|
children: Self::build_tree(&children, children_map),
|
||||||
|
version: m.version,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
/// 辅助:构造 menu::Model
|
||||||
|
fn make_menu(id: Uuid, parent_id: Option<Uuid>, title: &str, sort_order: i32) -> menu::Model {
|
||||||
|
let now = Utc::now();
|
||||||
|
let tenant_id = Uuid::now_v7();
|
||||||
|
menu::Model {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
parent_id,
|
||||||
|
title: title.to_string(),
|
||||||
|
path: Some(format!("/{}", title.to_lowercase())),
|
||||||
|
icon: None,
|
||||||
|
sort_order,
|
||||||
|
visible: true,
|
||||||
|
menu_type: "menu".to_string(),
|
||||||
|
permission: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created_by: tenant_id,
|
||||||
|
updated_by: tenant_id,
|
||||||
|
deleted_at: None,
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_empty_input() {
|
||||||
|
let nodes: Vec<&menu::Model> = vec![];
|
||||||
|
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
let tree = MenuService::build_tree(&nodes, &children_map);
|
||||||
|
assert!(tree.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_single_root() {
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let root = make_menu(root_id, None, "首页", 0);
|
||||||
|
|
||||||
|
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
let roots: Vec<&menu::Model> = vec![&root];
|
||||||
|
let tree = MenuService::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
assert_eq!(tree[0].id, root_id);
|
||||||
|
assert_eq!(tree[0].title, "首页");
|
||||||
|
assert!(tree[0].children.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_two_levels() {
|
||||||
|
// 根节点 -> 子节点1, 子节点2
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let child1_id = Uuid::now_v7();
|
||||||
|
let child2_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let root = make_menu(root_id, None, "系统管理", 0);
|
||||||
|
let child1 = make_menu(child1_id, Some(root_id), "用户管理", 1);
|
||||||
|
let child2 = make_menu(child2_id, Some(root_id), "角色管理", 2);
|
||||||
|
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
children_map.insert(Some(root_id), vec![&child1, &child2]);
|
||||||
|
|
||||||
|
let roots: Vec<&menu::Model> = vec![&root];
|
||||||
|
let tree = MenuService::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
assert_eq!(tree[0].children.len(), 2);
|
||||||
|
assert_eq!(tree[0].children[0].title, "用户管理");
|
||||||
|
assert_eq!(tree[0].children[1].title, "角色管理");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_three_levels() {
|
||||||
|
// 根 -> 子 -> 孙
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let child_id = Uuid::now_v7();
|
||||||
|
let grandchild_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let root = make_menu(root_id, None, "系统管理", 0);
|
||||||
|
let child = make_menu(child_id, Some(root_id), "用户管理", 1);
|
||||||
|
let grandchild = make_menu(grandchild_id, Some(child_id), "用户详情", 0);
|
||||||
|
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
children_map.insert(Some(root_id), vec![&child]);
|
||||||
|
children_map.insert(Some(child_id), vec![&grandchild]);
|
||||||
|
|
||||||
|
let roots: Vec<&menu::Model> = vec![&root];
|
||||||
|
let tree = MenuService::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
assert_eq!(tree[0].children.len(), 1);
|
||||||
|
assert_eq!(tree[0].children[0].children.len(), 1);
|
||||||
|
assert_eq!(tree[0].children[0].children[0].title, "用户详情");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_multiple_roots() {
|
||||||
|
// 两个独立的根节点
|
||||||
|
let root1_id = Uuid::now_v7();
|
||||||
|
let root2_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let root1 = make_menu(root1_id, None, "首页", 0);
|
||||||
|
let root2 = make_menu(root2_id, None, "系统管理", 1);
|
||||||
|
|
||||||
|
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
let roots: Vec<&menu::Model> = vec![&root1, &root2];
|
||||||
|
let tree = MenuService::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
assert_eq!(tree.len(), 2);
|
||||||
|
assert_eq!(tree[0].title, "首页");
|
||||||
|
assert_eq!(tree[1].title, "系统管理");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_preserves_model_fields() {
|
||||||
|
let root_id = Uuid::now_v7();
|
||||||
|
let now = Utc::now();
|
||||||
|
let tenant_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let root = menu::Model {
|
||||||
|
id: root_id,
|
||||||
|
tenant_id,
|
||||||
|
parent_id: None,
|
||||||
|
title: "设置".to_string(),
|
||||||
|
path: Some("/settings".to_string()),
|
||||||
|
icon: Some("SettingOutlined".to_string()),
|
||||||
|
sort_order: 5,
|
||||||
|
visible: false,
|
||||||
|
menu_type: "directory".to_string(),
|
||||||
|
permission: Some("settings:view".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created_by: tenant_id,
|
||||||
|
updated_by: tenant_id,
|
||||||
|
deleted_at: None,
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
let children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
let roots: Vec<&menu::Model> = vec![&root];
|
||||||
|
let tree = MenuService::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
assert_eq!(tree.len(), 1);
|
||||||
|
let node = &tree[0];
|
||||||
|
assert_eq!(node.id, root_id);
|
||||||
|
assert_eq!(node.title, "设置");
|
||||||
|
assert_eq!(node.path, Some("/settings".to_string()));
|
||||||
|
assert_eq!(node.icon, Some("SettingOutlined".to_string()));
|
||||||
|
assert_eq!(node.sort_order, 5);
|
||||||
|
assert!(!node.visible);
|
||||||
|
assert_eq!(node.menu_type, "directory");
|
||||||
|
assert_eq!(node.permission, Some("settings:view".to_string()));
|
||||||
|
assert_eq!(node.version, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/erp-config/src/service/mod.rs
Normal file
4
crates/erp-config/src/service/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod dictionary_service;
|
||||||
|
pub mod menu_service;
|
||||||
|
pub mod numbering_service;
|
||||||
|
pub mod setting_service;
|
||||||
747
crates/erp-config/src/service/numbering_service.rs
Normal file
747
crates/erp-config/src/service/numbering_service.rs
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
use chrono::{Datelike, NaiveDate, Utc};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
|
||||||
|
QueryFilter, Set, Statement, TransactionTrait,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
|
||||||
|
use crate::entity::numbering_rule;
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// 格式化编号字符串。
|
||||||
|
///
|
||||||
|
/// 拼接规则:
|
||||||
|
/// 1. 以 `prefix` 开头
|
||||||
|
/// 2. 若 `prefix` 非空,追加 `separator`
|
||||||
|
/// 3. 若 `date_part` 为 `Some` 且非空,追加 `date_part` + `separator`
|
||||||
|
/// 4. 追加零填充的 `seq_current`(填充到 `seq_length` 位,最少 1 位)
|
||||||
|
pub(crate) fn format_number(
|
||||||
|
prefix: &str,
|
||||||
|
separator: &str,
|
||||||
|
date_part: Option<&str>,
|
||||||
|
seq_current: i64,
|
||||||
|
seq_length: i32,
|
||||||
|
) -> String {
|
||||||
|
let mut result = String::with_capacity(32);
|
||||||
|
result.push_str(prefix);
|
||||||
|
|
||||||
|
if !prefix.is_empty() {
|
||||||
|
result.push_str(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dp) = date_part
|
||||||
|
&& !dp.is_empty()
|
||||||
|
{
|
||||||
|
result.push_str(dp);
|
||||||
|
result.push_str(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = (seq_length.max(1)) as usize;
|
||||||
|
let seq_padded = format!("{:0>width$}", seq_current, width = width);
|
||||||
|
result.push_str(&seq_padded);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则,
|
||||||
|
/// 以及线程安全地生成编号序列。
|
||||||
|
pub struct NumberingService;
|
||||||
|
|
||||||
|
impl NumberingService {
|
||||||
|
/// 分页查询编号规则列表。
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<(Vec<NumberingRuleResp>, u64)> {
|
||||||
|
let paginator = numbering_rule::Entity::find()
|
||||||
|
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(numbering_rule::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resps: Vec<NumberingRuleResp> = models.iter().map(Self::model_to_resp).collect();
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建编号规则。
|
||||||
|
///
|
||||||
|
/// 检查 code 在租户内唯一后插入。
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateNumberingRuleReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<NumberingRuleResp> {
|
||||||
|
// 检查 code 唯一性
|
||||||
|
let existing = numbering_rule::Entity::find()
|
||||||
|
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(numbering_rule::Column::Code.eq(&req.code))
|
||||||
|
.filter(numbering_rule::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(ConfigError::DuplicateKey(format!(
|
||||||
|
"编号规则编码已存在: {}",
|
||||||
|
req.code
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let seq_start = req.seq_start.unwrap_or(1);
|
||||||
|
|
||||||
|
let model = numbering_rule::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(req.name.clone()),
|
||||||
|
code: Set(req.code.clone()),
|
||||||
|
prefix: Set(req.prefix.clone().unwrap_or_default()),
|
||||||
|
date_format: Set(req.date_format.clone()),
|
||||||
|
seq_length: Set(req.seq_length.unwrap_or(4)),
|
||||||
|
seq_start: Set(seq_start),
|
||||||
|
seq_current: Set(seq_start as i64),
|
||||||
|
separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())),
|
||||||
|
reset_cycle: Set(req
|
||||||
|
.reset_cycle
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "never".to_string())),
|
||||||
|
last_reset_date: Set(Some(Utc::now().date_naive())),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"numbering_rule.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "rule_id": id, "code": req.code }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"numbering_rule.create",
|
||||||
|
"numbering_rule",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(NumberingRuleResp {
|
||||||
|
id,
|
||||||
|
name: req.name.clone(),
|
||||||
|
code: req.code.clone(),
|
||||||
|
prefix: req.prefix.clone().unwrap_or_default(),
|
||||||
|
date_format: req.date_format.clone(),
|
||||||
|
seq_length: req.seq_length.unwrap_or(4),
|
||||||
|
seq_start,
|
||||||
|
seq_current: seq_start as i64,
|
||||||
|
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
||||||
|
reset_cycle: req
|
||||||
|
.reset_cycle
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "never".to_string()),
|
||||||
|
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新编号规则的可编辑字段。使用乐观锁校验版本。
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::UpdateNumberingRuleReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<NumberingRuleResp> {
|
||||||
|
let model = numbering_rule::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: numbering_rule::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(name) = &req.name {
|
||||||
|
active.name = Set(name.clone());
|
||||||
|
}
|
||||||
|
if let Some(prefix) = &req.prefix {
|
||||||
|
active.prefix = Set(prefix.clone());
|
||||||
|
}
|
||||||
|
if let Some(date_format) = &req.date_format {
|
||||||
|
active.date_format = Set(Some(date_format.clone()));
|
||||||
|
}
|
||||||
|
if let Some(seq_length) = req.seq_length {
|
||||||
|
active.seq_length = Set(seq_length);
|
||||||
|
}
|
||||||
|
if let Some(separator) = &req.separator {
|
||||||
|
active.separator = Set(separator.clone());
|
||||||
|
}
|
||||||
|
if let Some(reset_cycle) = &req.reset_cycle {
|
||||||
|
active.reset_cycle = Set(reset_cycle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"numbering_rule.update",
|
||||||
|
"numbering_rule",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Self::model_to_resp(&updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 软删除编号规则。使用乐观锁校验版本。
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = numbering_rule::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: numbering_rule::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"numbering_rule.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "rule_id": id }),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(
|
||||||
|
tenant_id,
|
||||||
|
Some(operator_id),
|
||||||
|
"numbering_rule.delete",
|
||||||
|
"numbering_rule",
|
||||||
|
)
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线程安全地生成编号。
|
||||||
|
///
|
||||||
|
/// 使用 PostgreSQL advisory lock 保证并发安全:
|
||||||
|
/// 1. 在事务内获取 pg_advisory_xact_lock
|
||||||
|
/// 2. 在同一事务内读取规则、检查重置周期、递增序列、更新数据库
|
||||||
|
/// 3. 拼接编号字符串返回
|
||||||
|
pub async fn generate_number(
|
||||||
|
rule_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<GenerateNumberResp> {
|
||||||
|
// 先读取规则获取 code(用于 advisory lock)
|
||||||
|
let rule = numbering_rule::Entity::find_by_id(rule_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
|
||||||
|
|
||||||
|
let rule_code = rule.code.clone();
|
||||||
|
let tenant_id_str = tenant_id.to_string();
|
||||||
|
|
||||||
|
// 在同一个事务内获取 advisory lock 并执行编号生成
|
||||||
|
// pg_advisory_xact_lock 是事务级别的,锁会在事务结束时自动释放
|
||||||
|
let number = db
|
||||||
|
.transaction(|txn| {
|
||||||
|
let rule_code = rule_code.clone();
|
||||||
|
let tenant_id_str = tenant_id_str.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
// 在事务内获取 advisory lock
|
||||||
|
txn.execute(Statement::from_sql_and_values(
|
||||||
|
DatabaseBackend::Postgres,
|
||||||
|
"SELECT pg_advisory_xact_lock(abs(hashtext($1)), abs(hashtext($2))::int)",
|
||||||
|
[rule_code.into(), tenant_id_str.into()],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(format!("获取编号锁失败: {e}")))?;
|
||||||
|
|
||||||
|
// 在同一个事务内执行编号生成
|
||||||
|
Self::generate_number_in_txn(rule_id, tenant_id, txn).await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(GenerateNumberResp { number })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 事务内执行编号生成逻辑。
|
||||||
|
///
|
||||||
|
/// 检查重置周期,必要时重置序列,然后递增并拼接编号。
|
||||||
|
async fn generate_number_in_txn<C>(
|
||||||
|
rule_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
txn: &C,
|
||||||
|
) -> ConfigResult<String>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let rule = numbering_rule::Entity::find_by_id(rule_id)
|
||||||
|
.one(txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
|
||||||
|
|
||||||
|
let today = Utc::now().date_naive();
|
||||||
|
let mut seq_current = rule.seq_current;
|
||||||
|
|
||||||
|
// 检查是否需要重置序列
|
||||||
|
seq_current = Self::maybe_reset_sequence(
|
||||||
|
seq_current,
|
||||||
|
rule.seq_start as i64,
|
||||||
|
&rule.reset_cycle,
|
||||||
|
rule.last_reset_date,
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 递增序列
|
||||||
|
let next_seq = seq_current + 1;
|
||||||
|
|
||||||
|
// 检查序列是否超出 seq_length 能表示的最大值
|
||||||
|
let max_val = 10i64.pow(rule.seq_length as u32) - 1;
|
||||||
|
if next_seq > max_val {
|
||||||
|
return Err(ConfigError::NumberingExhausted(format!(
|
||||||
|
"编号序列已耗尽,当前序列号 {next_seq} 超出长度 {} 的最大值",
|
||||||
|
rule.seq_length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数据库中的 seq_current 和 last_reset_date
|
||||||
|
let mut active: numbering_rule::ActiveModel = rule.clone().into();
|
||||||
|
active.seq_current = Set(next_seq);
|
||||||
|
active.last_reset_date = Set(Some(today));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||||
|
active
|
||||||
|
.update(txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
|
||||||
|
let date_part = rule
|
||||||
|
.date_format
|
||||||
|
.as_ref()
|
||||||
|
.map(|fmt| Utc::now().format(fmt).to_string());
|
||||||
|
|
||||||
|
let number = format_number(
|
||||||
|
&rule.prefix,
|
||||||
|
&rule.separator,
|
||||||
|
date_part.as_deref(),
|
||||||
|
seq_current,
|
||||||
|
rule.seq_length,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据重置周期判断是否需要重置序列号。
|
||||||
|
///
|
||||||
|
/// 如果需要重置,返回 `seq_start`;否则返回原值。
|
||||||
|
fn maybe_reset_sequence(
|
||||||
|
seq_current: i64,
|
||||||
|
seq_start: i64,
|
||||||
|
reset_cycle: &str,
|
||||||
|
last_reset_date: Option<NaiveDate>,
|
||||||
|
today: NaiveDate,
|
||||||
|
) -> i64 {
|
||||||
|
let last_reset = match last_reset_date {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return seq_start, // 从未重置过,使用 seq_start
|
||||||
|
};
|
||||||
|
|
||||||
|
match reset_cycle {
|
||||||
|
"daily" => {
|
||||||
|
if last_reset != today {
|
||||||
|
seq_start
|
||||||
|
} else {
|
||||||
|
seq_current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"monthly" => {
|
||||||
|
if last_reset.month() != today.month() || last_reset.year() != today.year() {
|
||||||
|
seq_start
|
||||||
|
} else {
|
||||||
|
seq_current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"yearly" => {
|
||||||
|
if last_reset.year() != today.year() {
|
||||||
|
seq_start
|
||||||
|
} else {
|
||||||
|
seq_current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => seq_current, // "never" 或其他值不重置
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将数据库模型转换为响应 DTO。
|
||||||
|
fn model_to_resp(m: &numbering_rule::Model) -> NumberingRuleResp {
|
||||||
|
NumberingRuleResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name.clone(),
|
||||||
|
code: m.code.clone(),
|
||||||
|
prefix: m.prefix.clone(),
|
||||||
|
date_format: m.date_format.clone(),
|
||||||
|
seq_length: m.seq_length,
|
||||||
|
seq_start: m.seq_start,
|
||||||
|
seq_current: m.seq_current,
|
||||||
|
separator: m.separator.clone(),
|
||||||
|
reset_cycle: m.reset_cycle.clone(),
|
||||||
|
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
|
||||||
|
version: m.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
/// 辅助:构造 NaiveDate
|
||||||
|
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- maybe_reset_sequence 测试 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_never_keeps_current() {
|
||||||
|
// "never" 周期:永远不重置,保持 seq_current
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
100,
|
||||||
|
1,
|
||||||
|
"never",
|
||||||
|
Some(date(2025, 1, 1)),
|
||||||
|
date(2026, 4, 15),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_unknown_cycle_keeps_current() {
|
||||||
|
// 未知周期值等同于不重置
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
50,
|
||||||
|
1,
|
||||||
|
"weekly",
|
||||||
|
Some(date(2025, 1, 1)),
|
||||||
|
date(2026, 4, 15),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_daily_same_day_keeps_current() {
|
||||||
|
// 同一天内不重置
|
||||||
|
let today = date(2026, 4, 15);
|
||||||
|
let result = NumberingService::maybe_reset_sequence(42, 1, "daily", Some(today), today);
|
||||||
|
assert_eq!(result, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_daily_different_day_resets() {
|
||||||
|
// 不同天重置为 seq_start
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
42,
|
||||||
|
1,
|
||||||
|
"daily",
|
||||||
|
Some(date(2026, 4, 14)),
|
||||||
|
date(2026, 4, 15),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_daily_resets_with_custom_start() {
|
||||||
|
// 重置时使用自定义 seq_start
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
99,
|
||||||
|
10,
|
||||||
|
"daily",
|
||||||
|
Some(date(2026, 4, 10)),
|
||||||
|
date(2026, 4, 15),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_monthly_same_month_keeps_current() {
|
||||||
|
// 同月不重置
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
30,
|
||||||
|
1,
|
||||||
|
"monthly",
|
||||||
|
Some(date(2026, 4, 1)),
|
||||||
|
date(2026, 4, 15),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_monthly_different_month_resets() {
|
||||||
|
// 不同月份重置
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
30,
|
||||||
|
1,
|
||||||
|
"monthly",
|
||||||
|
Some(date(2026, 3, 31)),
|
||||||
|
date(2026, 4, 1),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_monthly_same_month_different_year_resets() {
|
||||||
|
// 不同年份但相同月份数字,仍然重置
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
20,
|
||||||
|
5,
|
||||||
|
"monthly",
|
||||||
|
Some(date(2025, 4, 15)),
|
||||||
|
date(2026, 4, 15),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_yearly_same_year_keeps_current() {
|
||||||
|
// 同年不重置
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
50,
|
||||||
|
1,
|
||||||
|
"yearly",
|
||||||
|
Some(date(2026, 1, 1)),
|
||||||
|
date(2026, 12, 31),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_yearly_different_year_resets() {
|
||||||
|
// 不同年份重置
|
||||||
|
let result = NumberingService::maybe_reset_sequence(
|
||||||
|
50,
|
||||||
|
1,
|
||||||
|
"yearly",
|
||||||
|
Some(date(2025, 12, 31)),
|
||||||
|
date(2026, 1, 1),
|
||||||
|
);
|
||||||
|
assert_eq!(result, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_no_last_reset_date_returns_seq_start() {
|
||||||
|
// 从未重置过,使用 seq_start
|
||||||
|
let result =
|
||||||
|
NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15));
|
||||||
|
assert_eq!(result, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_no_last_reset_date_uses_custom_start() {
|
||||||
|
// 从未重置过,使用自定义 seq_start
|
||||||
|
let result =
|
||||||
|
NumberingService::maybe_reset_sequence(999, 42, "monthly", None, date(2026, 4, 15));
|
||||||
|
assert_eq!(result, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- model_to_resp 测试 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_maps_fields_correctly() {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let tenant_id = Uuid::now_v7();
|
||||||
|
let now = Utc::now();
|
||||||
|
let today = now.date_naive();
|
||||||
|
|
||||||
|
let model = numbering_rule::Model {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
name: "订单编号".to_string(),
|
||||||
|
code: "ORDER".to_string(),
|
||||||
|
prefix: "ORD".to_string(),
|
||||||
|
date_format: Some("%Y%m%d".to_string()),
|
||||||
|
seq_length: 6,
|
||||||
|
seq_start: 1,
|
||||||
|
seq_current: 42,
|
||||||
|
separator: "-".to_string(),
|
||||||
|
reset_cycle: "daily".to_string(),
|
||||||
|
last_reset_date: Some(today),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created_by: tenant_id,
|
||||||
|
updated_by: tenant_id,
|
||||||
|
deleted_at: None,
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = NumberingService::model_to_resp(&model);
|
||||||
|
|
||||||
|
assert_eq!(resp.id, id);
|
||||||
|
assert_eq!(resp.name, "订单编号");
|
||||||
|
assert_eq!(resp.code, "ORDER");
|
||||||
|
assert_eq!(resp.prefix, "ORD");
|
||||||
|
assert_eq!(resp.date_format, Some("%Y%m%d".to_string()));
|
||||||
|
assert_eq!(resp.seq_length, 6);
|
||||||
|
assert_eq!(resp.seq_start, 1);
|
||||||
|
assert_eq!(resp.seq_current, 42);
|
||||||
|
assert_eq!(resp.separator, "-");
|
||||||
|
assert_eq!(resp.reset_cycle, "daily");
|
||||||
|
assert_eq!(resp.last_reset_date, Some(today.to_string()));
|
||||||
|
assert_eq!(resp.version, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_none_fields() {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let tenant_id = Uuid::now_v7();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let model = numbering_rule::Model {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
name: "简单编号".to_string(),
|
||||||
|
code: "SIMPLE".to_string(),
|
||||||
|
prefix: "".to_string(),
|
||||||
|
date_format: None,
|
||||||
|
seq_length: 4,
|
||||||
|
seq_start: 1,
|
||||||
|
seq_current: 1,
|
||||||
|
separator: "-".to_string(),
|
||||||
|
reset_cycle: "never".to_string(),
|
||||||
|
last_reset_date: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created_by: tenant_id,
|
||||||
|
updated_by: tenant_id,
|
||||||
|
deleted_at: None,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = NumberingService::model_to_resp(&model);
|
||||||
|
|
||||||
|
assert_eq!(resp.date_format, None);
|
||||||
|
assert_eq!(resp.last_reset_date, None);
|
||||||
|
assert_eq!(resp.prefix, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- format_number 测试 ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_basic_prefix_no_date() {
|
||||||
|
// 基础:前缀 + 序列号
|
||||||
|
let result = format_number("ORD", "/", None, 1, 5);
|
||||||
|
assert_eq!(result, "ORD/00001");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_with_date_part() {
|
||||||
|
// 前缀 + 日期 + 序列号
|
||||||
|
let result = format_number("INV", "-", Some("20260430"), 42, 4);
|
||||||
|
assert_eq!(result, "INV-20260430-0042");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_no_prefix() {
|
||||||
|
// 无前缀,直接输出序列号
|
||||||
|
let result = format_number("", "/", None, 7, 3);
|
||||||
|
assert_eq!(result, "007");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_no_prefix_no_date() {
|
||||||
|
// 无前缀无日期,仅序列号
|
||||||
|
let result = format_number("", "-", None, 99, 6);
|
||||||
|
assert_eq!(result, "000099");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_seq_length_zero_pads_to_one() {
|
||||||
|
// seq_length=0 时仍至少填充 1 位
|
||||||
|
let result = format_number("", "", None, 5, 0);
|
||||||
|
assert_eq!(result, "5");
|
||||||
|
}
|
||||||
|
}
|
||||||
447
crates/erp-config/src/service/setting_service.rs
Normal file
447
crates/erp-config/src/service/setting_service.rs
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::SettingResp;
|
||||||
|
use crate::entity::setting;
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// Setting scope hierarchy constants.
|
||||||
|
const SCOPE_PLATFORM: &str = "platform";
|
||||||
|
const SCOPE_TENANT: &str = "tenant";
|
||||||
|
const SCOPE_ORG: &str = "org";
|
||||||
|
const SCOPE_USER: &str = "user";
|
||||||
|
|
||||||
|
/// Setting CRUD service — manage hierarchical configuration values.
|
||||||
|
///
|
||||||
|
/// Settings support a 4-level inheritance hierarchy:
|
||||||
|
/// `user -> org -> tenant -> platform`
|
||||||
|
///
|
||||||
|
/// When reading a setting, if the exact scope+scope_id match is not found,
|
||||||
|
/// the service walks up the hierarchy to find the nearest ancestor value.
|
||||||
|
pub struct SettingService;
|
||||||
|
|
||||||
|
impl SettingService {
|
||||||
|
/// Get a setting value with hierarchical fallback.
|
||||||
|
///
|
||||||
|
/// Resolution order:
|
||||||
|
/// 1. Exact match at (scope, scope_id)
|
||||||
|
/// 2. Walk up the hierarchy based on scope:
|
||||||
|
/// - `user` -> org -> tenant -> platform
|
||||||
|
/// - `org` -> tenant -> platform
|
||||||
|
/// - `tenant` -> platform
|
||||||
|
/// - `platform` -> NotFound
|
||||||
|
pub async fn get(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<SettingResp> {
|
||||||
|
// 1. Try exact match
|
||||||
|
if let Some(resp) = Self::find_exact(key, scope, scope_id, tenant_id, db).await? {
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up the hierarchy based on scope
|
||||||
|
let fallback_chain = Self::fallback_chain(scope, scope_id, tenant_id)?;
|
||||||
|
|
||||||
|
for (fb_scope, fb_scope_id) in fallback_chain {
|
||||||
|
if let Some(resp) =
|
||||||
|
Self::find_exact(key, &fb_scope, &fb_scope_id, tenant_id, db).await?
|
||||||
|
{
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ConfigError::NotFound(format!(
|
||||||
|
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||||
|
key, scope
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a setting value. Creates or updates.
|
||||||
|
///
|
||||||
|
/// If a record with the same (scope, scope_id, key) exists and is not
|
||||||
|
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
|
||||||
|
pub async fn set(
|
||||||
|
params: crate::dto::SetSettingParams,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<SettingResp> {
|
||||||
|
// Look for an existing non-deleted record
|
||||||
|
let mut query = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(¶ms.scope))
|
||||||
|
.filter(setting::Column::SettingKey.eq(¶ms.key))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
query = match params.scope_id {
|
||||||
|
Some(id) => query.filter(setting::Column::ScopeId.eq(id)),
|
||||||
|
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing = query
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(model) = existing {
|
||||||
|
// Update existing record — 乐观锁校验
|
||||||
|
let next_version = match params.version {
|
||||||
|
Some(v) => {
|
||||||
|
check_version(v, model.version).map_err(|_| ConfigError::VersionMismatch)?
|
||||||
|
}
|
||||||
|
None => model.version + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut active: setting::ActiveModel = model.into();
|
||||||
|
active.setting_value = Set(params.value.clone());
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"setting.updated",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"setting_id": updated.id,
|
||||||
|
"key": params.key,
|
||||||
|
"scope": params.scope,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
|
||||||
|
.with_resource_id(updated.id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Self::model_to_resp(&updated))
|
||||||
|
} else {
|
||||||
|
// Insert new record
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = setting::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
scope: Set(params.scope.clone()),
|
||||||
|
scope_id: Set(params.scope_id),
|
||||||
|
setting_key: Set(params.key.clone()),
|
||||||
|
setting_value: Set(params.value),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
let inserted = model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus
|
||||||
|
.publish(
|
||||||
|
erp_core::events::DomainEvent::new(
|
||||||
|
"setting.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"setting_id": id,
|
||||||
|
"key": params.key,
|
||||||
|
"scope": params.scope,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting")
|
||||||
|
.with_resource_id(id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Self::model_to_resp(&inserted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all settings for a specific scope and scope_id, with pagination.
|
||||||
|
pub async fn list_by_scope(
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<(Vec<SettingResp>, u64)> {
|
||||||
|
let mut query = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
query = match scope_id {
|
||||||
|
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
|
||||||
|
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let paginator = query.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resps: Vec<SettingResp> = models.iter().map(Self::model_to_resp).collect();
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a setting by setting the `deleted_at` timestamp.
|
||||||
|
/// Performs optimistic locking via version check.
|
||||||
|
pub async fn delete(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
version: i32,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let mut query = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::SettingKey.eq(key))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
query = match scope_id {
|
||||||
|
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
|
||||||
|
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = query
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ConfigError::NotFound(format!("设置 '{}' 在 '{}' 作用域下不存在", key, scope))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let next_version =
|
||||||
|
check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let setting_id = model.id;
|
||||||
|
let mut active: setting::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_version);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, Some(operator_id), "setting.delete", "setting")
|
||||||
|
.with_resource_id(setting_id),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部辅助方法 ----
|
||||||
|
|
||||||
|
/// Find an exact setting match by key, scope, and scope_id.
|
||||||
|
async fn find_exact(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Option<SettingResp>> {
|
||||||
|
let mut query = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::SettingKey.eq(key))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
// SQL 中 `= NULL` 永远返回 false,必须用 IS NULL 匹配 NULL 值
|
||||||
|
query = match scope_id {
|
||||||
|
Some(id) => query.filter(setting::Column::ScopeId.eq(*id)),
|
||||||
|
None => query.filter(setting::Column::ScopeId.is_null()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = query
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(model.as_ref().map(Self::model_to_resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the fallback chain for hierarchical lookup.
|
||||||
|
///
|
||||||
|
/// Returns a list of (scope, scope_id) tuples to try in order.
|
||||||
|
pub(crate) fn fallback_chain(
|
||||||
|
scope: &str,
|
||||||
|
_scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
|
||||||
|
match scope {
|
||||||
|
SCOPE_USER => {
|
||||||
|
// user -> org -> tenant -> platform
|
||||||
|
// Note: We cannot resolve the actual org_id from user scope here
|
||||||
|
// without a dependency on auth module. The caller should handle
|
||||||
|
// org-level resolution externally if needed. We skip org fallback
|
||||||
|
// and go directly to tenant.
|
||||||
|
Ok(vec![
|
||||||
|
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||||
|
(SCOPE_PLATFORM.to_string(), None),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
SCOPE_ORG => Ok(vec![
|
||||||
|
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||||
|
(SCOPE_PLATFORM.to_string(), None),
|
||||||
|
]),
|
||||||
|
SCOPE_TENANT => Ok(vec![(SCOPE_PLATFORM.to_string(), None)]),
|
||||||
|
SCOPE_PLATFORM => Ok(vec![]),
|
||||||
|
_ => Err(ConfigError::Validation(format!(
|
||||||
|
"不支持的作用域类型: '{}'",
|
||||||
|
scope
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a SeaORM model to a response DTO.
|
||||||
|
pub(crate) fn model_to_resp(model: &setting::Model) -> SettingResp {
|
||||||
|
SettingResp {
|
||||||
|
id: model.id,
|
||||||
|
scope: model.scope.clone(),
|
||||||
|
scope_id: model.scope_id,
|
||||||
|
setting_key: model.setting_key.clone(),
|
||||||
|
setting_value: model.setting_value.clone(),
|
||||||
|
version: model.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tid() -> Uuid {
|
||||||
|
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fallback_chain ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_user_scope_returns_tenant_then_platform() {
|
||||||
|
let chain = SettingService::fallback_chain("user", &None, tid()).unwrap();
|
||||||
|
assert_eq!(chain.len(), 2);
|
||||||
|
assert_eq!(chain[0], ("tenant".to_string(), Some(tid())));
|
||||||
|
assert_eq!(chain[1], ("platform".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_org_scope_returns_tenant_then_platform() {
|
||||||
|
let chain = SettingService::fallback_chain("org", &None, tid()).unwrap();
|
||||||
|
assert_eq!(chain.len(), 2);
|
||||||
|
assert_eq!(chain[0], ("tenant".to_string(), Some(tid())));
|
||||||
|
assert_eq!(chain[1], ("platform".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_tenant_scope_returns_platform() {
|
||||||
|
let chain = SettingService::fallback_chain("tenant", &None, tid()).unwrap();
|
||||||
|
assert_eq!(chain.len(), 1);
|
||||||
|
assert_eq!(chain[0], ("platform".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_platform_scope_returns_empty() {
|
||||||
|
let chain = SettingService::fallback_chain("platform", &None, tid()).unwrap();
|
||||||
|
assert!(chain.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_invalid_scope_returns_error() {
|
||||||
|
let result = SettingService::fallback_chain("invalid", &None, tid());
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
ConfigError::Validation(msg) => assert!(msg.contains("不支持的作用域")),
|
||||||
|
other => panic!("期望 Validation,得到 {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- model_to_resp ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_maps_all_fields() {
|
||||||
|
let m = setting::Model {
|
||||||
|
id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(),
|
||||||
|
tenant_id: tid(),
|
||||||
|
scope: "tenant".to_string(),
|
||||||
|
scope_id: Some(tid()),
|
||||||
|
setting_key: "theme.primary_color".to_string(),
|
||||||
|
setting_value: serde_json::json!("#1890ff"),
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
created_by: tid(),
|
||||||
|
updated_by: tid(),
|
||||||
|
deleted_at: None,
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
let resp = SettingService::model_to_resp(&m);
|
||||||
|
assert_eq!(resp.scope, "tenant");
|
||||||
|
assert_eq!(resp.setting_key, "theme.primary_color");
|
||||||
|
assert_eq!(resp.setting_value, serde_json::json!("#1890ff"));
|
||||||
|
assert_eq!(resp.version, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_null_scope_id() {
|
||||||
|
let m = setting::Model {
|
||||||
|
id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(),
|
||||||
|
tenant_id: tid(),
|
||||||
|
scope: "platform".to_string(),
|
||||||
|
scope_id: None,
|
||||||
|
setting_key: "language.default".to_string(),
|
||||||
|
setting_value: serde_json::json!("zh-CN"),
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
created_by: tid(),
|
||||||
|
updated_by: tid(),
|
||||||
|
deleted_at: None,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
let resp = SettingService::model_to_resp(&m);
|
||||||
|
assert_eq!(resp.scope_id, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
crates/erp-core/Cargo.toml
Normal file
26
crates/erp-core/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "erp-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
sea-orm.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
rand = "0.8"
|
||||||
|
dashmap = "6"
|
||||||
|
ammonia.workspace = true
|
||||||
38
crates/erp-core/src/aggregate.rs
Normal file
38
crates/erp-core/src/aggregate.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! 聚合查询容错工具
|
||||||
|
//!
|
||||||
|
//! 仪表盘等聚合统计端点通常包含多个独立子查询。
|
||||||
|
//! 单个子查询失败不应导致整个接口 500。
|
||||||
|
//! `safe_aggregate` 让每个子查询独立容错,失败时返回默认值并记录警告日志。
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
/// 执行一个子查询,失败时返回 `T::default()` 并记录警告日志。
|
||||||
|
///
|
||||||
|
/// # 使用场景
|
||||||
|
///
|
||||||
|
/// 仪表盘统计 API 聚合多个指标(患者数/咨询数/随访数等),
|
||||||
|
/// 任一子查询失败不应阻塞其他指标返回。
|
||||||
|
///
|
||||||
|
/// # 示例
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let patients = safe_aggregate(
|
||||||
|
/// stats_service::get_patient_statistics(&state, tenant_id),
|
||||||
|
/// "患者统计",
|
||||||
|
/// ).await;
|
||||||
|
/// ```
|
||||||
|
pub async fn safe_aggregate<T: Default, E: std::fmt::Display>(
|
||||||
|
fut: impl Future<Output = Result<T, E>>,
|
||||||
|
label: &str,
|
||||||
|
) -> T {
|
||||||
|
match fut.await {
|
||||||
|
Ok(v) => {
|
||||||
|
tracing::debug!("聚合子查询 [{label}] 成功");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("聚合子查询 [{label}] 失败,使用默认值: {e}");
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/erp-core/src/audit.rs
Normal file
67
crates/erp-core/src/audit.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// 审计日志记录。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditLog {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub action: String,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub resource_id: Option<Uuid>,
|
||||||
|
pub old_value: Option<serde_json::Value>,
|
||||||
|
pub new_value: Option<serde_json::Value>,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditLog {
|
||||||
|
/// 创建一条审计日志记录。
|
||||||
|
pub fn new(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
action: impl Into<String>,
|
||||||
|
resource_type: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
action: action.into(),
|
||||||
|
resource_type: resource_type.into(),
|
||||||
|
resource_id: None,
|
||||||
|
old_value: None,
|
||||||
|
new_value: None,
|
||||||
|
ip_address: None,
|
||||||
|
user_agent: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置资源 ID。
|
||||||
|
pub fn with_resource_id(mut self, id: Uuid) -> Self {
|
||||||
|
self.resource_id = Some(id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置变更前后的值。
|
||||||
|
pub fn with_changes(
|
||||||
|
mut self,
|
||||||
|
old: Option<serde_json::Value>,
|
||||||
|
new: Option<serde_json::Value>,
|
||||||
|
) -> Self {
|
||||||
|
self.old_value = old;
|
||||||
|
self.new_value = new;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置请求来源信息。
|
||||||
|
pub fn with_request_info(mut self, ip: Option<String>, user_agent: Option<String>) -> Self {
|
||||||
|
self.ip_address = ip;
|
||||||
|
self.user_agent = user_agent;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
285
crates/erp-core/src/audit_service.rs
Normal file
285
crates/erp-core/src/audit_service.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
use crate::audit::AuditLog;
|
||||||
|
use crate::entity::audit_log;
|
||||||
|
use crate::request_info::RequestInfo;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tracing;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// 审计日志中需要脱敏的 PII 字段名(小写匹配)
|
||||||
|
const PII_FIELDS: &[&str] = &[
|
||||||
|
"id_number",
|
||||||
|
"phone",
|
||||||
|
"emergency_contact_phone",
|
||||||
|
"emergency_contact_name",
|
||||||
|
"allergy_history",
|
||||||
|
"medical_history_summary",
|
||||||
|
"name",
|
||||||
|
"content",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 审计日志中需要脱敏的 resource_type 前缀
|
||||||
|
const PII_RESOURCE_TYPES: &[&str] = &[
|
||||||
|
"patient",
|
||||||
|
"consultation",
|
||||||
|
"follow_up",
|
||||||
|
"family_member",
|
||||||
|
"doctor_profile",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 对 JSON Value 中的 PII 字段进行脱敏
|
||||||
|
fn sanitize_audit_value(
|
||||||
|
value: &Option<serde_json::Value>,
|
||||||
|
resource_type: &str,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let needs_sanitization = PII_RESOURCE_TYPES
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| resource_type.starts_with(prefix));
|
||||||
|
|
||||||
|
if !needs_sanitization {
|
||||||
|
return value.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
value.as_ref().map(sanitize_json_value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_json_value(v: &serde_json::Value) -> serde_json::Value {
|
||||||
|
match v {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let sanitized: serde_json::Map<String, serde_json::Value> = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let key_lower = k.to_lowercase();
|
||||||
|
if PII_FIELDS.iter().any(|f| key_lower.contains(f)) {
|
||||||
|
(k.clone(), serde_json::Value::String("***".to_string()))
|
||||||
|
} else {
|
||||||
|
(k.clone(), sanitize_json_value(v))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(sanitized)
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
serde_json::Value::Array(arr.iter().map(sanitize_json_value).collect())
|
||||||
|
}
|
||||||
|
other => other.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 持久化审计日志到 audit_logs 表。
|
||||||
|
///
|
||||||
|
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
|
||||||
|
///
|
||||||
|
/// 自动从 task_local 读取当前请求的 IP 和 User-Agent,
|
||||||
|
/// 如果 AuditLog 中已有 ip_address/user_agent 则不覆盖。
|
||||||
|
///
|
||||||
|
/// 哈希链:查询同租户最新一条记录的 record_hash 作为 prev_hash,
|
||||||
|
/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。
|
||||||
|
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||||
|
// 自动填充请求来源信息(仅当调用方未显式设置时)
|
||||||
|
if let Some(info) = RequestInfo::try_current() {
|
||||||
|
if log.ip_address.is_none() {
|
||||||
|
log.ip_address = info.ip_address;
|
||||||
|
}
|
||||||
|
if log.user_agent.is_none() {
|
||||||
|
log.user_agent = info.user_agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询同租户最新一条记录的 record_hash 作为 prev_hash
|
||||||
|
let prev_hash = audit_log::Entity::find()
|
||||||
|
.filter(audit_log::Column::TenantId.eq(log.tenant_id))
|
||||||
|
.filter(audit_log::Column::RecordHash.is_not_null())
|
||||||
|
.order_by_desc(audit_log::Column::CreatedAt)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|m| m.record_hash);
|
||||||
|
|
||||||
|
// 计算当前记录的 record_hash
|
||||||
|
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
|
||||||
|
|
||||||
|
// 脱敏处理:对 patient/consultation/follow_up 等资源类型的变更值中 PII 字段进行 mask
|
||||||
|
let sanitized_old = sanitize_audit_value(&log.old_value, &log.resource_type);
|
||||||
|
let sanitized_new = sanitize_audit_value(&log.new_value, &log.resource_type);
|
||||||
|
|
||||||
|
// 保存日志字段用于错误日志(model 构建会 move String 字段)
|
||||||
|
let err_tenant_id = log.tenant_id;
|
||||||
|
let err_action = log.action.clone();
|
||||||
|
let err_resource_type = log.resource_type.clone();
|
||||||
|
let err_resource_id = log.resource_id;
|
||||||
|
|
||||||
|
let model = audit_log::ActiveModel {
|
||||||
|
id: Set(log.id),
|
||||||
|
tenant_id: Set(log.tenant_id),
|
||||||
|
user_id: Set(log.user_id),
|
||||||
|
action: Set(log.action),
|
||||||
|
resource_type: Set(log.resource_type),
|
||||||
|
resource_id: Set(log.resource_id),
|
||||||
|
old_value: Set(sanitized_old),
|
||||||
|
new_value: Set(sanitized_new),
|
||||||
|
ip_address: Set(log.ip_address),
|
||||||
|
user_agent: Set(log.user_agent),
|
||||||
|
created_at: Set(log.created_at),
|
||||||
|
prev_hash: Set(prev_hash),
|
||||||
|
record_hash: Set(Some(record_hash)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = model.insert(db).await {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
tenant_id = ?err_tenant_id,
|
||||||
|
action = %err_action,
|
||||||
|
resource_type = %err_resource_type,
|
||||||
|
resource_id = ?err_resource_id,
|
||||||
|
"审计日志写入失败 — 数据完整性风险"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算 record_hash: SHA256(id + action + resource_type + resource_id + created_at + prev_hash)
|
||||||
|
fn compute_record_hash(log: &AuditLog, prev_hash: Option<&str>) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(log.id.to_string().as_bytes());
|
||||||
|
hasher.update(log.action.as_bytes());
|
||||||
|
hasher.update(log.resource_type.as_bytes());
|
||||||
|
hasher.update(
|
||||||
|
log.resource_id
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
hasher.update(log.created_at.to_rfc3339().as_bytes());
|
||||||
|
hasher.update(prev_hash.unwrap_or("").as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证审计日志哈希链完整性。
|
||||||
|
///
|
||||||
|
/// 检查指定租户的所有含 record_hash 的日志记录,
|
||||||
|
/// 验证每条记录的 prev_hash 是否等于前一条的 record_hash,
|
||||||
|
/// 以及 record_hash 是否可以重新计算验证。
|
||||||
|
///
|
||||||
|
/// 返回 (总记录数, 断链数)。
|
||||||
|
pub async fn verify_hash_chain(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> Result<(usize, usize), sea_orm::DbErr> {
|
||||||
|
use sea_orm::QueryOrder;
|
||||||
|
|
||||||
|
let records = audit_log::Entity::find()
|
||||||
|
.filter(audit_log::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(audit_log::Column::RecordHash.is_not_null())
|
||||||
|
.order_by_asc(audit_log::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total = records.len();
|
||||||
|
let mut broken = 0;
|
||||||
|
let mut prev: Option<String> = None;
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
// 验证 prev_hash 指向正确
|
||||||
|
if prev.as_deref() != record.prev_hash.as_deref() {
|
||||||
|
broken += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 record_hash 可重算
|
||||||
|
let log = AuditLog {
|
||||||
|
id: record.id,
|
||||||
|
tenant_id: record.tenant_id,
|
||||||
|
user_id: record.user_id,
|
||||||
|
action: record.action.clone(),
|
||||||
|
resource_type: record.resource_type.clone(),
|
||||||
|
resource_id: record.resource_id,
|
||||||
|
old_value: record.old_value.clone(),
|
||||||
|
new_value: record.new_value.clone(),
|
||||||
|
ip_address: record.ip_address.clone(),
|
||||||
|
user_agent: record.user_agent.clone(),
|
||||||
|
created_at: record.created_at,
|
||||||
|
};
|
||||||
|
let expected = compute_record_hash(&log, record.prev_hash.as_deref());
|
||||||
|
if Some(expected.as_str()) != record.record_hash.as_deref() {
|
||||||
|
broken += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = record.record_hash.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((total, broken))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 哈希链验证结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChainVerificationResult {
|
||||||
|
pub total: usize,
|
||||||
|
pub passed: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
pub failed_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证最近 N 条审计记录的哈希链完整性。
|
||||||
|
pub async fn verify_recent_chain(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
limit: u64,
|
||||||
|
) -> Result<ChainVerificationResult, String> {
|
||||||
|
let records = audit_log::Entity::find()
|
||||||
|
.filter(audit_log::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(audit_log::Column::RecordHash.is_not_null())
|
||||||
|
.order_by_desc(audit_log::Column::CreatedAt)
|
||||||
|
.limit(limit)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("查询审计日志失败: {}", e))?;
|
||||||
|
|
||||||
|
let mut records = records;
|
||||||
|
records.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||||
|
|
||||||
|
let total = records.len();
|
||||||
|
let mut passed = 0;
|
||||||
|
let mut failed_ids = Vec::new();
|
||||||
|
let mut prev: Option<String> = None;
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
let mut record_broken = false;
|
||||||
|
if prev.as_deref() != record.prev_hash.as_deref() {
|
||||||
|
record_broken = true;
|
||||||
|
}
|
||||||
|
let log = AuditLog {
|
||||||
|
id: record.id,
|
||||||
|
tenant_id: record.tenant_id,
|
||||||
|
user_id: record.user_id,
|
||||||
|
action: record.action.clone(),
|
||||||
|
resource_type: record.resource_type.clone(),
|
||||||
|
resource_id: record.resource_id,
|
||||||
|
old_value: record.old_value.clone(),
|
||||||
|
new_value: record.new_value.clone(),
|
||||||
|
ip_address: record.ip_address.clone(),
|
||||||
|
user_agent: record.user_agent.clone(),
|
||||||
|
created_at: record.created_at,
|
||||||
|
};
|
||||||
|
let expected = compute_record_hash(&log, record.prev_hash.as_deref());
|
||||||
|
if Some(expected.as_str()) != record.record_hash.as_deref() {
|
||||||
|
record_broken = true;
|
||||||
|
}
|
||||||
|
if record_broken {
|
||||||
|
failed_ids.push(record.id);
|
||||||
|
} else {
|
||||||
|
passed += 1;
|
||||||
|
}
|
||||||
|
prev = record.record_hash.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = total - passed;
|
||||||
|
Ok(ChainVerificationResult {
|
||||||
|
total,
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
failed_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
48
crates/erp-core/src/crypto/engine.rs
Normal file
48
crates/erp-core/src/crypto/engine.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use aes_gcm::aead::Aead;
|
||||||
|
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
const CIPHER_VERSION: u8 = 0x01;
|
||||||
|
|
||||||
|
/// AES-256-GCM 加密。输出格式: Base64(0x01 || nonce[12] || ciphertext + tag)
|
||||||
|
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<String, String> {
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext.as_bytes())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut combined = vec![CIPHER_VERSION];
|
||||||
|
combined.extend_from_slice(&nonce_bytes);
|
||||||
|
combined.extend_from_slice(&ciphertext);
|
||||||
|
Ok(BASE64.encode(&combined))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-256-GCM 解密。支持 v1 格式: Base64(0x01 || nonce[12] || ciphertext + tag)
|
||||||
|
/// 兼容旧格式: Base64(nonce[12] || ciphertext + tag)
|
||||||
|
pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result<String, String> {
|
||||||
|
let bytes = BASE64.decode(encoded).map_err(|e| e.to_string())?;
|
||||||
|
if bytes.len() < 13 {
|
||||||
|
return Err("ciphertext too short".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (nonce_bytes, ciphertext) = if bytes[0] == CIPHER_VERSION {
|
||||||
|
// v1: version(1) + nonce(12) + ciphertext
|
||||||
|
if bytes.len() < 14 {
|
||||||
|
return Err("v1 ciphertext too short".into());
|
||||||
|
}
|
||||||
|
(&bytes[1..13], &bytes[13..])
|
||||||
|
} else {
|
||||||
|
// 旧格式: nonce(12) + ciphertext(向后兼容)
|
||||||
|
(&bytes[0..12], &bytes[12..])
|
||||||
|
};
|
||||||
|
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
String::from_utf8(plaintext).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
24
crates/erp-core/src/crypto/hmac_index.rs
Normal file
24
crates/erp-core/src/crypto/hmac_index.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// HMAC-SHA256 搜索索引。使用 KEK 派生的独立子密钥,与加密密钥分离。
|
||||||
|
pub fn hmac_hash(key: &[u8; 32], value: &str) -> String {
|
||||||
|
let hmac_key = derive_hmac_key(key);
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("HMAC key length is valid");
|
||||||
|
mac.update(value.as_bytes());
|
||||||
|
hex::encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 KEK 派生独立的 HMAC 子密钥,避免密钥复用
|
||||||
|
fn derive_hmac_key(kek: &[u8; 32]) -> [u8; 32] {
|
||||||
|
use sha2::Digest;
|
||||||
|
let derived = <Sha256 as Digest>::new()
|
||||||
|
.chain_update(b"pii-hmac-index-v1")
|
||||||
|
.chain_update(kek)
|
||||||
|
.finalize();
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&derived);
|
||||||
|
key
|
||||||
|
}
|
||||||
225
crates/erp-core/src/crypto/key_manager.rs
Normal file
225
crates/erp-core/src/crypto/key_manager.rs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
use super::engine;
|
||||||
|
|
||||||
|
/// DEK 缓存条目 — Drop 时清零密钥材料
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CachedDek {
|
||||||
|
dek: [u8; 32],
|
||||||
|
version: u32,
|
||||||
|
loaded_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CachedDek {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.dek.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DEK 缓存管理 — 每租户独立 DEK,LRU + TTL
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DekManager {
|
||||||
|
cache: DashMap<Uuid, CachedDek>,
|
||||||
|
ttl_secs: u64,
|
||||||
|
max_entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DekManager {
|
||||||
|
pub fn new(ttl_secs: u64, max_entries: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
cache: DashMap::new(),
|
||||||
|
ttl_secs,
|
||||||
|
max_entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取或创建租户的 DEK
|
||||||
|
pub fn get_or_create_dek(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
encrypted_dek: Option<&str>,
|
||||||
|
kek: &[u8; 32],
|
||||||
|
) -> AppResult<([u8; 32], u32)> {
|
||||||
|
// 检查缓存
|
||||||
|
if let Some(entry) = self.cache.get(&tenant_id)
|
||||||
|
&& entry.loaded_at.elapsed().as_secs() < self.ttl_secs
|
||||||
|
{
|
||||||
|
return Ok((entry.dek, entry.version));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从加密 DEK 解密
|
||||||
|
if let Some(enc_dek) = encrypted_dek {
|
||||||
|
let dek_hex = engine::decrypt(kek, enc_dek).map_err(AppError::Internal)?;
|
||||||
|
let dek_bytes = hex::decode(&dek_hex).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
if dek_bytes.len() != 32 {
|
||||||
|
return Err(AppError::Internal("DEK must be 32 bytes".into()));
|
||||||
|
}
|
||||||
|
let mut dek = [0u8; 32];
|
||||||
|
dek.copy_from_slice(&dek_bytes);
|
||||||
|
|
||||||
|
// 缓存(版本从外部传入时无法确定,使用默认值 1)
|
||||||
|
self.evict_if_full();
|
||||||
|
self.cache.insert(
|
||||||
|
tenant_id,
|
||||||
|
CachedDek {
|
||||||
|
dek,
|
||||||
|
version: 1,
|
||||||
|
loaded_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Ok((dek, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无现有 DEK → 生成新的
|
||||||
|
let dek = Self::generate_dek();
|
||||||
|
self.evict_if_full();
|
||||||
|
self.cache.insert(
|
||||||
|
tenant_id,
|
||||||
|
CachedDek {
|
||||||
|
dek,
|
||||||
|
version: 1,
|
||||||
|
loaded_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok((dek, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 KEK 加密 DEK 以便存储
|
||||||
|
pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult<String> {
|
||||||
|
let dek_hex = hex::encode(dek);
|
||||||
|
engine::encrypt(kek, &dek_hex).map_err(AppError::Internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成新 DEK 并用 KEK 加密,返回 (新 DEK, 加密后的 DEK)
|
||||||
|
pub fn generate_new_dek(kek: &[u8; 32]) -> AppResult<([u8; 32], String)> {
|
||||||
|
let dek = Self::generate_dek();
|
||||||
|
let encrypted = Self::encrypt_dek_for_storage(&dek, kek)?;
|
||||||
|
Ok((dek, encrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使缓存失效(轮换后调用)
|
||||||
|
pub fn invalidate(&self, tenant_id: Uuid) {
|
||||||
|
self.cache.remove(&tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_dek() -> [u8; 32] {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut dek = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut dek);
|
||||||
|
dek
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evict_if_full(&self) {
|
||||||
|
if self.cache.len() >= self.max_entries {
|
||||||
|
let to_remove: Vec<Uuid> = self
|
||||||
|
.cache
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2)
|
||||||
|
.map(|e| *e.key())
|
||||||
|
.take(self.max_entries / 2)
|
||||||
|
.collect();
|
||||||
|
for id in to_remove {
|
||||||
|
self.cache.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crypto::PiiCrypto;
|
||||||
|
|
||||||
|
fn test_kek() -> [u8; 32] {
|
||||||
|
*PiiCrypto::dev_default().kek()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_uuid(i: u8) -> Uuid {
|
||||||
|
let s = format!("00000000-0000-0000-0000-0000000000{:02x}", i);
|
||||||
|
Uuid::parse_str(&s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_new_dek_returns_32_bytes() {
|
||||||
|
let (dek, _enc) = DekManager::generate_new_dek(&test_kek()).unwrap();
|
||||||
|
assert_eq!(dek.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_new_dek_produces_unique_keys() {
|
||||||
|
let (dek1, _) = DekManager::generate_new_dek(&test_kek()).unwrap();
|
||||||
|
let (dek2, _) = DekManager::generate_new_dek(&test_kek()).unwrap();
|
||||||
|
assert_ne!(dek1, dek2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_dek_roundtrip() {
|
||||||
|
let kek = test_kek();
|
||||||
|
let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap();
|
||||||
|
let mgr = DekManager::new(300, 100);
|
||||||
|
let tenant_id = test_uuid(1);
|
||||||
|
let (recovered_dek, _ver) = mgr
|
||||||
|
.get_or_create_dek(tenant_id, Some(&encrypted), &kek)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(original_dek, recovered_dek);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_or_create_generates_when_none() {
|
||||||
|
let mgr = DekManager::new(300, 100);
|
||||||
|
let tenant_id = test_uuid(2);
|
||||||
|
let (dek1, ver1) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||||
|
assert_eq!(ver1, 1);
|
||||||
|
let (dek2, ver2) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||||
|
assert_eq!(dek1, dek2);
|
||||||
|
assert_eq!(ver2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalidate_removes_cached_dek() {
|
||||||
|
let mgr = DekManager::new(300, 100);
|
||||||
|
let tenant_id = test_uuid(3);
|
||||||
|
let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||||
|
mgr.invalidate(tenant_id);
|
||||||
|
let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||||
|
assert_ne!(dek1, dek2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_with_wrong_kek_fails() {
|
||||||
|
let kek1 = test_kek();
|
||||||
|
let kek2 = [0xffu8; 32];
|
||||||
|
let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap();
|
||||||
|
let mgr = DekManager::new(300, 100);
|
||||||
|
let tenant_id = test_uuid(4);
|
||||||
|
assert!(
|
||||||
|
mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_entry_not_returned() {
|
||||||
|
let mgr = DekManager::new(0, 100);
|
||||||
|
let tenant_id = test_uuid(5);
|
||||||
|
let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||||
|
let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||||
|
assert_ne!(dek1, dek2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_entries_eviction() {
|
||||||
|
let mgr = DekManager::new(300, 3);
|
||||||
|
for i in 0..5u8 {
|
||||||
|
let _ = mgr
|
||||||
|
.get_or_create_dek(test_uuid(i), None, &test_kek())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(mgr.cache.len() <= 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
crates/erp-core/src/crypto/masking.rs
Normal file
113
crates/erp-core/src/crypto/masking.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代
|
||||||
|
pub fn mask_id_number(s: &str) -> String {
|
||||||
|
let chars: Vec<char> = s.chars().collect();
|
||||||
|
if chars.len() >= 7 {
|
||||||
|
let head: String = chars[..3].iter().collect();
|
||||||
|
let tail: String = chars[chars.len() - 4..].iter().collect();
|
||||||
|
format!("{}****{}", head, tail)
|
||||||
|
} else {
|
||||||
|
"****".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代
|
||||||
|
pub fn mask_phone(s: Option<&str>) -> Option<String> {
|
||||||
|
s.map(|p| {
|
||||||
|
let chars: Vec<char> = p.chars().collect();
|
||||||
|
if chars.len() >= 7 {
|
||||||
|
let head: String = chars[..3].iter().collect();
|
||||||
|
let tail: String = chars[chars.len() - 4..].iter().collect();
|
||||||
|
format!("{}****{}", head, tail)
|
||||||
|
} else {
|
||||||
|
"****".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执业证号脱敏: 保留前 2 位和后 2 位,中间用 **** 替代
|
||||||
|
pub fn mask_license_number(s: &str) -> String {
|
||||||
|
let chars: Vec<char> = s.chars().collect();
|
||||||
|
if chars.len() >= 5 {
|
||||||
|
let head: String = chars[..2].iter().collect();
|
||||||
|
let tail: String = chars[chars.len() - 2..].iter().collect();
|
||||||
|
format!("{}****{}", head, tail)
|
||||||
|
} else {
|
||||||
|
"****".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_id_18_digits() {
|
||||||
|
assert_eq!("110****1234", mask_id_number("110101199001011234"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_id_short() {
|
||||||
|
assert_eq!("****", mask_id_number("123456"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_id_empty() {
|
||||||
|
assert_eq!("****", mask_id_number(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_phone_normal() {
|
||||||
|
assert_eq!(
|
||||||
|
Some("138****5678".to_string()),
|
||||||
|
mask_phone(Some("13812345678"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_phone_none() {
|
||||||
|
assert_eq!(None, mask_phone(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_phone_short() {
|
||||||
|
assert_eq!(Some("****".to_string()), mask_phone(Some("123")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_phone_exactly_7() {
|
||||||
|
assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_id_exactly_7() {
|
||||||
|
assert_eq!("123****4567", mask_id_number("1234567"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_id_unicode_safe() {
|
||||||
|
assert_eq!("你好世****cdef", mask_id_number("你好世界abcdef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_phone_unicode_safe() {
|
||||||
|
assert_eq!(
|
||||||
|
Some("你好世****cdef".to_string()),
|
||||||
|
mask_phone(Some("你好世界abcdef"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_license_normal() {
|
||||||
|
assert_eq!("YL****23", mask_license_number("YL-2024-00123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_license_short() {
|
||||||
|
assert_eq!("****", mask_license_number("AB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_license_empty() {
|
||||||
|
assert_eq!("****", mask_license_number(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
234
crates/erp-core/src/crypto/mod.rs
Normal file
234
crates/erp-core/src/crypto/mod.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
pub mod engine;
|
||||||
|
pub mod hmac_index;
|
||||||
|
pub mod key_manager;
|
||||||
|
pub mod masking;
|
||||||
|
|
||||||
|
pub use engine::{decrypt, encrypt};
|
||||||
|
pub use hmac_index::hmac_hash;
|
||||||
|
pub use key_manager::DekManager;
|
||||||
|
pub use masking::{mask_id_number, mask_license_number, mask_phone};
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
/// PII 加密服务 — 封装 KEK 和 DEK 管理
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PiiCrypto {
|
||||||
|
kek: [u8; 32],
|
||||||
|
hmac_key: [u8; 32],
|
||||||
|
pub(crate) dek_manager: DekManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PiiCrypto {
|
||||||
|
/// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex(32 字节)。
|
||||||
|
pub fn from_kek_hex(kek_hex: &str) -> AppResult<Self> {
|
||||||
|
let bytes = hex::decode(kek_hex)
|
||||||
|
.map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(AppError::Internal(
|
||||||
|
"KEK must be 32 bytes (64 hex chars)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut kek = [0u8; 32];
|
||||||
|
kek.copy_from_slice(&bytes);
|
||||||
|
Ok(Self::from_kek(kek))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dev fallback: 从固定字符串派生确定性 KEK。仅用于开发。
|
||||||
|
pub fn dev_default() -> Self {
|
||||||
|
use sha2::Digest;
|
||||||
|
let kek = <sha2::Sha256 as Digest>::digest(b"erp-pii-kek-dev-key-DO-NOT-USE-IN-PROD");
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&kek);
|
||||||
|
Self::from_kek(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_kek(kek: [u8; 32]) -> Self {
|
||||||
|
use sha2::Digest;
|
||||||
|
let hmac_key = <sha2::Sha256 as Digest>::new()
|
||||||
|
.chain_update(b"pii-hmac-index-v1")
|
||||||
|
.chain_update(kek)
|
||||||
|
.finalize();
|
||||||
|
let mut hk = [0u8; 32];
|
||||||
|
hk.copy_from_slice(&hmac_key);
|
||||||
|
Self {
|
||||||
|
kek,
|
||||||
|
hmac_key: hk,
|
||||||
|
dek_manager: DekManager::new(300, 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kek(&self) -> &[u8; 32] {
|
||||||
|
&self.kek
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMAC 搜索索引使用的独立子密钥
|
||||||
|
pub fn hmac_key(&self) -> &[u8; 32] {
|
||||||
|
&self.hmac_key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使指定租户的 DEK 缓存失效
|
||||||
|
pub fn invalidate_dek(&self, tenant_id: uuid::Uuid) {
|
||||||
|
self.dek_manager.invalidate(tenant_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_crypto() -> PiiCrypto {
|
||||||
|
PiiCrypto::dev_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_kek_hex_roundtrip() {
|
||||||
|
let kek_hex = "00".repeat(32);
|
||||||
|
let crypto = PiiCrypto::from_kek_hex(&kek_hex).unwrap();
|
||||||
|
assert_eq!(crypto.kek(), &[0u8; 32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_kek_hex_invalid() {
|
||||||
|
assert!(PiiCrypto::from_kek_hex("not-hex").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_kek_hex_wrong_length() {
|
||||||
|
assert!(PiiCrypto::from_kek_hex("ab").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_roundtrip() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let plaintext = "13812345678";
|
||||||
|
let encrypted = encrypt(crypto.kek(), plaintext).unwrap();
|
||||||
|
let decrypted = decrypt(crypto.kek(), &encrypted).unwrap();
|
||||||
|
assert_eq!(plaintext, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_produces_different_ciphertexts() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let e1 = encrypt(crypto.kek(), "test").unwrap();
|
||||||
|
let e2 = encrypt(crypto.kek(), "test").unwrap();
|
||||||
|
assert_ne!(e1, e2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_wrong_key_fails() {
|
||||||
|
let crypto1 = PiiCrypto::dev_default();
|
||||||
|
let other_key_hex = "ff".repeat(32);
|
||||||
|
let crypto2 = PiiCrypto::from_kek_hex(&other_key_hex).unwrap();
|
||||||
|
let encrypted = encrypt(crypto1.kek(), "test").unwrap();
|
||||||
|
assert!(decrypt(crypto2.kek(), &encrypted).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hmac_hash_deterministic() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let h1 = hmac_hash(crypto.hmac_key(), "13812345678");
|
||||||
|
let h2 = hmac_hash(crypto.hmac_key(), "13812345678");
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hmac_hash_different_inputs() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let h1 = hmac_hash(crypto.hmac_key(), "111");
|
||||||
|
let h2 = hmac_hash(crypto.hmac_key(), "222");
|
||||||
|
assert_ne!(h1, h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hmac_key_differs_from_kek() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
assert_ne!(crypto.kek(), crypto.hmac_key(), "HMAC 密钥应与 KEK 不同");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_empty_string() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let encrypted = encrypt(crypto.kek(), "").unwrap();
|
||||||
|
let decrypted = decrypt(crypto.kek(), &encrypted).unwrap();
|
||||||
|
assert_eq!("", decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_too_short_fails() {
|
||||||
|
use base64::Engine;
|
||||||
|
let short = base64::engine::general_purpose::STANDARD.encode(b"short");
|
||||||
|
assert!(decrypt(&[0u8; 32], &short).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_unicode() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let plaintext = "患者过敏史:青霉素、磺胺类药物";
|
||||||
|
let encrypted = encrypt(crypto.kek(), plaintext).unwrap();
|
||||||
|
let decrypted = decrypt(crypto.kek(), &encrypted).unwrap();
|
||||||
|
assert_eq!(plaintext, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ciphertext_has_version_prefix() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let encrypted = encrypt(crypto.kek(), "test").unwrap();
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&encrypted)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bytes[0], 0x01, "密文首字节应为版本号 0x01");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 性能基准 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bench_encrypt_1000() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let kek = crypto.kek();
|
||||||
|
let plaintext = "13812345678";
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
for _ in 0..1000 {
|
||||||
|
let _ = encrypt(kek, plaintext).unwrap();
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
let avg_us = elapsed.as_micros() / 1000;
|
||||||
|
assert!(avg_us < 50, "encrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
|
||||||
|
eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bench_decrypt_1000() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let kek = crypto.kek();
|
||||||
|
let ciphertext = encrypt(kek, "13812345678").unwrap();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
for _ in 0..1000 {
|
||||||
|
let _ = decrypt(kek, &ciphertext).unwrap();
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
let avg_us = elapsed.as_micros() / 1000;
|
||||||
|
assert!(avg_us < 50, "decrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
|
||||||
|
eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bench_batch_decrypt_50() {
|
||||||
|
let crypto = test_crypto();
|
||||||
|
let kek = crypto.kek();
|
||||||
|
let ciphertexts: Vec<String> = (0..50)
|
||||||
|
.map(|i| encrypt(kek, &format!("数据{}", i)).unwrap())
|
||||||
|
.collect();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
for ct in &ciphertexts {
|
||||||
|
let _ = decrypt(kek, ct).unwrap();
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
assert!(
|
||||||
|
elapsed.as_millis() < 10,
|
||||||
|
"批量解密 50 条应 < 10ms, 实际: {}ms",
|
||||||
|
elapsed.as_millis()
|
||||||
|
);
|
||||||
|
eprintln!("batch decrypt 50 条: {:?}", elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/erp-core/src/entity/audit_log.rs
Normal file
29
crates/erp-core/src/entity/audit_log.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 审计日志实体 — 映射 audit_logs 表。
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "audit_logs")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub action: String,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub resource_id: Option<Uuid>,
|
||||||
|
pub old_value: Option<serde_json::Value>,
|
||||||
|
pub new_value: Option<serde_json::Value>,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
/// 哈希链 — 前一条记录的 record_hash
|
||||||
|
pub prev_hash: Option<String>,
|
||||||
|
/// 当前记录的哈希 SHA256(id + action + resource_type + resource_id + created_at + prev_hash)
|
||||||
|
pub record_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
27
crates/erp-core/src/entity/dead_letter_event.rs
Normal file
27
crates/erp-core/src/entity/dead_letter_event.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "dead_letter_events")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tenant_id: Option<Uuid>,
|
||||||
|
pub original_event_id: Uuid,
|
||||||
|
pub event_type: String,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub payload: Option<serde_json::Value>,
|
||||||
|
pub consumer_id: String,
|
||||||
|
pub attempts: i32,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resolved_at: Option<DateTimeUtc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
24
crates/erp-core/src/entity/domain_event.rs
Normal file
24
crates/erp-core/src/entity/domain_event.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 领域事件实体 — 映射 domain_events 表。
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "domain_events")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub event_type: String,
|
||||||
|
pub payload: Option<serde_json::Value>,
|
||||||
|
pub correlation_id: Option<Uuid>,
|
||||||
|
pub status: String,
|
||||||
|
pub attempts: i32,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub published_at: Option<DateTimeUtc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
4
crates/erp-core/src/entity/mod.rs
Normal file
4
crates/erp-core/src/entity/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod audit_log;
|
||||||
|
pub mod dead_letter_event;
|
||||||
|
pub mod domain_event;
|
||||||
|
pub mod processed_event;
|
||||||
18
crates/erp-core/src/entity/processed_event.rs
Normal file
18
crates/erp-core/src/entity/processed_event.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 已处理事件记录 — 幂等性去重表。
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "processed_events")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub event_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub consumer_id: String,
|
||||||
|
pub processed_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
188
crates/erp-core/src/error.rs
Normal file
188
crates/erp-core/src/error.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// 统一错误响应格式
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 平台级错误类型
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("资源未找到: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("验证失败: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("未授权")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[error("禁止访问: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("冲突: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||||
|
VersionMismatch,
|
||||||
|
|
||||||
|
#[error("请求过于频繁,请稍后重试")]
|
||||||
|
TooManyRequests,
|
||||||
|
|
||||||
|
#[error("内部错误: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
|
||||||
|
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
|
||||||
|
AppError::Internal(msg) => {
|
||||||
|
tracing::error!("Internal error: {}", msg);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ErrorResponse {
|
||||||
|
error: status.canonical_reason().unwrap_or("Error").to_string(),
|
||||||
|
message,
|
||||||
|
details: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for AppError {
|
||||||
|
fn from(err: anyhow::Error) -> Self {
|
||||||
|
AppError::Internal(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::DbErr> for AppError {
|
||||||
|
fn from(err: sea_orm::DbErr) -> Self {
|
||||||
|
match err {
|
||||||
|
sea_orm::DbErr::RecordNotFound(msg) => AppError::NotFound(msg),
|
||||||
|
sea_orm::DbErr::Query(sea_orm::RuntimeErr::SqlxError(e))
|
||||||
|
if e.to_string().contains("duplicate key") =>
|
||||||
|
{
|
||||||
|
AppError::Conflict("记录已存在".to_string())
|
||||||
|
}
|
||||||
|
_ => AppError::Internal(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
/// 检查乐观锁版本是否匹配。
|
||||||
|
///
|
||||||
|
/// 返回下一个版本号(actual + 1),或 VersionMismatch 错误。
|
||||||
|
pub fn check_version(expected: i32, actual: i32) -> AppResult<i32> {
|
||||||
|
if expected == actual {
|
||||||
|
Ok(actual + 1)
|
||||||
|
} else {
|
||||||
|
Err(AppError::VersionMismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_version_ok() {
|
||||||
|
assert_eq!(check_version(1, 1).unwrap(), 2);
|
||||||
|
assert_eq!(check_version(5, 5).unwrap(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_version_mismatch() {
|
||||||
|
let result = check_version(1, 2);
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
AppError::VersionMismatch => {}
|
||||||
|
other => panic!("Expected VersionMismatch, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_err_record_not_found_maps_to_not_found() {
|
||||||
|
let err = sea_orm::DbErr::RecordNotFound("test".to_string());
|
||||||
|
let app_err: AppError = err.into();
|
||||||
|
match app_err {
|
||||||
|
AppError::NotFound(msg) => assert_eq!(msg, "test"),
|
||||||
|
other => panic!("Expected NotFound, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_err_generic_maps_to_internal() {
|
||||||
|
let db_err = sea_orm::DbErr::Custom("some error".to_string());
|
||||||
|
let app_err: AppError = db_err.into();
|
||||||
|
match app_err {
|
||||||
|
AppError::Internal(msg) => assert!(msg.contains("some error")),
|
||||||
|
other => panic!("Expected Internal, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_error_into_response_status_codes() {
|
||||||
|
// NotFound -> 404
|
||||||
|
let resp = AppError::NotFound("test".to_string()).into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// Validation -> 400
|
||||||
|
let resp = AppError::Validation("bad input".to_string()).into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Unauthorized -> 401
|
||||||
|
let resp = AppError::Unauthorized.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
// Forbidden -> 403
|
||||||
|
let resp = AppError::Forbidden("no access".to_string()).into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// VersionMismatch -> 409
|
||||||
|
let resp = AppError::VersionMismatch.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// TooManyRequests -> 429
|
||||||
|
let resp = AppError::TooManyRequests.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
|
||||||
|
// Internal -> 500
|
||||||
|
let resp = AppError::Internal("oops".to_string()).into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_error_internal_hides_details_from_response() {
|
||||||
|
// Internal errors should map to 500 with a generic message
|
||||||
|
let resp = AppError::Internal("sensitive db error detail".to_string()).into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anyhow_error_maps_to_internal() {
|
||||||
|
let err: AppError = anyhow::anyhow!("something went wrong").into();
|
||||||
|
match err {
|
||||||
|
AppError::Internal(msg) => assert_eq!(msg, "something went wrong"),
|
||||||
|
other => panic!("Expected Internal, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
458
crates/erp-core/src/events.rs
Normal file
458
crates/erp-core/src/events.rs
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
use tracing::{error, info};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entity::dead_letter_event;
|
||||||
|
use crate::entity::domain_event;
|
||||||
|
|
||||||
|
/// 已知的 PII 字段列表 -- 在事件 payload 中自动脱敏
|
||||||
|
const PII_FIELDS: &[&str] = &[
|
||||||
|
"phone",
|
||||||
|
"id_number",
|
||||||
|
"emergency_contact_phone",
|
||||||
|
"emergency_contact_name",
|
||||||
|
"medical_history_summary",
|
||||||
|
"allergy_history",
|
||||||
|
"content",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 递归脱敏 payload 中的 PII 字段(原地修改)。
|
||||||
|
fn sanitize_payload(payload: &mut serde_json::Value) {
|
||||||
|
if let Some(obj) = payload.as_object_mut() {
|
||||||
|
for field in PII_FIELDS {
|
||||||
|
if let Some(val) = obj.get_mut(*field)
|
||||||
|
&& val.is_string()
|
||||||
|
{
|
||||||
|
*val = serde_json::Value::String("[REDACTED]".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for val in obj.values_mut() {
|
||||||
|
if val.is_object() {
|
||||||
|
sanitize_payload(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 领域事件
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DomainEvent {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub event_type: String,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub timestamp: chrono::DateTime<Utc>,
|
||||||
|
pub correlation_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainEvent {
|
||||||
|
pub fn new(event_type: impl Into<String>, tenant_id: Uuid, payload: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
event_type: event_type.into(),
|
||||||
|
tenant_id,
|
||||||
|
payload,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
correlation_id: Uuid::now_v7(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前事件 payload schema 版本
|
||||||
|
pub const EVENT_SCHEMA_VERSION: &str = "v1";
|
||||||
|
|
||||||
|
/// 构造统一信封格式的事件 payload。
|
||||||
|
///
|
||||||
|
/// 自动注入 `schema_version` 和 `occurred_at`,业务数据通过 `data` 传入。
|
||||||
|
/// 用法:`build_event_payload(serde_json::json!({ "patient_id": ..., }))`
|
||||||
|
pub fn build_event_payload(data: serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut envelope = serde_json::json!({
|
||||||
|
"schema_version": EVENT_SCHEMA_VERSION,
|
||||||
|
"occurred_at": Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
if let serde_json::Value::Object(ref mut map) = envelope
|
||||||
|
&& let serde_json::Value::Object(data_map) = data
|
||||||
|
{
|
||||||
|
for (k, v) in data_map {
|
||||||
|
map.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查事件是否已被指定消费者处理。
|
||||||
|
///
|
||||||
|
/// 查询 `processed_events` 表判断 event_id + consumer_id 是否已存在。
|
||||||
|
pub async fn is_event_processed(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_id: Uuid,
|
||||||
|
consumer_id: &str,
|
||||||
|
) -> Result<bool, sea_orm::DbErr> {
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
|
||||||
|
let count = crate::entity::processed_event::Entity::find()
|
||||||
|
.filter(crate::entity::processed_event::Column::EventId.eq(event_id))
|
||||||
|
.filter(crate::entity::processed_event::Column::ConsumerId.eq(consumer_id))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记事件已被指定消费者处理。
|
||||||
|
///
|
||||||
|
/// 插入 `processed_events` 记录,重复插入会因主键冲突被安全忽略。
|
||||||
|
pub async fn mark_event_processed(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_id: Uuid,
|
||||||
|
consumer_id: &str,
|
||||||
|
) -> Result<(), sea_orm::DbErr> {
|
||||||
|
use sea_orm::ActiveModelTrait;
|
||||||
|
use sea_orm::Set;
|
||||||
|
|
||||||
|
let model = crate::entity::processed_event::ActiveModel {
|
||||||
|
event_id: Set(event_id),
|
||||||
|
consumer_id: Set(consumer_id.to_string()),
|
||||||
|
processed_at: Set(Utc::now()),
|
||||||
|
};
|
||||||
|
// INSERT ... ON CONFLICT DO NOTHING(主键冲突时安全忽略)
|
||||||
|
match model.insert(db).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
// 唯一约束冲突 = 已处理,不是错误
|
||||||
|
if e.to_string().contains("duplicate") || e.to_string().contains("violates unique") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消费事件 — 带幂等检查和 dead-letter 兜底。
|
||||||
|
///
|
||||||
|
/// 如果事件已被处理(幂等),返回 `ConsumeResult::AlreadyProcessed`。
|
||||||
|
/// 如果处理成功,标记为已处理并返回 `ConsumeResult::Success`。
|
||||||
|
/// 如果处理失败,将事件转入 dead_letter_events 表并返回 `ConsumeResult::DeadLettered`。
|
||||||
|
pub async fn consume_with_retry<F, Fut>(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event: &DomainEvent,
|
||||||
|
consumer_id: &str,
|
||||||
|
handler: F,
|
||||||
|
) -> ConsumeResult
|
||||||
|
where
|
||||||
|
F: FnOnce(&DomainEvent) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<(), String>>,
|
||||||
|
{
|
||||||
|
if is_event_processed(db, event.id, consumer_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return ConsumeResult::AlreadyProcessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
match handler(event).await {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Err(e) = mark_event_processed(db, event.id, consumer_id).await {
|
||||||
|
tracing::warn!(
|
||||||
|
event_id = %event.id,
|
||||||
|
consumer_id = consumer_id,
|
||||||
|
error = %e,
|
||||||
|
"标记事件已处理失败(非致命)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConsumeResult::Success
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(
|
||||||
|
event_id = %event.id,
|
||||||
|
event_type = %event.event_type,
|
||||||
|
consumer_id = consumer_id,
|
||||||
|
error = %err,
|
||||||
|
"事件消费失败,转入 dead-letter"
|
||||||
|
);
|
||||||
|
if let Err(e) = insert_dead_letter(db, event, consumer_id, &err).await {
|
||||||
|
tracing::error!(
|
||||||
|
event_id = %event.id,
|
||||||
|
error = %e,
|
||||||
|
"Dead-letter 写入失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConsumeResult::DeadLettered(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消费结果
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConsumeResult {
|
||||||
|
Success,
|
||||||
|
AlreadyProcessed,
|
||||||
|
DeadLettered(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将失败事件写入 dead_letter_events 表
|
||||||
|
pub async fn insert_dead_letter(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event: &DomainEvent,
|
||||||
|
consumer_id: &str,
|
||||||
|
error_msg: &str,
|
||||||
|
) -> Result<(), sea_orm::DbErr> {
|
||||||
|
let model = dead_letter_event::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(Some(event.tenant_id)),
|
||||||
|
original_event_id: Set(event.id),
|
||||||
|
event_type: Set(event.event_type.clone()),
|
||||||
|
payload: Set(Some(event.payload.clone())),
|
||||||
|
consumer_id: Set(consumer_id.to_string()),
|
||||||
|
attempts: Set(1),
|
||||||
|
last_error: Set(Some(error_msg.to_string())),
|
||||||
|
created_at: Set(Utc::now()),
|
||||||
|
resolved_at: Set(None),
|
||||||
|
};
|
||||||
|
model.insert(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 过滤事件接收器 — 只接收匹配 `event_type_prefix` 的事件
|
||||||
|
pub struct FilteredEventReceiver {
|
||||||
|
receiver: mpsc::Receiver<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilteredEventReceiver {
|
||||||
|
/// 接收下一个匹配的事件
|
||||||
|
pub async fn recv(&mut self) -> Option<DomainEvent> {
|
||||||
|
self.receiver.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订阅句柄 — 用于取消过滤订阅
|
||||||
|
pub struct SubscriptionHandle {
|
||||||
|
cancel_tx: mpsc::Sender<()>,
|
||||||
|
join_handle: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriptionHandle {
|
||||||
|
/// 取消订阅并等待后台任务结束
|
||||||
|
pub async fn cancel(self) {
|
||||||
|
let _ = self.cancel_tx.send(()).await;
|
||||||
|
let _ = self.join_handle.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进程内事件总线
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EventBus {
|
||||||
|
sender: broadcast::Sender<DomainEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus {
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
let (sender, _) = broadcast::channel(capacity);
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布事件:先持久化到 domain_events 表(pending 状态),再内存广播,
|
||||||
|
/// 最后更新为 published 并 NOTIFY outbox relay。
|
||||||
|
///
|
||||||
|
/// 两阶段提交保证:即使广播后服务崩溃,事件仍为 pending 状态,
|
||||||
|
/// 重启后 outbox relay 会重新广播。
|
||||||
|
pub async fn publish(&self, mut event: DomainEvent, db: &sea_orm::DatabaseConnection) {
|
||||||
|
// 0. 脱敏 payload 中的 PII 字段
|
||||||
|
sanitize_payload(&mut event.payload);
|
||||||
|
|
||||||
|
// 1. 持久化为 pending 状态
|
||||||
|
let event_id = event.id;
|
||||||
|
let model = domain_event::ActiveModel {
|
||||||
|
id: Set(event.id),
|
||||||
|
tenant_id: Set(event.tenant_id),
|
||||||
|
event_type: Set(event.event_type.clone()),
|
||||||
|
payload: Set(Some(event.payload.clone())),
|
||||||
|
correlation_id: Set(Some(event.correlation_id)),
|
||||||
|
status: Set("pending".to_string()),
|
||||||
|
attempts: Set(0),
|
||||||
|
last_error: Set(None),
|
||||||
|
created_at: Set(event.timestamp),
|
||||||
|
published_at: Set(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved = match model.insert(db).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(event_id = %event_id, error = %e, "领域事件持久化失败");
|
||||||
|
// 持久化失败仍然广播(best-effort)
|
||||||
|
self.broadcast(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 内存广播
|
||||||
|
self.broadcast(event);
|
||||||
|
|
||||||
|
// 3. 更新为 published
|
||||||
|
let mut active: domain_event::ActiveModel = saved.into();
|
||||||
|
active.status = Set("published".to_string());
|
||||||
|
active.published_at = Set(Some(Utc::now()));
|
||||||
|
if let Err(e) = active.update(db).await {
|
||||||
|
tracing::warn!(event_id = %event_id, error = %e, "领域事件状态更新为 published 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. NOTIFY outbox relay(通知 outbox relay 有新事件到达)
|
||||||
|
let notify_sql = sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
format!("NOTIFY outbox_channel, '{}'", event_id),
|
||||||
|
);
|
||||||
|
if let Err(e) = db.execute(notify_sql).await {
|
||||||
|
tracing::debug!(event_id = %event_id, error = %e, "NOTIFY outbox_channel 失败(非致命)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅内存广播(不持久化,用于内部测试等场景)。
|
||||||
|
pub fn broadcast(&self, event: DomainEvent) {
|
||||||
|
info!(event_type = %event.event_type, event_id = %event.id, "Event broadcast");
|
||||||
|
if let Err(e) = self.sender.send(event) {
|
||||||
|
error!("Failed to broadcast event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订阅所有事件,返回接收端
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> {
|
||||||
|
self.sender.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按事件类型前缀过滤订阅。
|
||||||
|
///
|
||||||
|
/// 为每次调用 spawn 一个 Tokio task 从 broadcast channel 读取,
|
||||||
|
/// 只转发匹配 `event_type_prefix` 的事件到 mpsc channel(capacity 256)。
|
||||||
|
pub fn subscribe_filtered(
|
||||||
|
&self,
|
||||||
|
event_type_prefix: String,
|
||||||
|
) -> (FilteredEventReceiver, SubscriptionHandle) {
|
||||||
|
let mut broadcast_rx = self.sender.subscribe();
|
||||||
|
let (mpsc_tx, mpsc_rx) = mpsc::channel(256);
|
||||||
|
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
||||||
|
|
||||||
|
let prefix = event_type_prefix.clone();
|
||||||
|
let join_handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancel_rx.recv() => {
|
||||||
|
tracing::info!(prefix = %prefix, "Filtered subscription cancelled");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
event = broadcast_rx.recv() => {
|
||||||
|
match event {
|
||||||
|
Ok(event) => {
|
||||||
|
if event.event_type.starts_with(&prefix)
|
||||||
|
&& mpsc_tx.send(event).await.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!(prefix = %prefix, lagged = n, "Filtered subscriber lagged");
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(prefix = %event_type_prefix, "Filtered subscription created");
|
||||||
|
|
||||||
|
(
|
||||||
|
FilteredEventReceiver { receiver: mpsc_rx },
|
||||||
|
SubscriptionHandle {
|
||||||
|
cancel_tx,
|
||||||
|
join_handle,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试 dead_letter_events 中未解决的失败事件(指数退避)。
|
||||||
|
pub async fn retry_dead_letters(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
bus: &EventBus,
|
||||||
|
max_attempts: i32,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
// 1. 查询所有未解决且未超过最大重试次数的 dead-letter
|
||||||
|
let pending = dead_letter_event::Entity::find()
|
||||||
|
.filter(dead_letter_event::Column::ResolvedAt.is_null())
|
||||||
|
.filter(dead_letter_event::Column::Attempts.lt(max_attempts))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("查询 dead_letter_events 失败: {}", e))?;
|
||||||
|
|
||||||
|
let retried = pending.len() as u64;
|
||||||
|
|
||||||
|
for dl in &pending {
|
||||||
|
let event = DomainEvent {
|
||||||
|
id: dl.original_event_id,
|
||||||
|
event_type: dl.event_type.clone(),
|
||||||
|
tenant_id: dl.tenant_id.unwrap_or(Uuid::nil()),
|
||||||
|
payload: dl.payload.clone().unwrap_or(serde_json::Value::Null),
|
||||||
|
timestamp: dl.created_at,
|
||||||
|
correlation_id: Uuid::now_v7(),
|
||||||
|
};
|
||||||
|
bus.broadcast(event);
|
||||||
|
|
||||||
|
let mut active: dead_letter_event::ActiveModel = dl.clone().into();
|
||||||
|
let new_attempts = dl.attempts + 1;
|
||||||
|
active.attempts = Set(new_attempts);
|
||||||
|
active.last_error = Set(Some(format!(
|
||||||
|
"第 {} 次自动重试({})",
|
||||||
|
new_attempts,
|
||||||
|
Utc::now().to_rfc3339()
|
||||||
|
)));
|
||||||
|
if let Err(e) = active.update(db).await {
|
||||||
|
tracing::warn!(
|
||||||
|
dead_letter_id = %dl.id,
|
||||||
|
error = %e,
|
||||||
|
"更新 dead_letter_events attempts 失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 标记超过最大重试次数的记录为永久失败
|
||||||
|
let exhausted = dead_letter_event::Entity::find()
|
||||||
|
.filter(dead_letter_event::Column::ResolvedAt.is_null())
|
||||||
|
.filter(dead_letter_event::Column::Attempts.gte(max_attempts))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("查询超限 dead_letter_events 失败: {}", e))?;
|
||||||
|
|
||||||
|
for dl in &exhausted {
|
||||||
|
let mut active: dead_letter_event::ActiveModel = dl.clone().into();
|
||||||
|
active.resolved_at = Set(Some(Utc::now()));
|
||||||
|
active.last_error = Set(Some(format!(
|
||||||
|
"已达最大重试次数 {},标记为永久失败",
|
||||||
|
max_attempts
|
||||||
|
)));
|
||||||
|
if let Err(e) = active.update(db).await {
|
||||||
|
tracing::warn!(
|
||||||
|
dead_letter_id = %dl.id,
|
||||||
|
error = %e,
|
||||||
|
"标记 dead_letter_event 为永久失败时更新失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if retried > 0 || !exhausted.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
retried = retried,
|
||||||
|
permanently_failed = exhausted.len(),
|
||||||
|
"Dead-letter 自动重试完成"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(retried)
|
||||||
|
}
|
||||||
19
crates/erp-core/src/lib.rs
Normal file
19
crates/erp-core/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
pub mod aggregate;
|
||||||
|
pub mod audit;
|
||||||
|
pub mod audit_service;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod entity;
|
||||||
|
pub mod error;
|
||||||
|
pub mod events;
|
||||||
|
pub mod module;
|
||||||
|
pub mod rbac;
|
||||||
|
pub mod request_info;
|
||||||
|
pub mod sanitize;
|
||||||
|
pub mod sea_orm_ext;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test_helpers;
|
||||||
|
|
||||||
|
// 便捷导出
|
||||||
|
pub use module::{ModuleContext, ModuleType, PermissionDescriptor};
|
||||||
357
crates/erp-core/src/module.rs
Normal file
357
crates/erp-core/src/module.rs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::events::EventBus;
|
||||||
|
|
||||||
|
/// 权限描述符,用于模块声明自己需要的权限。
|
||||||
|
///
|
||||||
|
/// 各业务模块通过 `ErpModule::permissions()` 返回此列表,
|
||||||
|
/// 由 erp-server 在启动时统一注册到权限表。
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PermissionDescriptor {
|
||||||
|
/// 权限编码,全局唯一,格式建议 `{模块}.{动作}` 如 `plugin.admin`
|
||||||
|
pub code: String,
|
||||||
|
/// 权限显示名称
|
||||||
|
pub name: String,
|
||||||
|
/// 权限描述
|
||||||
|
pub description: String,
|
||||||
|
/// 所属模块名称
|
||||||
|
pub module: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ModuleType {
|
||||||
|
/// 内置模块(编译时链接)
|
||||||
|
Builtin,
|
||||||
|
/// 插件模块(运行时加载)
|
||||||
|
Plugin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块启动上下文 — 在 on_startup 时提供给模块
|
||||||
|
pub struct ModuleContext {
|
||||||
|
pub db: sea_orm::DatabaseConnection,
|
||||||
|
pub event_bus: EventBus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块注册接口
|
||||||
|
/// 所有业务模块(Auth, Workflow, Message, Config, 行业模块)都实现此 trait
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait ErpModule: Send + Sync {
|
||||||
|
/// 模块名称(唯一标识)
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// 模块唯一 ID(默认等于 name)
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
self.name()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块版本
|
||||||
|
fn version(&self) -> &str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块类型
|
||||||
|
fn module_type(&self) -> ModuleType {
|
||||||
|
ModuleType::Builtin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依赖的其他模块名称
|
||||||
|
fn dependencies(&self) -> Vec<&str> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册事件处理器
|
||||||
|
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||||
|
|
||||||
|
/// 模块启动钩子 — 服务启动时调用
|
||||||
|
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块关闭钩子 — 服务关闭时调用
|
||||||
|
async fn on_shutdown(&self) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康检查
|
||||||
|
async fn health_check(&self) -> AppResult<serde_json::Value> {
|
||||||
|
Ok(serde_json::json!({"status": "healthy"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 租户创建时的初始化钩子。
|
||||||
|
///
|
||||||
|
/// 用于为新建租户创建默认角色、管理员用户等初始数据。
|
||||||
|
async fn on_tenant_created(
|
||||||
|
&self,
|
||||||
|
_tenant_id: Uuid,
|
||||||
|
_db: &sea_orm::DatabaseConnection,
|
||||||
|
_event_bus: &EventBus,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 租户删除时的清理钩子。
|
||||||
|
///
|
||||||
|
/// 用于软删除该租户下的所有关联数据。
|
||||||
|
async fn on_tenant_deleted(
|
||||||
|
&self,
|
||||||
|
_tenant_id: Uuid,
|
||||||
|
_db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回此模块需要注册的权限列表。
|
||||||
|
///
|
||||||
|
/// 默认返回空列表,有权限需求的模块(如 plugin)可覆写此方法。
|
||||||
|
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downcast support: return `self` as `&dyn Any` for concrete type access.
|
||||||
|
///
|
||||||
|
/// This allows the server crate to retrieve module-specific methods
|
||||||
|
/// (e.g. `AuthModule::public_routes()`) that are not part of the trait.
|
||||||
|
fn as_any(&self) -> &dyn Any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块注册器 — 用 Arc 包装使其可 Clone(用于 Axum State)
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ModuleRegistry {
|
||||||
|
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
modules: Arc::new(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(mut self, module: impl ErpModule + 'static) -> Self {
|
||||||
|
tracing::info!(
|
||||||
|
module = module.name(),
|
||||||
|
id = module.id(),
|
||||||
|
version = module.version(),
|
||||||
|
module_type = ?module.module_type(),
|
||||||
|
"Module registered"
|
||||||
|
);
|
||||||
|
let mut modules = (*self.modules).clone();
|
||||||
|
modules.push(Arc::new(module));
|
||||||
|
self.modules = Arc::new(modules);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_handlers(&self, bus: &EventBus) {
|
||||||
|
for module in self.modules.iter() {
|
||||||
|
module.register_event_handlers(bus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn modules(&self) -> &[Arc<dyn ErpModule>] {
|
||||||
|
&self.modules
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按名称获取模块
|
||||||
|
pub fn get_module(&self, name: &str) -> Option<Arc<dyn ErpModule>> {
|
||||||
|
self.modules.iter().find(|m| m.name() == name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按拓扑排序返回模块(依赖在前,被依赖在后)
|
||||||
|
///
|
||||||
|
/// 使用 Kahn 算法,环检测返回 Validation 错误。
|
||||||
|
pub fn sorted_modules(&self) -> AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||||
|
let modules = &*self.modules;
|
||||||
|
let n = modules.len();
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建名称到索引的映射
|
||||||
|
let name_to_idx: HashMap<&str, usize> = modules
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| (m.name(), i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 构建邻接表和入度
|
||||||
|
let mut adjacency: Vec<Vec<usize>> = vec![vec![]; n];
|
||||||
|
let mut in_degree: Vec<usize> = vec![0; n];
|
||||||
|
|
||||||
|
for (idx, module) in modules.iter().enumerate() {
|
||||||
|
for dep in module.dependencies() {
|
||||||
|
if let Some(&dep_idx) = name_to_idx.get(dep) {
|
||||||
|
adjacency[dep_idx].push(idx);
|
||||||
|
in_degree[idx] += 1;
|
||||||
|
}
|
||||||
|
// 依赖未注册的模块不阻断(可能是可选依赖)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn 算法
|
||||||
|
let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
|
||||||
|
let mut sorted_indices = Vec::with_capacity(n);
|
||||||
|
|
||||||
|
while let Some(idx) = queue.pop() {
|
||||||
|
sorted_indices.push(idx);
|
||||||
|
for &next in &adjacency[idx] {
|
||||||
|
in_degree[next] -= 1;
|
||||||
|
if in_degree[next] == 0 {
|
||||||
|
queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sorted_indices.len() != n {
|
||||||
|
let cycle_modules: Vec<&str> = (0..n)
|
||||||
|
.filter(|i| !sorted_indices.contains(i))
|
||||||
|
.filter_map(|i| modules.get(i).map(|m| m.name()))
|
||||||
|
.collect();
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"模块依赖存在循环: {}",
|
||||||
|
cycle_modules.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sorted_indices
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| modules[i].clone())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按拓扑顺序启动所有模块
|
||||||
|
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()> {
|
||||||
|
let sorted = self.sorted_modules()?;
|
||||||
|
for module in sorted {
|
||||||
|
tracing::info!(module = module.name(), "Starting module");
|
||||||
|
module.on_startup(ctx).await?;
|
||||||
|
tracing::info!(module = module.name(), "Module started");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按拓扑逆序关闭所有模块
|
||||||
|
pub async fn shutdown_all(&self) -> AppResult<()> {
|
||||||
|
let sorted = self.sorted_modules()?;
|
||||||
|
for module in sorted.into_iter().rev() {
|
||||||
|
tracing::info!(module = module.name(), "Shutting down module");
|
||||||
|
if let Err(e) = module.on_shutdown().await {
|
||||||
|
tracing::error!(module = module.name(), error = %e, "Module shutdown failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对所有模块执行健康检查
|
||||||
|
pub async fn health_check_all(&self) -> Vec<(String, AppResult<serde_json::Value>)> {
|
||||||
|
let mut results = Vec::with_capacity(self.modules.len());
|
||||||
|
for module in self.modules.iter() {
|
||||||
|
let result = module.health_check().await;
|
||||||
|
results.push((module.name().to_string(), result));
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct TestModule {
|
||||||
|
name: &'static str,
|
||||||
|
deps: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ErpModule for TestModule {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
fn dependencies(&self) -> Vec<&str> {
|
||||||
|
self.deps.clone()
|
||||||
|
}
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorted_modules_empty() {
|
||||||
|
let registry = ModuleRegistry::new();
|
||||||
|
let sorted = registry.sorted_modules().unwrap();
|
||||||
|
assert!(sorted.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorted_modules_no_deps() {
|
||||||
|
let registry = ModuleRegistry::new()
|
||||||
|
.register(TestModule {
|
||||||
|
name: "a",
|
||||||
|
deps: vec![],
|
||||||
|
})
|
||||||
|
.register(TestModule {
|
||||||
|
name: "b",
|
||||||
|
deps: vec![],
|
||||||
|
});
|
||||||
|
let sorted = registry.sorted_modules().unwrap();
|
||||||
|
assert_eq!(sorted.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorted_modules_with_deps() {
|
||||||
|
let registry = ModuleRegistry::new()
|
||||||
|
.register(TestModule {
|
||||||
|
name: "auth",
|
||||||
|
deps: vec![],
|
||||||
|
})
|
||||||
|
.register(TestModule {
|
||||||
|
name: "plugin",
|
||||||
|
deps: vec!["auth", "config"],
|
||||||
|
})
|
||||||
|
.register(TestModule {
|
||||||
|
name: "config",
|
||||||
|
deps: vec!["auth"],
|
||||||
|
});
|
||||||
|
let sorted = registry.sorted_modules().unwrap();
|
||||||
|
let names: Vec<&str> = sorted.iter().map(|m| m.name()).collect();
|
||||||
|
let auth_pos = names.iter().position(|&n| n == "auth").unwrap();
|
||||||
|
let config_pos = names.iter().position(|&n| n == "config").unwrap();
|
||||||
|
let plugin_pos = names.iter().position(|&n| n == "plugin").unwrap();
|
||||||
|
assert!(auth_pos < config_pos);
|
||||||
|
assert!(config_pos < plugin_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorted_modules_circular_dep() {
|
||||||
|
let registry = ModuleRegistry::new()
|
||||||
|
.register(TestModule {
|
||||||
|
name: "a",
|
||||||
|
deps: vec!["b"],
|
||||||
|
})
|
||||||
|
.register(TestModule {
|
||||||
|
name: "b",
|
||||||
|
deps: vec!["a"],
|
||||||
|
});
|
||||||
|
let result = registry.sorted_modules();
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.err().unwrap() {
|
||||||
|
AppError::Validation(msg) => assert!(msg.contains("循环")),
|
||||||
|
other => panic!("Expected Validation, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_module_found() {
|
||||||
|
let registry = ModuleRegistry::new().register(TestModule {
|
||||||
|
name: "auth",
|
||||||
|
deps: vec![],
|
||||||
|
});
|
||||||
|
assert!(registry.get_module("auth").is_some());
|
||||||
|
assert!(registry.get_module("unknown").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/erp-core/src/rbac.rs
Normal file
102
crates/erp-core/src/rbac.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use crate::error::AppError;
|
||||||
|
use crate::types::{DataScope, TenantContext};
|
||||||
|
|
||||||
|
/// Check whether the `TenantContext` includes the specified permission code.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` if the permission is present, or `AppError::Forbidden` otherwise.
|
||||||
|
pub fn require_permission(ctx: &TenantContext, permission: &str) -> Result<(), AppError> {
|
||||||
|
if ctx.permissions.iter().any(|p| p == permission) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::Forbidden("权限不足".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the `TenantContext` includes at least one of the specified permission codes.
|
||||||
|
///
|
||||||
|
/// Useful when multiple permissions can grant access to the same resource.
|
||||||
|
pub fn require_any_permission(ctx: &TenantContext, permissions: &[&str]) -> Result<(), AppError> {
|
||||||
|
let has_any = permissions
|
||||||
|
.iter()
|
||||||
|
.any(|p| ctx.permissions.iter().any(|up| up == *p));
|
||||||
|
|
||||||
|
if has_any {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::Forbidden("权限不足".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the `TenantContext` includes the specified role code.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` if the role is present, or `AppError::Forbidden` otherwise.
|
||||||
|
pub fn require_role(ctx: &TenantContext, role: &str) -> Result<(), AppError> {
|
||||||
|
if ctx.roles.iter().any(|r| r == role) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::Forbidden("权限不足".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定权限的数据范围。默认 All(向后兼容)。
|
||||||
|
///
|
||||||
|
/// Service 层根据返回值追加对应的查询过滤条件。
|
||||||
|
pub fn get_data_scope(ctx: &TenantContext, permission: &str) -> DataScope {
|
||||||
|
ctx.permission_data_scopes
|
||||||
|
.get(permission)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataScope::All)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn test_ctx(roles: Vec<&str>, permissions: Vec<&str>) -> TenantContext {
|
||||||
|
TenantContext {
|
||||||
|
tenant_id: Uuid::now_v7(),
|
||||||
|
user_id: Uuid::now_v7(),
|
||||||
|
roles: roles.into_iter().map(String::from).collect(),
|
||||||
|
permissions: permissions.into_iter().map(String::from).collect(),
|
||||||
|
department_ids: vec![],
|
||||||
|
permission_data_scopes: std::collections::HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_permission_succeeds_when_present() {
|
||||||
|
let ctx = test_ctx(vec![], vec!["user.read", "user.write"]);
|
||||||
|
assert!(require_permission(&ctx, "user.read").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_permission_fails_when_missing() {
|
||||||
|
let ctx = test_ctx(vec![], vec!["user.read"]);
|
||||||
|
assert!(require_permission(&ctx, "user.delete").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_any_permission_succeeds_with_match() {
|
||||||
|
let ctx = test_ctx(vec![], vec!["user.read"]);
|
||||||
|
assert!(require_any_permission(&ctx, &["user.delete", "user.read"]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_any_permission_fails_with_no_match() {
|
||||||
|
let ctx = test_ctx(vec![], vec!["user.read"]);
|
||||||
|
assert!(require_any_permission(&ctx, &["user.delete", "user.admin"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_role_succeeds_when_present() {
|
||||||
|
let ctx = test_ctx(vec!["admin", "user"], vec![]);
|
||||||
|
assert!(require_role(&ctx, "admin").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn require_role_fails_when_missing() {
|
||||||
|
let ctx = test_ctx(vec!["user"], vec![]);
|
||||||
|
assert!(require_role(&ctx, "admin").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/erp-core/src/request_info.rs
Normal file
54
crates/erp-core/src/request_info.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/// 请求来源信息(IP 地址 + User-Agent)。
|
||||||
|
///
|
||||||
|
/// 通过 `tokio::task_local!` 在请求生命周期内传递,
|
||||||
|
/// JWT 中间件设置,审计服务自动读取。
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RequestInfo {
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
/// 当前请求的来源信息。
|
||||||
|
///
|
||||||
|
/// 在 JWT 中间件中通过 `REQUEST_INFO.scope(info, future)` 设置,
|
||||||
|
/// 在 `audit_service::record()` 中自动读取。
|
||||||
|
pub static REQUEST_INFO: RequestInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestInfo {
|
||||||
|
/// 从 HTTP 请求头中提取 IP 地址和 User-Agent。
|
||||||
|
///
|
||||||
|
/// IP 优先级:X-Forwarded-For > X-Real-IP > 直接连接(不记录)。
|
||||||
|
pub fn from_headers(headers: &axum::http::HeaderMap) -> Self {
|
||||||
|
let ip_address = headers
|
||||||
|
.get("X-Forwarded-For")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| {
|
||||||
|
// X-Forwarded-For 可能包含多个 IP,取第一个(客户端真实 IP)
|
||||||
|
s.split(',').next().unwrap_or(s).trim().to_string()
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
headers
|
||||||
|
.get("X-Real-IP")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let user_agent = headers
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试从 task_local 中读取当前请求信息。
|
||||||
|
/// 如果不在请求上下文中(如后台任务),返回 None。
|
||||||
|
pub fn try_current() -> Option<Self> {
|
||||||
|
REQUEST_INFO.try_with(|info| info.clone()).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
218
crates/erp-core/src/sanitize.rs
Normal file
218
crates/erp-core/src/sanitize.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/// HTML/Script 内容清理工具。
|
||||||
|
///
|
||||||
|
/// 基于 ammonia(html5ever)剥离所有 HTML 标签,防止存储型 XSS。
|
||||||
|
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
|
||||||
|
///
|
||||||
|
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
|
||||||
|
///
|
||||||
|
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。
|
||||||
|
/// 比手写字符级解析器更安全,能正确处理所有 HTML 边界情况。
|
||||||
|
pub fn strip_html_tags(input: &str) -> String {
|
||||||
|
// 使用 ammonia 清理(保留在 span 中的纯文本),然后剥离 span 标签
|
||||||
|
let doc = ammonia::Builder::new()
|
||||||
|
.tags(std::collections::HashSet::new())
|
||||||
|
.clean(input)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// ammonia 的 clean() 结果可能包含 HTML 实体(如 <),需要解码
|
||||||
|
// 但由于所有标签已被禁止,结果是纯文本(可能有实体转义)
|
||||||
|
// 使用二次清理:将结果作为纯文本处理
|
||||||
|
decode_entities(&doc).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 简单解码常见 HTML 实体。
|
||||||
|
fn decode_entities(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("/", "/")
|
||||||
|
.replace(" ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对 Option<String> 类型的字段进行清理。
|
||||||
|
pub fn sanitize_option(input: Option<String>) -> Option<String> {
|
||||||
|
input.map(|s| strip_html_tags(&s)).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对 String 类型的必填字段进行清理。
|
||||||
|
pub fn sanitize_string(input: &str) -> String {
|
||||||
|
strip_html_tags(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对富文本 HTML 进行安全清理,保留安全的 HTML 标签和内联样式,去除危险元素。
|
||||||
|
/// 适用于文章内容等需要保留 HTML 排版的场景。
|
||||||
|
pub fn sanitize_rich_html(input: &str) -> String {
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
let tag_attrs: HashMap<&str, HashSet<&str>> = [
|
||||||
|
("div", HashSet::from(["style", "data-w-e-type"])),
|
||||||
|
("span", HashSet::from(["style"])),
|
||||||
|
("p", HashSet::from(["style"])),
|
||||||
|
(
|
||||||
|
"img",
|
||||||
|
HashSet::from(["src", "alt", "style", "width", "height"]),
|
||||||
|
),
|
||||||
|
("a", HashSet::from(["href", "target"])),
|
||||||
|
("td", HashSet::from(["style", "colspan", "rowspan"])),
|
||||||
|
("th", HashSet::from(["style", "colspan", "rowspan"])),
|
||||||
|
("blockquote", HashSet::from(["style"])),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ammonia::Builder::new()
|
||||||
|
.tags(
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"br",
|
||||||
|
"span",
|
||||||
|
"div",
|
||||||
|
"strong",
|
||||||
|
"b",
|
||||||
|
"em",
|
||||||
|
"i",
|
||||||
|
"u",
|
||||||
|
"s",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"ul",
|
||||||
|
"ol",
|
||||||
|
"li",
|
||||||
|
"blockquote",
|
||||||
|
"pre",
|
||||||
|
"code",
|
||||||
|
"table",
|
||||||
|
"thead",
|
||||||
|
"tbody",
|
||||||
|
"tr",
|
||||||
|
"th",
|
||||||
|
"td",
|
||||||
|
"img",
|
||||||
|
"a",
|
||||||
|
"hr",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.tag_attributes(tag_attrs)
|
||||||
|
.generic_attributes(HashSet::from(["style"]))
|
||||||
|
.url_relative(ammonia::UrlRelative::PassThrough)
|
||||||
|
.clean(input)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对 Option<String> 的富文本进行安全清理。
|
||||||
|
pub fn sanitize_rich_html_option(input: Option<String>) -> Option<String> {
|
||||||
|
input
|
||||||
|
.map(|s| sanitize_rich_html(&s))
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_script_tag() {
|
||||||
|
// script 内容在 HTML 规范中是 raw text,ammonia 正确地将其完全移除
|
||||||
|
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_img_onerror() {
|
||||||
|
assert_eq!(strip_html_tags("<img src=x onerror=alert(1)>"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_bold_tags() {
|
||||||
|
assert_eq!(strip_html_tags("Hello <b>World</b>"), "Hello World");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_tags_passthrough() {
|
||||||
|
assert_eq!(strip_html_tags("Normal text"), "Normal text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_tags() {
|
||||||
|
assert_eq!(strip_html_tags("<div><p>text</p></div>"), "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_option_some() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_option(Some("<b>evil</b>".to_string())),
|
||||||
|
Some("evil".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_option_none() {
|
||||||
|
assert_eq!(sanitize_option(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_option_becomes_empty() {
|
||||||
|
assert_eq!(sanitize_option(Some("<img>".to_string())), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_nested_script_attack() {
|
||||||
|
let result = strip_html_tags("<scr<script>ipt>alert(1)</scr</script>ipt>");
|
||||||
|
assert!(!result.contains("<"), "不应残留 HTML 标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_unclosed_tag() {
|
||||||
|
let result = strip_html_tags("text <img");
|
||||||
|
assert!(result.contains("text") || result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_entities() {
|
||||||
|
let result = strip_html_tags("a < b");
|
||||||
|
assert!(result.contains("a") && result.contains("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_html_preserves_safe_tags() {
|
||||||
|
let html = r#"<p>Hello</p><div style="background:#f0fdf4;padding:14px">Green box</div><strong>Bold</strong>"#;
|
||||||
|
let result = sanitize_rich_html(html);
|
||||||
|
assert!(result.contains("<p>Hello</p>"), "should preserve <p> tags");
|
||||||
|
assert!(
|
||||||
|
result.contains("<strong>Bold</strong>"),
|
||||||
|
"should preserve <strong>"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("background"),
|
||||||
|
"should preserve style attribute"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_html_removes_script() {
|
||||||
|
let html = r#"<p>Hello</p><script>alert(1)</script>"#;
|
||||||
|
let result = sanitize_rich_html(html);
|
||||||
|
assert!(!result.contains("script"), "should remove script tags");
|
||||||
|
assert!(result.contains("Hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_html_preserves_styled_block() {
|
||||||
|
let html = r#"<div data-w-e-type="styled-block" style="background:#f0fdf4;border-radius:8px;padding:14px">Tip content</div>"#;
|
||||||
|
let result = sanitize_rich_html(html);
|
||||||
|
assert!(
|
||||||
|
result.contains("styled-block"),
|
||||||
|
"should preserve data-w-e-type"
|
||||||
|
);
|
||||||
|
assert!(result.contains("Tip content"));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/erp-core/src/sea_orm_ext.rs
Normal file
17
crates/erp-core/src/sea_orm_ext.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use sea_orm::ActiveValue;
|
||||||
|
|
||||||
|
/// 从 SeaORM ActiveValue<i32> 中安全提取 version 值。
|
||||||
|
/// Set(v) / Unchanged(v) → 返回 v
|
||||||
|
/// NotSet → 返回 1(首次版本号)
|
||||||
|
/// 绝不 panic。
|
||||||
|
pub fn safe_version(val: &ActiveValue<i32>) -> i32 {
|
||||||
|
match val {
|
||||||
|
ActiveValue::Set(v) | ActiveValue::Unchanged(v) => *v,
|
||||||
|
ActiveValue::NotSet => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安全递增 version:基于当前值 +1,绝不 panic。
|
||||||
|
pub fn bump_version(current: &ActiveValue<i32>) -> i32 {
|
||||||
|
safe_version(current) + 1
|
||||||
|
}
|
||||||
37
crates/erp-core/src/test_helpers.rs
Normal file
37
crates/erp-core/src/test_helpers.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//! 测试基础设施 — 事务回滚模式解决并行化问题
|
||||||
|
//!
|
||||||
|
//! 每个测试在独立事务中执行,测试结束自动回滚,无数据残留。
|
||||||
|
//! 多个测试共享同一个数据库连接池,无连接竞争。
|
||||||
|
|
||||||
|
use sea_orm::{
|
||||||
|
ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait,
|
||||||
|
};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
|
static DB_POOL: OnceCell<DatabaseConnection> = OnceCell::const_new();
|
||||||
|
static DB_URL: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
fn db_url() -> String {
|
||||||
|
DB_URL
|
||||||
|
.get_or_init(|| {
|
||||||
|
std::env::var("TEST_DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "postgres://erp:erp@localhost:5432/erp_test".into())
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn db_pool() -> &'static DatabaseConnection {
|
||||||
|
DB_POOL
|
||||||
|
.get_or_init(|| async {
|
||||||
|
let opt = ConnectOptions::new(db_url()).max_connections(5).to_owned();
|
||||||
|
Database::connect(opt).await.expect("测试数据库连接失败")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建测试用事务。测试结束自动回滚,无数据残留。
|
||||||
|
pub async fn test_txn() -> DatabaseTransaction {
|
||||||
|
let pool = db_pool().await;
|
||||||
|
pool.begin().await.expect("测试事务创建失败")
|
||||||
|
}
|
||||||
188
crates/erp-core/src/types.rs
Normal file
188
crates/erp-core/src/types.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// 所有数据库实体的公共字段
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BaseFields {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页请求
|
||||||
|
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||||
|
pub struct Pagination {
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pagination {
|
||||||
|
pub fn offset(&self) -> u64 {
|
||||||
|
(self.page.unwrap_or(1).saturating_sub(1)) * self.limit()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn limit(&self) -> u64 {
|
||||||
|
self.page_size.unwrap_or(20).min(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pagination_defaults() {
|
||||||
|
let p = Pagination {
|
||||||
|
page: None,
|
||||||
|
page_size: None,
|
||||||
|
};
|
||||||
|
assert_eq!(p.limit(), 20);
|
||||||
|
assert_eq!(p.offset(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pagination_custom_values() {
|
||||||
|
let p = Pagination {
|
||||||
|
page: Some(3),
|
||||||
|
page_size: Some(10),
|
||||||
|
};
|
||||||
|
assert_eq!(p.limit(), 10);
|
||||||
|
assert_eq!(p.offset(), 20); // (3-1) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pagination_max_cap() {
|
||||||
|
let p = Pagination {
|
||||||
|
page: Some(1),
|
||||||
|
page_size: Some(200),
|
||||||
|
};
|
||||||
|
assert_eq!(p.limit(), 100); // capped at 100
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pagination_page_zero_treated_as_first() {
|
||||||
|
// page 0 -> saturating_sub wraps to 0 -> offset = 0
|
||||||
|
let p = Pagination {
|
||||||
|
page: Some(0),
|
||||||
|
page_size: Some(10),
|
||||||
|
};
|
||||||
|
assert_eq!(p.offset(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pagination_page_one() {
|
||||||
|
let p = Pagination {
|
||||||
|
page: Some(1),
|
||||||
|
page_size: Some(50),
|
||||||
|
};
|
||||||
|
assert_eq!(p.offset(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paginated_response_total_pages() {
|
||||||
|
let resp = PaginatedResponse {
|
||||||
|
data: vec![1, 2, 3],
|
||||||
|
total: 25,
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
total_pages: 3,
|
||||||
|
};
|
||||||
|
assert_eq!(resp.data.len(), 3);
|
||||||
|
assert_eq!(resp.total, 25);
|
||||||
|
assert_eq!(resp.total_pages, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_response_ok() {
|
||||||
|
let resp = ApiResponse::ok(42);
|
||||||
|
assert!(resp.success);
|
||||||
|
assert_eq!(resp.data, Some(42));
|
||||||
|
assert!(resp.message.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tenant_context_fields() {
|
||||||
|
let ctx = TenantContext {
|
||||||
|
tenant_id: Uuid::now_v7(),
|
||||||
|
user_id: Uuid::now_v7(),
|
||||||
|
roles: vec!["admin".to_string()],
|
||||||
|
permissions: vec!["user.read".to_string()],
|
||||||
|
department_ids: vec![],
|
||||||
|
permission_data_scopes: HashMap::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(ctx.roles.len(), 1);
|
||||||
|
assert_eq!(ctx.permissions.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页响应
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct PaginatedResponse<T> {
|
||||||
|
pub data: Vec<T>,
|
||||||
|
pub total: u64,
|
||||||
|
pub page: u64,
|
||||||
|
pub page_size: u64,
|
||||||
|
pub total_pages: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 统一响应
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct ApiResponse<T: Serialize> {
|
||||||
|
pub success: bool,
|
||||||
|
pub data: Option<T>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> ApiResponse<T> {
|
||||||
|
pub fn ok(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: Some(data),
|
||||||
|
message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行级数据权限范围
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DataScope {
|
||||||
|
/// 查看所有数据
|
||||||
|
All,
|
||||||
|
/// 仅查看自己创建的数据
|
||||||
|
SelfOnly,
|
||||||
|
/// 仅查看本部门数据
|
||||||
|
Department,
|
||||||
|
/// 查看本部门及下属部门数据
|
||||||
|
DepartmentTree,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataScope {
|
||||||
|
pub fn parse_scope(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"self" => Self::SelfOnly,
|
||||||
|
"department" => Self::Department,
|
||||||
|
"department_tree" => Self::DepartmentTree,
|
||||||
|
_ => Self::All,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 租户上下文(中间件注入)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TenantContext {
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
/// 用户所属部门 ID 列表(行级数据权限使用)
|
||||||
|
pub department_ids: Vec<Uuid>,
|
||||||
|
/// 每个权限码对应的数据范围(从 role_permissions.data_scope 加载)
|
||||||
|
pub permission_data_scopes: HashMap<String, DataScope>,
|
||||||
|
}
|
||||||
21
crates/erp-diary/Cargo.toml
Normal file
21
crates/erp-diary/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "erp-diary"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
erp-core.workspace = true
|
||||||
|
erp-auth.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
sea-orm.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
validator.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
125
crates/erp-diary/src/dto.rs
Normal file
125
crates/erp-diary/src/dto.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// erp-diary 数据传输对象 (DTO)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// 日记心情枚举
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Mood {
|
||||||
|
Happy,
|
||||||
|
Calm,
|
||||||
|
Sad,
|
||||||
|
Angry,
|
||||||
|
Thinking,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 天气枚举
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Weather {
|
||||||
|
Sunny,
|
||||||
|
Cloudy,
|
||||||
|
Rainy,
|
||||||
|
Snowy,
|
||||||
|
Windy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建日记请求
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateJournalReq {
|
||||||
|
pub title: String,
|
||||||
|
pub date: chrono::NaiveDate,
|
||||||
|
pub mood: Mood,
|
||||||
|
pub weather: Weather,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub is_private: bool,
|
||||||
|
pub class_id: Option<uuid::Uuid>,
|
||||||
|
pub assigned_topic_id: Option<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新日记请求
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateJournalReq {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub mood: Option<Mood>,
|
||||||
|
pub weather: Option<Weather>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub is_private: Option<bool>,
|
||||||
|
pub shared_to_class: Option<bool>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日记响应
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct JournalResp {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub author_id: uuid::Uuid,
|
||||||
|
pub class_id: Option<uuid::Uuid>,
|
||||||
|
pub title: String,
|
||||||
|
pub date: chrono::NaiveDate,
|
||||||
|
pub mood: Mood,
|
||||||
|
pub weather: Weather,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub is_private: bool,
|
||||||
|
pub shared_to_class: bool,
|
||||||
|
pub version: i32,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建班级请求
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateClassReq {
|
||||||
|
pub name: String,
|
||||||
|
pub school_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加入班级请求
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct JoinClassReq {
|
||||||
|
pub class_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 班级响应
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct ClassResp {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub school_name: Option<String>,
|
||||||
|
pub teacher_id: uuid::Uuid,
|
||||||
|
pub class_code: String,
|
||||||
|
pub member_count: i32,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步请求
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct SyncReq {
|
||||||
|
pub last_sync_time: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub changes: Vec<SyncChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步变更条目
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub enum SyncChange {
|
||||||
|
CreateJournal { data: serde_json::Value },
|
||||||
|
UpdateJournal { id: uuid::Uuid, version: i32, data: serde_json::Value },
|
||||||
|
DeleteJournal { id: uuid::Uuid, version: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步响应
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct SyncResp {
|
||||||
|
pub server_changes: Vec<serde_json::Value>,
|
||||||
|
pub conflicts: Vec<ConflictInfo>,
|
||||||
|
pub sync_time: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冲突信息
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct ConflictInfo {
|
||||||
|
pub journal_id: uuid::Uuid,
|
||||||
|
pub local_version: i32,
|
||||||
|
pub server_version: i32,
|
||||||
|
}
|
||||||
2
crates/erp-diary/src/entity/mod.rs
Normal file
2
crates/erp-diary/src/entity/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// erp-diary SeaORM 实体占位
|
||||||
|
// 后续 Phase B1 会定义完整的 ~15 个实体
|
||||||
75
crates/erp-diary/src/error.rs
Normal file
75
crates/erp-diary/src/error.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// erp-diary 错误类型
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DiaryError {
|
||||||
|
#[error("日记未找到: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("版本冲突: 本地版本 {local}, 服务端版本 {server}")]
|
||||||
|
VersionConflict { local: i32, server: i32 },
|
||||||
|
|
||||||
|
#[error("班级码无效")]
|
||||||
|
InvalidClassCode,
|
||||||
|
|
||||||
|
#[error("班级码已过期")]
|
||||||
|
ClassCodeExpired,
|
||||||
|
|
||||||
|
#[error("班级码尝试次数过多,请 {lockout_minutes} 分钟后重试")]
|
||||||
|
ClassCodeLocked { lockout_minutes: u32 },
|
||||||
|
|
||||||
|
#[error("无权限执行此操作")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("内容安全检查未通过")]
|
||||||
|
ContentSafetyViolation,
|
||||||
|
|
||||||
|
#[error("同步失败: {0}")]
|
||||||
|
SyncFailed(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("内部错误: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorBody {
|
||||||
|
error: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for DiaryError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
DiaryError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
DiaryError::VersionConflict { .. } => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
DiaryError::InvalidClassCode | DiaryError::ClassCodeExpired => {
|
||||||
|
(StatusCode::BAD_REQUEST, self.to_string())
|
||||||
|
}
|
||||||
|
DiaryError::ClassCodeLocked { .. } => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
|
||||||
|
DiaryError::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
DiaryError::ContentSafetyViolation => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
DiaryError::SyncFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||||
|
DiaryError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
DiaryError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ErrorBody {
|
||||||
|
error: format!("diary.{}", status.as_u16()),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, axum::Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::DbErr> for DiaryError {
|
||||||
|
fn from(err: sea_orm::DbErr) -> Self {
|
||||||
|
DiaryError::Internal(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
61
crates/erp-diary/src/event.rs
Normal file
61
crates/erp-diary/src/event.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// erp-diary 事件定义
|
||||||
|
|
||||||
|
/// 日记模块领域事件
|
||||||
|
pub enum DiaryEvent {
|
||||||
|
/// 日记创建
|
||||||
|
JournalCreated {
|
||||||
|
journal_id: uuid::Uuid,
|
||||||
|
author_id: uuid::Uuid,
|
||||||
|
class_id: Option<uuid::Uuid>,
|
||||||
|
},
|
||||||
|
/// 日记更新
|
||||||
|
JournalUpdated {
|
||||||
|
journal_id: uuid::Uuid,
|
||||||
|
author_id: uuid::Uuid,
|
||||||
|
version: i32,
|
||||||
|
},
|
||||||
|
/// 日记删除
|
||||||
|
JournalDeleted {
|
||||||
|
journal_id: uuid::Uuid,
|
||||||
|
author_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 日记分享到班级
|
||||||
|
JournalShared {
|
||||||
|
journal_id: uuid::Uuid,
|
||||||
|
author_id: uuid::Uuid,
|
||||||
|
class_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 班级创建
|
||||||
|
ClassCreated {
|
||||||
|
class_id: uuid::Uuid,
|
||||||
|
teacher_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 学生加入班级
|
||||||
|
StudentJoinedClass {
|
||||||
|
class_id: uuid::Uuid,
|
||||||
|
student_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 老师布置主题
|
||||||
|
TopicAssigned {
|
||||||
|
topic_id: uuid::Uuid,
|
||||||
|
class_id: uuid::Uuid,
|
||||||
|
teacher_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 老师点评
|
||||||
|
CommentCreated {
|
||||||
|
comment_id: uuid::Uuid,
|
||||||
|
journal_id: uuid::Uuid,
|
||||||
|
teacher_id: uuid::Uuid,
|
||||||
|
student_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 家长绑定孩子
|
||||||
|
ParentBound {
|
||||||
|
parent_id: uuid::Uuid,
|
||||||
|
child_id: uuid::Uuid,
|
||||||
|
},
|
||||||
|
/// 成就解锁
|
||||||
|
AchievementUnlocked {
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
achievement_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
2
crates/erp-diary/src/handler/mod.rs
Normal file
2
crates/erp-diary/src/handler/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// erp-diary API 处理器占位
|
||||||
|
// 后续 Phase B2-B7 会实现 ~10 个处理器
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user