chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
25
crates/erp-auth/Cargo.toml
Normal file
25
crates/erp-auth/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[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
|
||||
redis.workspace = true
|
||||
dashmap.workspace = true
|
||||
80
crates/erp-auth/src/auth_state.rs
Normal file
80
crates/erp-auth/src/auth_state.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
481
crates/erp-auth/src/dto.rs
Normal file
481
crates/erp-auth/src/dto.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// 修改密码请求
|
||||
#[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 {}
|
||||
11
crates/erp-auth/src/entity/mod.rs
Normal file
11
crates/erp-auth/src/entity/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
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 {}
|
||||
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()),
|
||||
}))
|
||||
}
|
||||
4
crates/erp-auth/src/handler/mod.rs
Normal file
4
crates/erp-auth/src/handler/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod auth_handler;
|
||||
pub mod org_handler;
|
||||
pub mod role_handler;
|
||||
pub mod user_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()),
|
||||
}))
|
||||
}
|
||||
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};
|
||||
364
crates/erp-auth/src/module.rs
Normal file
364
crates/erp-auth/src/module.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
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};
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
10
crates/erp-auth/src/service/mod.rs
Normal file
10
crates/erp-auth/src/service/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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>,
|
||||
}
|
||||
23
crates/erp-message/Cargo.toml
Normal file
23
crates/erp-message/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "erp-message"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
axum = { workspace = true }
|
||||
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] }
|
||||
tracing = { workspace = true }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
utoipa = { workspace = true, features = ["uuid", "chrono"] }
|
||||
async-trait.workspace = true
|
||||
validator.workspace = true
|
||||
futures.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
async-stream.workspace = true
|
||||
517
crates/erp-message/src/dto.rs
Normal file
517
crates/erp-message/src/dto.rs
Normal file
@@ -0,0 +1,517 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// ============ 消息 DTO ============
|
||||
|
||||
/// 消息响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct MessageResp {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub template_id: Option<Uuid>,
|
||||
pub sender_id: Option<Uuid>,
|
||||
pub sender_type: String,
|
||||
pub recipient_id: Uuid,
|
||||
pub recipient_type: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub priority: String,
|
||||
pub business_type: Option<String>,
|
||||
pub business_id: Option<Uuid>,
|
||||
pub is_read: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
pub is_archived: bool,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sent_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 发送消息请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct SendMessageReq {
|
||||
#[validate(length(min = 1, max = 200, message = "标题不能为空且不超过200字符"))]
|
||||
pub title: String,
|
||||
#[validate(length(min = 1, message = "内容不能为空"))]
|
||||
pub body: String,
|
||||
pub recipient_id: Uuid,
|
||||
#[serde(default = "default_recipient_type")]
|
||||
#[validate(custom(function = "validate_recipient_type"))]
|
||||
pub recipient_type: String,
|
||||
#[serde(default = "default_priority")]
|
||||
#[validate(custom(function = "validate_priority"))]
|
||||
pub priority: String,
|
||||
pub template_id: Option<Uuid>,
|
||||
pub business_type: Option<String>,
|
||||
pub business_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
fn validate_recipient_type(value: &str) -> Result<(), validator::ValidationError> {
|
||||
match value {
|
||||
"user" | "role" | "department" | "all" => Ok(()),
|
||||
_ => Err(validator::ValidationError::new("invalid_recipient_type")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_priority(value: &str) -> Result<(), validator::ValidationError> {
|
||||
match value {
|
||||
"normal" | "important" | "urgent" => Ok(()),
|
||||
_ => Err(validator::ValidationError::new("invalid_priority")),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_recipient_type() -> String {
|
||||
"user".to_string()
|
||||
}
|
||||
|
||||
fn default_priority() -> String {
|
||||
"normal".to_string()
|
||||
}
|
||||
|
||||
/// 消息列表查询参数
|
||||
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
|
||||
pub struct MessageQuery {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub is_read: Option<bool>,
|
||||
pub priority: Option<String>,
|
||||
pub business_type: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
impl MessageQuery {
|
||||
/// 获取安全的分页大小(上限 100)。
|
||||
pub fn safe_page_size(&self) -> u64 {
|
||||
self.page_size.unwrap_or(20).min(100)
|
||||
}
|
||||
}
|
||||
|
||||
/// 未读消息计数响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UnreadCountResp {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
// ============ 消息模板 DTO ============
|
||||
|
||||
/// 消息模板响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct MessageTemplateResp {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub channel: String,
|
||||
pub title_template: String,
|
||||
pub body_template: String,
|
||||
pub language: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 创建消息模板请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateTemplateReq {
|
||||
#[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50, message = "编码不能为空且不超过50字符"))]
|
||||
pub code: String,
|
||||
#[serde(default = "default_channel")]
|
||||
#[validate(custom(function = "validate_channel"))]
|
||||
pub channel: String,
|
||||
#[validate(length(min = 1, max = 200, message = "标题模板不能为空"))]
|
||||
pub title_template: String,
|
||||
#[validate(length(min = 1, message = "内容模板不能为空"))]
|
||||
pub body_template: String,
|
||||
#[serde(default = "default_language")]
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
fn default_channel() -> String {
|
||||
"in_app".to_string()
|
||||
}
|
||||
|
||||
fn validate_channel(value: &str) -> Result<(), validator::ValidationError> {
|
||||
match value {
|
||||
"in_app" | "email" | "sms" | "wechat" => Ok(()),
|
||||
_ => Err(validator::ValidationError::new("invalid_channel")),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_language() -> String {
|
||||
"zh-CN".to_string()
|
||||
}
|
||||
|
||||
/// 更新消息模板请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateTemplateReq {
|
||||
#[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))]
|
||||
pub name: Option<String>,
|
||||
#[validate(length(min = 1, max = 200, message = "标题模板不能为空"))]
|
||||
pub title_template: Option<String>,
|
||||
#[validate(length(min = 1, message = "内容模板不能为空"))]
|
||||
pub body_template: Option<String>,
|
||||
#[validate(length(min = 1, max = 10, message = "语言代码无效"))]
|
||||
pub language: Option<String>,
|
||||
#[validate(custom(function = "validate_channel"))]
|
||||
pub channel: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ============ 消息订阅偏好 DTO ============
|
||||
|
||||
/// 消息订阅偏好响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct MessageSubscriptionResp {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub notification_types: Option<serde_json::Value>,
|
||||
pub channel_preferences: Option<serde_json::Value>,
|
||||
pub dnd_enabled: bool,
|
||||
pub dnd_start: Option<String>,
|
||||
pub dnd_end: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 更新消息订阅偏好请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateSubscriptionReq {
|
||||
pub notification_types: Option<serde_json::Value>,
|
||||
pub channel_preferences: Option<serde_json::Value>,
|
||||
pub dnd_enabled: Option<bool>,
|
||||
#[validate(length(max = 8, message = "免打扰开始时间格式无效"))]
|
||||
pub dnd_start: Option<String>,
|
||||
#[validate(length(max = 8, message = "免打扰结束时间格式无效"))]
|
||||
pub dnd_end: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// ============ SendMessageReq 测试 ============
|
||||
|
||||
fn valid_send_message_req() -> SendMessageReq {
|
||||
SendMessageReq {
|
||||
title: "系统通知".to_string(),
|
||||
body: "您有一条新消息".to_string(),
|
||||
recipient_id: Uuid::now_v7(),
|
||||
recipient_type: "user".to_string(),
|
||||
priority: "normal".to_string(),
|
||||
template_id: None,
|
||||
business_type: None,
|
||||
business_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_valid() {
|
||||
let req = valid_send_message_req();
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_empty_title_fails() {
|
||||
let mut req = valid_send_message_req();
|
||||
req.title = "".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_title_too_long_fails() {
|
||||
let mut req = valid_send_message_req();
|
||||
req.title = "x".repeat(201);
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_title_max_length_ok() {
|
||||
let mut req = valid_send_message_req();
|
||||
req.title = "x".repeat(200);
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_empty_body_fails() {
|
||||
let mut req = valid_send_message_req();
|
||||
req.body = "".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_valid_recipient_types() {
|
||||
for rt in &["user", "role", "department", "all"] {
|
||||
let mut req = valid_send_message_req();
|
||||
req.recipient_type = rt.to_string();
|
||||
assert!(
|
||||
req.validate().is_ok(),
|
||||
"recipient_type '{}' should be valid",
|
||||
rt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_invalid_recipient_type_fails() {
|
||||
let mut req = valid_send_message_req();
|
||||
req.recipient_type = "invalid".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_valid_priorities() {
|
||||
for p in &["normal", "important", "urgent"] {
|
||||
let mut req = valid_send_message_req();
|
||||
req.priority = p.to_string();
|
||||
assert!(req.validate().is_ok(), "priority '{}' should be valid", p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_invalid_priority_fails() {
|
||||
let mut req = valid_send_message_req();
|
||||
req.priority = "critical".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_default_recipient_type_is_user() {
|
||||
assert_eq!(default_recipient_type(), "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_req_default_priority_is_normal() {
|
||||
assert_eq!(default_priority(), "normal");
|
||||
}
|
||||
|
||||
// ============ MessageQuery 测试 ============
|
||||
|
||||
#[test]
|
||||
fn message_query_safe_page_size_default() {
|
||||
let query = MessageQuery {
|
||||
page: None,
|
||||
page_size: None,
|
||||
is_read: None,
|
||||
priority: None,
|
||||
business_type: None,
|
||||
status: None,
|
||||
};
|
||||
assert_eq!(query.safe_page_size(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_query_safe_page_size_custom() {
|
||||
let query = MessageQuery {
|
||||
page: None,
|
||||
page_size: Some(50),
|
||||
is_read: None,
|
||||
priority: None,
|
||||
business_type: None,
|
||||
status: None,
|
||||
};
|
||||
assert_eq!(query.safe_page_size(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_query_safe_page_size_capped_at_100() {
|
||||
let query = MessageQuery {
|
||||
page: None,
|
||||
page_size: Some(200),
|
||||
is_read: None,
|
||||
priority: None,
|
||||
business_type: None,
|
||||
status: None,
|
||||
};
|
||||
assert_eq!(query.safe_page_size(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_query_safe_page_size_exactly_100() {
|
||||
let query = MessageQuery {
|
||||
page: None,
|
||||
page_size: Some(100),
|
||||
is_read: None,
|
||||
priority: None,
|
||||
business_type: None,
|
||||
status: None,
|
||||
};
|
||||
assert_eq!(query.safe_page_size(), 100);
|
||||
}
|
||||
|
||||
// ============ CreateTemplateReq 测试 ============
|
||||
|
||||
fn valid_create_template_req() -> CreateTemplateReq {
|
||||
CreateTemplateReq {
|
||||
name: "欢迎模板".to_string(),
|
||||
code: "WELCOME".to_string(),
|
||||
channel: "in_app".to_string(),
|
||||
title_template: "欢迎加入".to_string(),
|
||||
body_template: "您好,{{name}},欢迎加入平台".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_valid() {
|
||||
let req = valid_create_template_req();
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_empty_name_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.name = "".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_name_too_long_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.name = "x".repeat(101);
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_name_max_length_ok() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.name = "x".repeat(100);
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_empty_code_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.code = "".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_code_too_long_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.code = "X".repeat(51);
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_code_max_length_ok() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.code = "X".repeat(50);
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_valid_channels() {
|
||||
for ch in &["in_app", "email", "sms", "wechat"] {
|
||||
let mut req = valid_create_template_req();
|
||||
req.channel = ch.to_string();
|
||||
assert!(req.validate().is_ok(), "channel '{}' should be valid", ch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_invalid_channel_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.channel = "telegram".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_empty_title_template_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.title_template = "".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_title_template_too_long_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.title_template = "x".repeat(201);
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_empty_body_template_fails() {
|
||||
let mut req = valid_create_template_req();
|
||||
req.body_template = "".to_string();
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_default_channel_is_in_app() {
|
||||
assert_eq!(default_channel(), "in_app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_template_req_default_language_is_zh_cn() {
|
||||
assert_eq!(default_language(), "zh-CN");
|
||||
}
|
||||
|
||||
// ============ 自定义验证函数测试 ============
|
||||
|
||||
#[test]
|
||||
fn validate_recipient_type_valid() {
|
||||
for rt in &["user", "role", "department", "all"] {
|
||||
assert!(
|
||||
validate_recipient_type(rt).is_ok(),
|
||||
"'{}' should be a valid recipient type",
|
||||
rt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_recipient_type_invalid() {
|
||||
assert!(validate_recipient_type("invalid").is_err());
|
||||
assert!(validate_recipient_type("").is_err());
|
||||
assert!(validate_recipient_type("USER").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_priority_valid() {
|
||||
for p in &["normal", "important", "urgent"] {
|
||||
assert!(
|
||||
validate_priority(p).is_ok(),
|
||||
"'{}' should be a valid priority",
|
||||
p
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_priority_invalid() {
|
||||
assert!(validate_priority("critical").is_err());
|
||||
assert!(validate_priority("").is_err());
|
||||
assert!(validate_priority("NORMAL").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_channel_valid() {
|
||||
for ch in &["in_app", "email", "sms", "wechat"] {
|
||||
assert!(
|
||||
validate_channel(ch).is_ok(),
|
||||
"'{}' should be a valid channel",
|
||||
ch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_channel_invalid() {
|
||||
assert!(validate_channel("slack").is_err());
|
||||
assert!(validate_channel("").is_err());
|
||||
assert!(validate_channel("EMAIL").is_err());
|
||||
}
|
||||
}
|
||||
58
crates/erp-message/src/entity/message.rs
Normal file
58
crates/erp-message/src/entity/message.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "messages")]
|
||||
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 template_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sender_id: Option<Uuid>,
|
||||
pub sender_type: String,
|
||||
pub recipient_id: Uuid,
|
||||
pub recipient_type: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub priority: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub business_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub business_id: Option<Uuid>,
|
||||
pub is_read: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_at: Option<DateTimeUtc>,
|
||||
pub is_archived: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archived_at: Option<DateTimeUtc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sent_at: Option<DateTimeUtc>,
|
||||
pub status: 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::message_template::Entity",
|
||||
from = "Column::TemplateId",
|
||||
to = "super::message_template::Column::Id"
|
||||
)]
|
||||
MessageTemplate,
|
||||
}
|
||||
|
||||
impl Related<super::message_template::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MessageTemplate.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
crates/erp-message/src/entity/message_subscription.rs
Normal file
32
crates/erp-message/src/entity/message_subscription.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "message_subscriptions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notification_types: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub channel_preferences: Option<serde_json::Value>,
|
||||
pub dnd_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dnd_start: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dnd_end: 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 {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
37
crates/erp-message/src/entity/message_template.rs
Normal file
37
crates/erp-message/src/entity/message_template.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 = "message_templates")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub channel: String,
|
||||
pub title_template: String,
|
||||
pub body_template: String,
|
||||
pub language: 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::message::Entity")]
|
||||
Message,
|
||||
}
|
||||
|
||||
impl Related<super::message::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Message.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
3
crates/erp-message/src/entity/mod.rs
Normal file
3
crates/erp-message/src/entity/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod message;
|
||||
pub mod message_subscription;
|
||||
pub mod message_template;
|
||||
144
crates/erp-message/src/error.rs
Normal file
144
crates/erp-message/src/error.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// 消息中心模块错误类型。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MessageError {
|
||||
#[error("验证失败: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("模板编码已存在: {0}")]
|
||||
DuplicateTemplateCode(String),
|
||||
|
||||
#[error("渲染失败: {0}")]
|
||||
TemplateRenderError(String),
|
||||
|
||||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||
VersionMismatch,
|
||||
}
|
||||
|
||||
impl From<MessageError> for AppError {
|
||||
fn from(err: MessageError) -> Self {
|
||||
match err {
|
||||
MessageError::Validation(msg) => AppError::Validation(msg),
|
||||
MessageError::NotFound(msg) => AppError::NotFound(msg),
|
||||
MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg),
|
||||
MessageError::TemplateRenderError(msg) => AppError::Internal(msg),
|
||||
MessageError::VersionMismatch => AppError::VersionMismatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sea_orm::TransactionError<MessageError>> for MessageError {
|
||||
fn from(err: sea_orm::TransactionError<MessageError>) -> Self {
|
||||
match err {
|
||||
sea_orm::TransactionError::Connection(db_err) => {
|
||||
MessageError::Validation(db_err.to_string())
|
||||
}
|
||||
sea_orm::TransactionError::Transaction(msg_err) => msg_err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type MessageResult<T> = Result<T, MessageError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use erp_core::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn validation_maps_to_app_validation() {
|
||||
let app: AppError = MessageError::Validation("标题不能为空".to_string()).into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert_eq!(msg, "标题不能为空"),
|
||||
other => panic!("Expected AppError::Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_maps_to_app_not_found() {
|
||||
let app: AppError = MessageError::NotFound("消息不存在".to_string()).into();
|
||||
match app {
|
||||
AppError::NotFound(msg) => assert_eq!(msg, "消息不存在"),
|
||||
other => panic!("Expected AppError::NotFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_template_code_maps_to_app_conflict() {
|
||||
let app: AppError = MessageError::DuplicateTemplateCode("WELCOME".to_string()).into();
|
||||
match app {
|
||||
AppError::Conflict(msg) => assert_eq!(msg, "WELCOME"),
|
||||
other => panic!("Expected AppError::Conflict, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_render_error_maps_to_app_internal() {
|
||||
let app: AppError = MessageError::TemplateRenderError("变量缺失".to_string()).into();
|
||||
match app {
|
||||
AppError::Internal(msg) => assert_eq!(msg, "变量缺失"),
|
||||
other => panic!("Expected AppError::Internal, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_maps_to_app_version_mismatch() {
|
||||
let app: AppError = MessageError::VersionMismatch.into();
|
||||
match app {
|
||||
AppError::VersionMismatch => {}
|
||||
other => panic!("Expected AppError::VersionMismatch, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_format() {
|
||||
assert_eq!(
|
||||
MessageError::Validation("字段为空".to_string()).to_string(),
|
||||
"验证失败: 字段为空"
|
||||
);
|
||||
assert_eq!(
|
||||
MessageError::NotFound("id=123".to_string()).to_string(),
|
||||
"未找到: id=123"
|
||||
);
|
||||
assert_eq!(
|
||||
MessageError::DuplicateTemplateCode("CODE".to_string()).to_string(),
|
||||
"模板编码已存在: CODE"
|
||||
);
|
||||
assert_eq!(
|
||||
MessageError::TemplateRenderError("解析失败".to_string()).to_string(),
|
||||
"渲染失败: 解析失败"
|
||||
);
|
||||
assert_eq!(
|
||||
MessageError::VersionMismatch.to_string(),
|
||||
"版本冲突: 数据已被其他操作修改,请刷新后重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_connection_error_maps_to_validation() {
|
||||
let db_err = sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal("连接超时".to_string()));
|
||||
let tx_err: sea_orm::TransactionError<MessageError> =
|
||||
sea_orm::TransactionError::Connection(db_err);
|
||||
let msg_err: MessageError = tx_err.into();
|
||||
match msg_err {
|
||||
MessageError::Validation(msg) => assert!(msg.contains("连接超时")),
|
||||
other => panic!("期望 Validation,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_inner_error_passthrough() {
|
||||
let inner = MessageError::NotFound("模板不存在".to_string());
|
||||
let tx_err: sea_orm::TransactionError<MessageError> =
|
||||
sea_orm::TransactionError::Transaction(inner);
|
||||
let msg_err: MessageError = tx_err.into();
|
||||
match msg_err {
|
||||
MessageError::NotFound(msg) => assert_eq!(msg, "模板不存在"),
|
||||
other => panic!("期望 NotFound,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
194
crates/erp-message/src/handler/message_handler.rs
Normal file
194
crates/erp-message/src/handler/message_handler.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::dto::{MessageQuery, MessageResp, SendMessageReq, UnreadCountResp};
|
||||
use crate::message_state::MessageState;
|
||||
use crate::service::message_service::MessageService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/messages",
|
||||
params(MessageQuery),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<MessageResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息管理"
|
||||
)]
|
||||
/// 查询消息列表。
|
||||
pub async fn list_messages<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<MessageQuery>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<MessageResp>>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.list")?;
|
||||
|
||||
let db = &_state.db;
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let (messages, total) = MessageService::list(ctx.tenant_id, ctx.user_id, &query, db).await?;
|
||||
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: messages,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/messages/unread-count",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<UnreadCountResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息管理"
|
||||
)]
|
||||
/// 获取未读消息数量。
|
||||
pub async fn unread_count<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<UnreadCountResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.list")?;
|
||||
|
||||
let result = MessageService::unread_count(ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/messages",
|
||||
request_body = SendMessageReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MessageResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息管理"
|
||||
)]
|
||||
/// 发送消息。
|
||||
pub async fn send_message<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<SendMessageReq>,
|
||||
) -> Result<Json<ApiResponse<MessageResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.send")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = MessageService::send(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&_state.db,
|
||||
&_state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/messages/{id}/read",
|
||||
params(("id" = Uuid, Path, description = "消息ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息管理"
|
||||
)]
|
||||
/// 标记消息已读。
|
||||
pub async fn mark_read<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
MessageService::mark_read(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/messages/read-all",
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息管理"
|
||||
)]
|
||||
/// 标记所有消息已读。
|
||||
pub async fn mark_all_read<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
MessageService::mark_all_read(ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/messages/{id}",
|
||||
params(("id" = Uuid, Path, description = "消息ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息管理"
|
||||
)]
|
||||
/// 删除消息。
|
||||
pub async fn delete_message<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
MessageService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
4
crates/erp-message/src/handler/mod.rs
Normal file
4
crates/erp-message/src/handler/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod message_handler;
|
||||
pub mod sse_handler;
|
||||
pub mod subscription_handler;
|
||||
pub mod template_handler;
|
||||
322
crates/erp-message/src/handler/sse_handler.rs
Normal file
322
crates/erp-message/src/handler/sse_handler.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::http::{HeaderMap, HeaderValue, header};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures::stream::Stream;
|
||||
use sea_orm::ConnectionTrait;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::TenantContext;
|
||||
|
||||
/// 包装 SSE 响应,添加 Cache-Control: no-store 头
|
||||
pub struct NoCacheSse<S>(Sse<S>);
|
||||
|
||||
impl<S> IntoResponse for NoCacheSse<S>
|
||||
where
|
||||
S: Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static,
|
||||
{
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let mut response = self.0.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, no-cache, must-revalidate"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
use crate::message_state::MessageState;
|
||||
|
||||
/// SSE 查询参数
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct SseQuery {
|
||||
/// 逗号分隔的患者 ID 列表,为空则订阅所有管床患者
|
||||
pub patient_ids: Option<String>,
|
||||
}
|
||||
|
||||
/// SSE 消息推送端点。
|
||||
///
|
||||
/// 监听所有事件,按类型分发为不同 SSE event:
|
||||
/// - `message.sent` → SSE event: `message`
|
||||
/// - `alert.triggered` → SSE event: `alert`
|
||||
/// - `device.readings.synced` → SSE event: `vital_update`
|
||||
///
|
||||
/// 增强:
|
||||
/// - Event ID(支持 Last-Event-ID 断点续传)
|
||||
/// - 30s 心跳保活
|
||||
/// - 患者选择性订阅(?patient_ids=id1,id2)
|
||||
pub async fn message_stream(
|
||||
axum::extract::State(state): axum::extract::State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<SseQuery>,
|
||||
) -> Result<NoCacheSse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let user_id = ctx.user_id;
|
||||
let tenant_id = ctx.tenant_id;
|
||||
|
||||
let last_event_id: Option<Uuid> = headers
|
||||
.get("Last-Event-ID")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
let subscribed_patient_ids: Option<HashSet<String>> = query.patient_ids.as_ref().map(|s| {
|
||||
s.split(',')
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect()
|
||||
});
|
||||
|
||||
let (mut rx, _handle) = state.event_bus.subscribe_filtered(String::new());
|
||||
|
||||
let db = state.db.clone();
|
||||
let last_event_id_cell = Cell::new(last_event_id);
|
||||
|
||||
let sse_stream = async_stream::stream! {
|
||||
loop {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
rx.recv(),
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(Some(event)) => {
|
||||
if event.tenant_id != tenant_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Last-Event-ID 恢复:跳过已发送的事件
|
||||
if let Some(skip_until) = last_event_id_cell.take()
|
||||
&& event.id <= skip_until {
|
||||
last_event_id_cell.set(Some(skip_until));
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.event_type.as_str() {
|
||||
"message.sent" => {
|
||||
let is_recipient = event.payload.get("recipient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == user_id.to_string())
|
||||
.unwrap_or(false);
|
||||
if !is_recipient {
|
||||
continue;
|
||||
}
|
||||
let data = serde_json::to_string(&event.payload)
|
||||
.unwrap_or_default();
|
||||
yield Ok(Event::default()
|
||||
.event("message")
|
||||
.id(event.id.to_string())
|
||||
.data(data));
|
||||
}
|
||||
"alert.triggered" => {
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
// 患者订阅过滤
|
||||
if let (Some(pid_str), Some(subscribed)) = (patient_id, &subscribed_patient_ids)
|
||||
&& !subscribed.contains(pid_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(pid_str) = patient_id {
|
||||
let pid = Uuid::parse_str(pid_str).ok();
|
||||
if let Some(pid) = pid {
|
||||
let is_doctor = is_doctor_for_patient(
|
||||
&db, tenant_id, user_id, pid,
|
||||
).await;
|
||||
if !is_doctor {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let data = serde_json::to_string(&event.payload)
|
||||
.unwrap_or_default();
|
||||
yield Ok(Event::default()
|
||||
.event("alert")
|
||||
.id(event.id.to_string())
|
||||
.data(data));
|
||||
}
|
||||
"device.readings.synced" => {
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
// 患者订阅过滤
|
||||
if let (Some(pid_str), Some(subscribed)) = (patient_id, &subscribed_patient_ids)
|
||||
&& !subscribed.contains(pid_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(pid_str) = patient_id {
|
||||
let pid = Uuid::parse_str(pid_str).ok();
|
||||
if let Some(pid) = pid {
|
||||
let is_doctor = is_doctor_for_patient(
|
||||
&db, tenant_id, user_id, pid,
|
||||
).await;
|
||||
if !is_doctor {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let data = serde_json::to_string(&event.payload)
|
||||
.unwrap_or_default();
|
||||
yield Ok(Event::default()
|
||||
.event("vital_update")
|
||||
.id(event.id.to_string())
|
||||
.data(data));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// 超时 = 发送心跳
|
||||
yield Ok(Event::default().comment("ping"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(NoCacheSse(
|
||||
Sse::new(sse_stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(std::time::Duration::from_secs(30))
|
||||
.text("ping"),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// 检查 user_id 对应的医生是否是某患者的管床医生。
|
||||
async fn is_doctor_for_patient(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> bool {
|
||||
let sql = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT COUNT(*) AS cnt FROM patient_doctor_relation
|
||||
WHERE tenant_id = $1 AND doctor_id = $2 AND patient_id = $3 AND deleted_at IS NULL"#,
|
||||
[tenant_id.into(), user_id.into(), patient_id.into()],
|
||||
);
|
||||
match db.query_one(sql).await {
|
||||
Ok(Some(row)) => {
|
||||
let cnt: i64 = row.try_get::<i64>("", "cnt").unwrap_or(0);
|
||||
cnt > 0
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
user_id = %user_id,
|
||||
patient_id = %patient_id,
|
||||
"查询医患关系失败,跳过推送"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn patient_id_parsing_from_payload() {
|
||||
let payload = serde_json::json!({
|
||||
"patient_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"severity": "critical",
|
||||
"rule_name": "心率过高",
|
||||
});
|
||||
let pid_str = payload.get("patient_id").and_then(|v| v.as_str());
|
||||
assert!(pid_str.is_some());
|
||||
let pid = Uuid::parse_str(pid_str.unwrap()).ok();
|
||||
assert!(pid.is_some());
|
||||
assert_eq!(
|
||||
pid.unwrap(),
|
||||
Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patient_id_missing_returns_none() {
|
||||
let payload = serde_json::json!({
|
||||
"severity": "warning",
|
||||
});
|
||||
let pid_str = payload.get("patient_id").and_then(|v| v.as_str());
|
||||
assert!(pid_str.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patient_id_invalid_uuid_returns_none() {
|
||||
let payload = serde_json::json!({
|
||||
"patient_id": "not-a-uuid",
|
||||
});
|
||||
let pid_str = payload.get("patient_id").and_then(|v| v.as_str());
|
||||
assert!(pid_str.is_some());
|
||||
let pid = Uuid::parse_str(pid_str.unwrap()).ok();
|
||||
assert!(pid.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_query_parses_patient_ids() {
|
||||
let query = SseQuery {
|
||||
patient_ids: Some("id1,id2,id3".into()),
|
||||
};
|
||||
assert!(query.patient_ids.is_some());
|
||||
let ids = query.patient_ids.unwrap();
|
||||
assert_eq!(ids, "id1,id2,id3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_query_default_is_empty() {
|
||||
let query = SseQuery::default();
|
||||
assert!(query.patient_ids.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribed_patient_ids_parsing() {
|
||||
let query = SseQuery {
|
||||
patient_ids: Some("aaa,bbb,ccc".into()),
|
||||
};
|
||||
let set: Option<HashSet<String>> = query.patient_ids.map(|s: String| {
|
||||
s.split(',')
|
||||
.map(|id: &str| id.trim().to_string())
|
||||
.filter(|id: &String| !id.is_empty())
|
||||
.collect()
|
||||
});
|
||||
assert!(set.is_some());
|
||||
let set = set.unwrap();
|
||||
assert_eq!(set.len(), 3);
|
||||
assert!(set.contains("aaa"));
|
||||
assert!(set.contains("bbb"));
|
||||
assert!(set.contains("ccc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_event_id_parsing_from_headers() {
|
||||
let event_id = Uuid::now_v7();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Last-Event-ID", event_id.to_string().parse().unwrap());
|
||||
|
||||
let parsed: Option<Uuid> = headers
|
||||
.get("Last-Event-ID")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
assert_eq!(parsed, Some(event_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_event_id_missing_returns_none() {
|
||||
let headers = HeaderMap::new();
|
||||
let parsed: Option<Uuid> = headers
|
||||
.get("Last-Event-ID")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
assert!(parsed.is_none());
|
||||
}
|
||||
}
|
||||
60
crates/erp-message/src/handler/subscription_handler.rs
Normal file
60
crates/erp-message/src/handler/subscription_handler.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, State};
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::dto::UpdateSubscriptionReq;
|
||||
use crate::message_state::MessageState;
|
||||
use crate::service::subscription_service::SubscriptionService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/message-subscriptions",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<crate::dto::MessageSubscriptionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息订阅"
|
||||
)]
|
||||
/// 获取当前用户的消息订阅偏好。
|
||||
pub async fn get_subscription<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<crate::dto::MessageSubscriptionResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let resp = SubscriptionService::get(ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/message-subscriptions",
|
||||
request_body = UpdateSubscriptionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<crate::dto::MessageSubscriptionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息订阅"
|
||||
)]
|
||||
/// 更新消息订阅偏好。
|
||||
pub async fn update_subscription<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<UpdateSubscriptionReq>,
|
||||
) -> Result<Json<ApiResponse<crate::dto::MessageSubscriptionResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let resp = SubscriptionService::upsert(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
140
crates/erp-message/src/handler/template_handler.rs
Normal file
140
crates/erp-message/src/handler/template_handler.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
|
||||
use crate::message_state::MessageState;
|
||||
use crate::service::template_service::TemplateService;
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct TemplateQuery {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/message-templates",
|
||||
params(TemplateQuery),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<MessageTemplateResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息模板"
|
||||
)]
|
||||
/// 查询消息模板列表。
|
||||
pub async fn list_templates<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<TemplateQuery>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<MessageTemplateResp>>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.template.list")?;
|
||||
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(20).max(1);
|
||||
|
||||
let (templates, total) =
|
||||
TemplateService::list(ctx.tenant_id, page, page_size, &_state.db).await?;
|
||||
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: templates,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/message-templates",
|
||||
request_body = CreateTemplateReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MessageTemplateResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息模板"
|
||||
)]
|
||||
/// 创建消息模板。
|
||||
pub async fn create_template<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateTemplateReq>,
|
||||
) -> Result<Json<ApiResponse<MessageTemplateResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.template.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = TemplateService::create(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/message-templates/{id}",
|
||||
request_body = UpdateTemplateReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MessageTemplateResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息模板"
|
||||
)]
|
||||
/// 更新消息模板。
|
||||
pub async fn update_template<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateTemplateReq>,
|
||||
) -> Result<Json<ApiResponse<MessageTemplateResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.template.manage")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = TemplateService::update(id, ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// 删除消息模板。
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn delete_template<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.template.manage")?;
|
||||
|
||||
TemplateService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
10
crates/erp-message/src/lib.rs
Normal file
10
crates/erp-message/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod message_state;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
|
||||
pub use message_state::MessageState;
|
||||
pub use module::MessageModule;
|
||||
9
crates/erp-message/src/message_state.rs
Normal file
9
crates/erp-message/src/message_state.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
/// 消息中心模块状态,通过 FromRef 从 AppState 提取。
|
||||
#[derive(Clone)]
|
||||
pub struct MessageState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
}
|
||||
1283
crates/erp-message/src/module.rs
Normal file
1283
crates/erp-message/src/module.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user