feat: initialize ERP base platform (extracted from HMS)
- Stripped 11 business crates (health, ai, dialysis, plugins) - Cleaned AppState, AppConfig, main.rs from business coupling - Reduced migrations from 169 to 53 (base-only) - Removed health_provider trait from erp-core - Removed business integration tests - Removed gateway rate limiting middleware - Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant Cargo check: OK Cargo test: OK
This commit is contained in:
29
crates/erp-auth/Cargo.toml
Normal file
29
crates/erp-auth/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "erp-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
sha2.workspace = true
|
||||
validator.workspace = true
|
||||
utoipa.workspace = true
|
||||
async-trait.workspace = true
|
||||
reqwest.workspace = true
|
||||
aes.workspace = true
|
||||
cbc.workspace = true
|
||||
hex.workspace = true
|
||||
base64 = "0.22"
|
||||
redis.workspace = true
|
||||
dashmap.workspace = true
|
||||
83
crates/erp-auth/src/auth_state.rs
Normal file
83
crates/erp-auth/src/auth_state.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use erp_core::crypto::PiiCrypto;
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Auth-specific state extracted from the server's AppState via `FromRef`.
|
||||
///
|
||||
/// This avoids a circular dependency between erp-auth and erp-server.
|
||||
/// The server crate implements `FromRef<AppState> for AuthState` so that
|
||||
/// Axum handlers in erp-auth can extract `State<AuthState>` directly.
|
||||
///
|
||||
/// Contains everything the auth handlers need:
|
||||
/// - Database connection for user/credential lookups
|
||||
/// - EventBus for publishing domain events
|
||||
/// - JWT configuration for token signing and validation
|
||||
/// - Default tenant ID for the bootstrap phase
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub jwt_secret: String,
|
||||
pub access_ttl_secs: i64,
|
||||
pub refresh_ttl_secs: i64,
|
||||
pub default_tenant_id: Uuid,
|
||||
pub wechat_appid: String,
|
||||
pub wechat_secret: String,
|
||||
pub wechat_dev_mode: bool,
|
||||
pub redis: Option<redis::Client>,
|
||||
pub crypto: PiiCrypto,
|
||||
}
|
||||
|
||||
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
||||
///
|
||||
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
|
||||
pub fn parse_ttl(ttl: &str) -> i64 {
|
||||
let ttl = ttl.trim();
|
||||
if let Some(num) = ttl.strip_suffix('s') {
|
||||
num.parse::<i64>().unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('m') {
|
||||
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('h') {
|
||||
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
|
||||
} else if let Some(num) = ttl.strip_suffix('d') {
|
||||
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
|
||||
} else {
|
||||
ttl.parse::<i64>().unwrap_or(900)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_seconds() {
|
||||
assert_eq!(parse_ttl("900s"), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_minutes() {
|
||||
assert_eq!(parse_ttl("15m"), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_hours() {
|
||||
assert_eq!(parse_ttl("1h"), 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_days() {
|
||||
assert_eq!(parse_ttl("7d"), 604800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_raw_number() {
|
||||
assert_eq!(parse_ttl("300"), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ttl_fallback_on_invalid() {
|
||||
assert_eq!(parse_ttl("invalid"), 900);
|
||||
}
|
||||
}
|
||||
506
crates/erp-auth/src/dto.rs
Normal file
506
crates/erp-auth/src/dto.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::sanitize::{sanitize_option, sanitize_string};
|
||||
|
||||
// --- Auth DTOs ---
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct LoginReq {
|
||||
#[validate(length(min = 1, message = "用户名不能为空"))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 1, max = 128, message = "密码长度需在1-128之间"))]
|
||||
pub password: String,
|
||||
/// 客户端类型: "miniprogram" 允许患者角色登录
|
||||
#[serde(default)]
|
||||
pub client_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LoginResp {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in: u64,
|
||||
pub user: UserResp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RefreshReq {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
// --- Wechat DTOs ---
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct WechatLoginReq {
|
||||
#[validate(length(min = 1, message = "code 不能为空"))]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WechatLoginResp {
|
||||
pub bound: bool,
|
||||
pub openid: String,
|
||||
pub token: Option<LoginResp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct WechatBindPhoneReq {
|
||||
#[validate(length(min = 1, message = "openid 不能为空"))]
|
||||
pub openid: String,
|
||||
#[validate(length(min = 1, message = "encrypted_data 不能为空"))]
|
||||
pub encrypted_data: String,
|
||||
#[validate(length(min = 1, message = "iv 不能为空"))]
|
||||
pub iv: String,
|
||||
}
|
||||
|
||||
/// 修改密码请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct ChangePasswordReq {
|
||||
#[validate(length(min = 1, message = "当前密码不能为空"))]
|
||||
pub current_password: String,
|
||||
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// 管理员重置用户密码请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct ResetPasswordReq {
|
||||
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
|
||||
pub new_password: String,
|
||||
#[validate(range(min = 0))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- User DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResp {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub status: String,
|
||||
pub roles: Vec<RoleResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateUserReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub username: String,
|
||||
#[validate(length(min = 6, max = 128))]
|
||||
pub password: String,
|
||||
#[validate(email)]
|
||||
pub email: Option<String>,
|
||||
#[validate(length(max = 20))]
|
||||
pub phone: Option<String>,
|
||||
#[validate(length(max = 100))]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateUserReq {
|
||||
/// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。
|
||||
pub fn sanitize(&mut self) {
|
||||
self.username = sanitize_string(&self.username);
|
||||
self.email = sanitize_option(self.email.take());
|
||||
self.phone = sanitize_option(self.phone.take());
|
||||
self.display_name = sanitize_option(self.display_name.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateUserReq {
|
||||
#[validate(email)]
|
||||
pub email: Option<String>,
|
||||
#[validate(length(max = 20))]
|
||||
pub phone: Option<String>,
|
||||
#[validate(length(max = 100))]
|
||||
pub display_name: Option<String>,
|
||||
#[validate(length(min = 1, max = 20))]
|
||||
pub status: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
impl UpdateUserReq {
|
||||
/// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。
|
||||
pub fn sanitize(&mut self) {
|
||||
self.email = sanitize_option(self.email.take());
|
||||
self.phone = sanitize_option(self.phone.take());
|
||||
self.display_name = sanitize_option(self.display_name.take());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Role DTOs ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct RoleResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateRoleReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub code: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateRoleReq {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct AssignRolesReq {
|
||||
#[validate(length(min = 1, message = "至少需要分配一个角色"))]
|
||||
pub role_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// --- Permission DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PermissionResp {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub resource: String,
|
||||
pub action: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct AssignPermissionsReq {
|
||||
#[validate(length(min = 1, message = "至少需要分配一个权限"))]
|
||||
pub permission_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// --- Organization DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct OrganizationResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub path: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub children: Vec<OrganizationResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateOrganizationReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateOrganizationReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Department DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DepartmentResp {
|
||||
pub id: Uuid,
|
||||
pub org_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub path: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub children: Vec<DepartmentResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateDepartmentReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateDepartmentReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub manager_id: Option<Uuid>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- Position DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PositionResp {
|
||||
pub id: Uuid,
|
||||
pub dept_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreatePositionReq {
|
||||
#[validate(length(min = 1))]
|
||||
pub name: String,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdatePositionReq {
|
||||
pub name: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub level: Option<i32>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use validator::Validate;
|
||||
|
||||
#[test]
|
||||
fn login_req_valid() {
|
||||
let req = LoginReq {
|
||||
username: "admin".to_string(),
|
||||
password: "password123".to_string(),
|
||||
client_type: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_req_empty_username_fails() {
|
||||
let req = LoginReq {
|
||||
username: "".to_string(),
|
||||
password: "password123".to_string(),
|
||||
client_type: None,
|
||||
};
|
||||
let result = req.validate();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_valid() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "newPassword456".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_empty_current_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "".to_string(),
|
||||
new_password: "newPassword456".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_short_new_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "12345".to_string(), // min 6
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_req_long_new_fails() {
|
||||
let req = ChangePasswordReq {
|
||||
current_password: "oldPassword123".to_string(),
|
||||
new_password: "a".repeat(129), // max 128
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_req_empty_password_fails() {
|
||||
let req = LoginReq {
|
||||
username: "admin".to_string(),
|
||||
password: "".to_string(),
|
||||
client_type: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_valid() {
|
||||
let req = CreateUserReq {
|
||||
username: "alice".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: Some("alice@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("Alice".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_short_password_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "bob".to_string(),
|
||||
password: "12345".to_string(), // min 6
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_empty_username_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_invalid_email_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "charlie".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
email: Some("not-an-email".to_string()),
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_user_req_long_username_fails() {
|
||||
let req = CreateUserReq {
|
||||
username: "a".repeat(51), // max 50
|
||||
password: "secret123".to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_valid() {
|
||||
let req = CreateRoleReq {
|
||||
name: "管理员".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: Some("系统管理员".to_string()),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_empty_name_fails() {
|
||||
let req = CreateRoleReq {
|
||||
name: "".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_role_req_empty_code_fails() {
|
||||
let req = CreateRoleReq {
|
||||
name: "管理员".to_string(),
|
||||
code: "".to_string(),
|
||||
description: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_org_req_valid() {
|
||||
let req = CreateOrganizationReq {
|
||||
name: "总部".to_string(),
|
||||
code: Some("HQ".to_string()),
|
||||
parent_id: None,
|
||||
sort_order: Some(0),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_org_req_empty_name_fails() {
|
||||
let req = CreateOrganizationReq {
|
||||
name: "".to_string(),
|
||||
code: None,
|
||||
parent_id: None,
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dept_req_valid() {
|
||||
let req = CreateDepartmentReq {
|
||||
name: "技术部".to_string(),
|
||||
code: Some("TECH".to_string()),
|
||||
parent_id: None,
|
||||
manager_id: None,
|
||||
sort_order: Some(1),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_position_req_valid() {
|
||||
let req = CreatePositionReq {
|
||||
name: "高级工程师".to_string(),
|
||||
code: Some("SENIOR".to_string()),
|
||||
level: Some(3),
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_position_req_empty_name_fails() {
|
||||
let req = CreatePositionReq {
|
||||
name: "".to_string(),
|
||||
code: None,
|
||||
level: None,
|
||||
sort_order: None,
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
}
|
||||
68
crates/erp-auth/src/entity/department.rs
Normal file
68
crates/erp-auth/src/entity/department.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "departments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub org_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manager_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::organization::Entity",
|
||||
from = "Column::OrgId",
|
||||
to = "super::organization::Column::Id",
|
||||
on_delete = "Restrict"
|
||||
)]
|
||||
Organization,
|
||||
#[sea_orm(has_many = "super::position::Entity")]
|
||||
Position,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::ManagerId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "SetNull"
|
||||
)]
|
||||
Manager,
|
||||
}
|
||||
|
||||
impl Related<super::organization::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Organization.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::position::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Position.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Manager.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
12
crates/erp-auth/src/entity/mod.rs
Normal file
12
crates/erp-auth/src/entity/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod department;
|
||||
pub mod organization;
|
||||
pub mod permission;
|
||||
pub mod position;
|
||||
pub mod role;
|
||||
pub mod role_permission;
|
||||
pub mod user;
|
||||
pub mod user_credential;
|
||||
pub mod user_department;
|
||||
pub mod user_role;
|
||||
pub mod user_token;
|
||||
pub mod wechat_user;
|
||||
40
crates/erp-auth/src/entity/organization.rs
Normal file
40
crates/erp-auth/src/entity/organization.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "organizations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::department::Entity")]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
37
crates/erp-auth/src/entity/permission.rs
Normal file
37
crates/erp-auth/src/entity/permission.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "permissions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub resource: String,
|
||||
pub action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||
RolePermission,
|
||||
}
|
||||
|
||||
impl Related<super::role_permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RolePermission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
42
crates/erp-auth/src/entity/position.rs
Normal file
42
crates/erp-auth/src/entity/position.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "positions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub dept_id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
pub level: i32,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::department::Entity",
|
||||
from = "Column::DeptId",
|
||||
to = "super::department::Column::Id",
|
||||
on_delete = "Restrict"
|
||||
)]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
crates/erp-auth/src/entity/role.rs
Normal file
44
crates/erp-auth/src/entity/role.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::role_permission::Entity")]
|
||||
RolePermission,
|
||||
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||
UserRole,
|
||||
}
|
||||
|
||||
impl Related<super::role_permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RolePermission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
53
crates/erp-auth/src/entity/role_permission.rs
Normal file
53
crates/erp-auth/src/entity/role_permission.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "role_permissions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub role_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub permission_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
/// 行级数据权限范围: all, self, department, department_tree
|
||||
pub data_scope: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::role::Entity",
|
||||
from = "Column::RoleId",
|
||||
to = "super::role::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Role,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::permission::Entity",
|
||||
from = "Column::PermissionId",
|
||||
to = "super::permission::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Permission,
|
||||
}
|
||||
|
||||
impl Related<super::role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Role.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::permission::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Permission.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
59
crates/erp-auth/src/entity/user.rs
Normal file
59
crates/erp-auth/src/entity/user.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::user_credential::Entity")]
|
||||
UserCredential,
|
||||
#[sea_orm(has_many = "super::user_token::Entity")]
|
||||
UserToken,
|
||||
#[sea_orm(has_many = "super::user_role::Entity")]
|
||||
UserRole,
|
||||
}
|
||||
|
||||
impl Related<super::user_credential::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserCredential.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_token::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserToken.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user_role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::UserRole.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
41
crates/erp-auth/src/entity/user_credential.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_credentials")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub credential_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_data: Option<serde_json::Value>,
|
||||
pub verified: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
54
crates/erp-auth/src/entity/user_department.rs
Normal file
54
crates/erp-auth/src/entity/user_department.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_departments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub department_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub is_primary: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::department::Entity",
|
||||
from = "Column::DepartmentId",
|
||||
to = "super::department::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Department,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::department::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Department.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-auth/src/entity/user_role.rs
Normal file
51
crates/erp-auth/src/entity/user_role.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_roles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub role_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::role::Entity",
|
||||
from = "Column::RoleId",
|
||||
to = "super::role::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Role,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::role::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Role.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
crates/erp-auth/src/entity/user_token.rs
Normal file
44
crates/erp-auth/src/entity/user_token.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_tokens")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub token_hash: String,
|
||||
pub token_type: String,
|
||||
pub expires_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub revoked_at: Option<DateTimeUtc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_info: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/erp-auth/src/entity/wechat_user.rs
Normal file
41
crates/erp-auth/src/entity/wechat_user.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "wechat_users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub openid: String,
|
||||
#[sea_orm(column_name = "union_id")]
|
||||
pub union_id: Option<String>,
|
||||
pub user_id: Uuid,
|
||||
pub phone: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
105
crates/erp-auth/src/error.rs
Normal file
105
crates/erp-auth/src/error.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// Auth module error types
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("用户名或密码错误")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("Token 已过期")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Token 已被吊销")]
|
||||
TokenRevoked,
|
||||
|
||||
#[error("用户已被{0}")]
|
||||
UserDisabled(String),
|
||||
|
||||
#[error("密码哈希错误")]
|
||||
HashError(String),
|
||||
|
||||
#[error("JWT 错误: {0}")]
|
||||
JwtError(#[from] jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DbError(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||
VersionMismatch,
|
||||
}
|
||||
|
||||
impl From<AuthError> for AppError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
match err {
|
||||
AuthError::InvalidCredentials => AppError::Unauthorized,
|
||||
AuthError::TokenExpired => AppError::Unauthorized,
|
||||
AuthError::TokenRevoked => AppError::Unauthorized,
|
||||
AuthError::UserDisabled(s) => AppError::Forbidden(s),
|
||||
AuthError::Validation(s) => AppError::Validation(s),
|
||||
AuthError::Forbidden(s) => AppError::Forbidden(s),
|
||||
AuthError::DbError(_) => AppError::Internal(err.to_string()),
|
||||
AuthError::HashError(_) => AppError::Internal(err.to_string()),
|
||||
AuthError::JwtError(_) => AppError::Unauthorized,
|
||||
AuthError::VersionMismatch => AppError::VersionMismatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use erp_core::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn auth_error_invalid_credentials_maps_to_unauthorized() {
|
||||
let app: AppError = AuthError::InvalidCredentials.into();
|
||||
match app {
|
||||
AppError::Unauthorized => {}
|
||||
other => panic!("Expected Unauthorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_token_expired_maps_to_unauthorized() {
|
||||
let app: AppError = AuthError::TokenExpired.into();
|
||||
match app {
|
||||
AppError::Unauthorized => {}
|
||||
other => panic!("Expected Unauthorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_user_disabled_maps_to_forbidden() {
|
||||
let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into();
|
||||
match app {
|
||||
AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"),
|
||||
other => panic!("Expected Forbidden, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_hash_error_maps_to_internal() {
|
||||
let app: AppError = AuthError::HashError("argon2 failed".to_string()).into();
|
||||
match app {
|
||||
AppError::Internal(_) => {}
|
||||
other => panic!("Expected Internal, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_validation_maps_to_validation() {
|
||||
let app: AppError = AuthError::Validation("用户名已存在".to_string()).into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"),
|
||||
other => panic!("Expected Validation, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
192
crates/erp-auth/src/handler/auth_handler.rs
Normal file
192
crates/erp-auth/src/handler/auth_handler.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq};
|
||||
use crate::service::auth_service::{AuthService, JwtConfig, RequestInfo};
|
||||
|
||||
/// 从请求头中提取客户端信息。
|
||||
fn extract_request_info(headers: &HeaderMap) -> RequestInfo {
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.or_else(|| headers.get("x-real-ip"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
RequestInfo { ip, user_agent }
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/login",
|
||||
request_body = LoginReq,
|
||||
responses(
|
||||
(status = 200, description = "登录成功", body = ApiResponse<LoginResp>),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "用户名或密码错误"),
|
||||
),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/login
|
||||
///
|
||||
/// Authenticates a user with username and password, returning access and refresh tokens.
|
||||
///
|
||||
/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`.
|
||||
/// In production, this will come from a tenant-resolution middleware.
|
||||
pub async fn login<S>(
|
||||
State(state): State<AuthState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<LoginReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let req_info = extract_request_info(&headers);
|
||||
let tenant_id = state.default_tenant_id;
|
||||
|
||||
let jwt_config = JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
};
|
||||
|
||||
let resp = AuthService::login(
|
||||
tenant_id,
|
||||
&req.username,
|
||||
&req.password,
|
||||
&state.db,
|
||||
&jwt_config,
|
||||
&state.event_bus,
|
||||
Some(&req_info),
|
||||
req.client_type.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshReq,
|
||||
responses(
|
||||
(status = 200, description = "刷新成功", body = ApiResponse<LoginResp>),
|
||||
(status = 401, description = "刷新令牌无效或已过期"),
|
||||
),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/refresh
|
||||
///
|
||||
/// Validates an existing refresh token, revokes it (rotation), and issues
|
||||
/// a new access + refresh token pair.
|
||||
pub async fn refresh<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<RefreshReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let jwt_config = JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
};
|
||||
|
||||
let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/logout",
|
||||
responses(
|
||||
(status = 200, description = "已成功登出"),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/logout
|
||||
///
|
||||
/// Revokes all refresh tokens for the authenticated user, effectively
|
||||
/// logging them out on all devices.
|
||||
pub async fn logout<S>(
|
||||
State(state): State<AuthState>,
|
||||
headers: HeaderMap,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let req_info = extract_request_info(&headers);
|
||||
AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db, Some(&req_info)).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("已成功登出".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/change-password",
|
||||
request_body = ChangePasswordReq,
|
||||
responses(
|
||||
(status = 200, description = "密码修改成功,需重新登录"),
|
||||
(status = 400, description = "当前密码不正确"),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/change-password
|
||||
///
|
||||
/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销,
|
||||
/// 用户需要在所有设备上重新登录。
|
||||
pub async fn change_password<S>(
|
||||
State(state): State<AuthState>,
|
||||
headers: HeaderMap,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<ChangePasswordReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let req_info = extract_request_info(&headers);
|
||||
AuthService::change_password(
|
||||
ctx.user_id,
|
||||
ctx.tenant_id,
|
||||
&req.current_password,
|
||||
&req.new_password,
|
||||
&state.db,
|
||||
Some(&req_info),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("密码修改成功,请重新登录".to_string()),
|
||||
}))
|
||||
}
|
||||
5
crates/erp-auth/src/handler/mod.rs
Normal file
5
crates/erp-auth/src/handler/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth_handler;
|
||||
pub mod org_handler;
|
||||
pub mod role_handler;
|
||||
pub mod user_handler;
|
||||
pub mod wechat_handler;
|
||||
460
crates/erp-auth/src/handler/org_handler.rs
Normal file
460
crates/erp-auth/src/handler/org_handler.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{
|
||||
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
||||
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
||||
};
|
||||
use crate::service::dept_service::DeptService;
|
||||
use crate::service::org_service::OrgService;
|
||||
use crate::service::position_service::PositionService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
// --- Organization handlers ---
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/organizations",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<OrganizationResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// GET /api/v1/organizations
|
||||
///
|
||||
/// List all organizations within the current tenant as a nested tree.
|
||||
/// Requires the `organization.list` permission.
|
||||
pub async fn list_organizations<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.list")?;
|
||||
|
||||
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(tree)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/organizations",
|
||||
request_body = CreateOrganizationReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<OrganizationResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// POST /api/v1/organizations
|
||||
///
|
||||
/// Create a new organization within the current tenant.
|
||||
/// Requires the `organization.create` permission.
|
||||
pub async fn create_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateOrganizationReq>,
|
||||
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let org = OrgService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(org)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/organizations/{id}",
|
||||
params(("id" = Uuid, Path, description = "组织ID")),
|
||||
request_body = UpdateOrganizationReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<OrganizationResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "组织不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// PUT /api/v1/organizations/{id}
|
||||
///
|
||||
/// Update editable organization fields (name, code, sort_order).
|
||||
/// Requires the `organization.update` permission.
|
||||
pub async fn update_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateOrganizationReq>,
|
||||
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.update")?;
|
||||
|
||||
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(org)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/organizations/{id}",
|
||||
params(("id" = Uuid, Path, description = "组织ID")),
|
||||
responses(
|
||||
(status = 200, description = "组织已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "组织不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// DELETE /api/v1/organizations/{id}
|
||||
///
|
||||
/// Soft-delete an organization by ID.
|
||||
/// Requires the `organization.delete` permission.
|
||||
pub async fn delete_organization<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "organization.delete")?;
|
||||
|
||||
OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("组织已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Department handlers ---
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/organizations/{org_id}/departments",
|
||||
params(("org_id" = Uuid, Path, description = "组织ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<DepartmentResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// GET /api/v1/organizations/{org_id}/departments
|
||||
///
|
||||
/// List all departments for an organization as a nested tree.
|
||||
/// Requires the `department.list` permission.
|
||||
pub async fn list_departments<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(org_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.list")?;
|
||||
|
||||
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(tree)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/organizations/{org_id}/departments",
|
||||
params(("org_id" = Uuid, Path, description = "组织ID")),
|
||||
request_body = CreateDepartmentReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<DepartmentResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// POST /api/v1/organizations/{org_id}/departments
|
||||
///
|
||||
/// Create a new department under the specified organization.
|
||||
/// Requires the `department.create` permission.
|
||||
pub async fn create_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(org_id): Path<Uuid>,
|
||||
Json(req): Json<CreateDepartmentReq>,
|
||||
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let dept = DeptService::create(
|
||||
org_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/departments/{id}",
|
||||
params(("id" = Uuid, Path, description = "部门ID")),
|
||||
request_body = UpdateDepartmentReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<DepartmentResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "部门不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// PUT /api/v1/departments/{id}
|
||||
///
|
||||
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||
/// Requires the `department.update` permission.
|
||||
pub async fn update_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateDepartmentReq>,
|
||||
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.update")?;
|
||||
|
||||
let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(dept)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/departments/{id}",
|
||||
params(("id" = Uuid, Path, description = "部门ID")),
|
||||
responses(
|
||||
(status = 200, description = "部门已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "部门不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// DELETE /api/v1/departments/{id}
|
||||
///
|
||||
/// Soft-delete a department by ID.
|
||||
/// Requires the `department.delete` permission.
|
||||
pub async fn delete_department<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "department.delete")?;
|
||||
|
||||
DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("部门已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Position handlers ---
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/departments/{dept_id}/positions",
|
||||
params(("dept_id" = Uuid, Path, description = "部门ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<PositionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// GET /api/v1/departments/{dept_id}/positions
|
||||
///
|
||||
/// List all positions for a department.
|
||||
/// Requires the `position.list` permission.
|
||||
pub async fn list_positions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dept_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.list")?;
|
||||
|
||||
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(positions)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/departments/{dept_id}/positions",
|
||||
params(("dept_id" = Uuid, Path, description = "部门ID")),
|
||||
request_body = CreatePositionReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<PositionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// POST /api/v1/departments/{dept_id}/positions
|
||||
///
|
||||
/// Create a new position under the specified department.
|
||||
/// Requires the `position.create` permission.
|
||||
pub async fn create_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(dept_id): Path<Uuid>,
|
||||
Json(req): Json<CreatePositionReq>,
|
||||
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let pos = PositionService::create(
|
||||
dept_id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/positions/{id}",
|
||||
params(("id" = Uuid, Path, description = "岗位ID")),
|
||||
request_body = UpdatePositionReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<PositionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "岗位不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// PUT /api/v1/positions/{id}
|
||||
///
|
||||
/// Update editable position fields (name, code, level, sort_order).
|
||||
/// Requires the `position.update` permission.
|
||||
pub async fn update_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePositionReq>,
|
||||
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.update")?;
|
||||
|
||||
let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(pos)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/positions/{id}",
|
||||
params(("id" = Uuid, Path, description = "岗位ID")),
|
||||
responses(
|
||||
(status = 200, description = "岗位已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "岗位不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "组织管理"
|
||||
)]
|
||||
/// DELETE /api/v1/positions/{id}
|
||||
///
|
||||
/// Soft-delete a position by ID.
|
||||
/// Requires the `position.delete` permission.
|
||||
pub async fn delete_position<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "position.delete")?;
|
||||
|
||||
PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("岗位已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
320
crates/erp-auth/src/handler/role_handler.rs
Normal file
320
crates/erp-auth/src/handler/role_handler.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
||||
use crate::service::permission_service::PermissionService;
|
||||
use crate::service::role_service::RoleService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/roles",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<RoleResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// GET /api/v1/roles
|
||||
///
|
||||
/// List roles within the current tenant with pagination.
|
||||
/// Requires the `role.list` permission.
|
||||
pub async fn list_roles<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.list")?;
|
||||
|
||||
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: roles,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/roles",
|
||||
request_body = CreateRoleReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<RoleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// POST /api/v1/roles
|
||||
///
|
||||
/// Create a new role within the current tenant.
|
||||
/// Requires the `role.create` permission.
|
||||
pub async fn create_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateRoleReq>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let role = RoleService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.code,
|
||||
&req.description,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/roles/{id}",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<RoleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// GET /api/v1/roles/:id
|
||||
///
|
||||
/// Fetch a single role by ID within the current tenant.
|
||||
/// Requires the `role.read` permission.
|
||||
pub async fn get_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.read")?;
|
||||
|
||||
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/roles/{id}",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
request_body = UpdateRoleReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<RoleResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// PUT /api/v1/roles/:id
|
||||
///
|
||||
/// Update editable role fields (name, description).
|
||||
/// Requires the `role.update` permission.
|
||||
pub async fn update_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateRoleReq>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.update")?;
|
||||
|
||||
let role = RoleService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.description,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/roles/{id}",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
responses(
|
||||
(status = 200, description = "角色已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// DELETE /api/v1/roles/:id
|
||||
///
|
||||
/// Soft-delete a role by ID within the current tenant.
|
||||
/// System roles cannot be deleted.
|
||||
/// Requires the `role.delete` permission.
|
||||
pub async fn delete_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.delete")?;
|
||||
|
||||
RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("角色已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/roles/{id}/permissions",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
request_body = AssignPermissionsReq,
|
||||
responses(
|
||||
(status = 200, description = "权限分配成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// POST /api/v1/roles/:id/permissions
|
||||
///
|
||||
/// Replace all permission assignments for a role.
|
||||
/// Requires the `role.update` permission.
|
||||
pub async fn assign_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<AssignPermissionsReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.update")?;
|
||||
|
||||
RoleService::assign_permissions(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.permission_ids,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("权限分配成功".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/roles/{id}/permissions",
|
||||
params(("id" = Uuid, Path, description = "角色ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "角色不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "角色管理"
|
||||
)]
|
||||
/// GET /api/v1/roles/:id/permissions
|
||||
///
|
||||
/// Fetch all permissions assigned to a role.
|
||||
/// Requires the `role.read` permission.
|
||||
pub async fn get_role_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.read")?;
|
||||
|
||||
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(perms)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/permissions",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "权限管理"
|
||||
)]
|
||||
/// GET /api/v1/permissions
|
||||
///
|
||||
/// List all permissions within the current tenant.
|
||||
/// Requires the `permission.list` permission.
|
||||
pub async fn list_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "permission.list")?;
|
||||
|
||||
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(perms)))
|
||||
}
|
||||
322
crates/erp-auth/src/handler/user_handler.rs
Normal file
322
crates/erp-auth/src/handler/user_handler.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{CreateUserReq, ResetPasswordReq, RoleResp, UpdateUserReq, UserResp};
|
||||
use crate::service::user_service::UserService;
|
||||
use erp_core::rbac::require_permission;
|
||||
|
||||
/// Query parameters for user list endpoint.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct UserListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
/// Optional search term — filters by username (case-insensitive contains).
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/users",
|
||||
params(UserListParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<UserResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// GET /api/v1/users
|
||||
///
|
||||
/// List users within the current tenant with pagination and optional search.
|
||||
/// Requires the `user.list` permission.
|
||||
pub async fn list_users<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<UserListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<UserResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.list")?;
|
||||
|
||||
let pagination = Pagination {
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (users, total) = UserService::list(
|
||||
ctx.tenant_id,
|
||||
&pagination,
|
||||
params.search.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: users,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/users",
|
||||
request_body = CreateUserReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<UserResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// POST /api/v1/users
|
||||
///
|
||||
/// Create a new user within the current tenant.
|
||||
/// Requires the `user.create` permission.
|
||||
pub async fn create_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(mut req): Json<CreateUserReq>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
req.sanitize();
|
||||
|
||||
let user = UserService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<UserResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// GET /api/v1/users/:id
|
||||
///
|
||||
/// Fetch a single user by ID within the current tenant.
|
||||
/// Requires the `user.read` permission.
|
||||
pub async fn get_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.read")?;
|
||||
|
||||
let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
request_body = UpdateUserReq,
|
||||
responses(
|
||||
(status = 200, description = "更新成功", body = ApiResponse<UserResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// PUT /api/v1/users/:id
|
||||
///
|
||||
/// Update editable user fields.
|
||||
/// Requires the `user.update` permission.
|
||||
pub async fn update_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(mut req): Json<UpdateUserReq>,
|
||||
) -> Result<Json<ApiResponse<UserResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.update")?;
|
||||
|
||||
req.sanitize();
|
||||
|
||||
let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/users/{id}",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
responses(
|
||||
(status = 200, description = "用户已删除"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// DELETE /api/v1/users/:id
|
||||
///
|
||||
/// Soft-delete a user by ID within the current tenant.
|
||||
/// Requires the `user.delete` permission.
|
||||
pub async fn delete_user<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.delete")?;
|
||||
|
||||
UserService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("用户已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Assign roles request body.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignRolesReq {
|
||||
pub role_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// Assign roles response.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AssignRolesResp {
|
||||
pub roles: Vec<RoleResp>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/users/{id}/roles",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
request_body = AssignRolesReq,
|
||||
responses(
|
||||
(status = 200, description = "角色分配成功", body = ApiResponse<AssignRolesResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// POST /api/v1/users/:id/roles
|
||||
///
|
||||
/// Replace all role assignments for a user within the current tenant.
|
||||
/// Requires the `user.update` permission.
|
||||
pub async fn assign_roles<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<AssignRolesReq>,
|
||||
) -> Result<Json<ApiResponse<AssignRolesResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.update")?;
|
||||
|
||||
let roles =
|
||||
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/users/{id}/reset-password",
|
||||
params(("id" = Uuid, Path, description = "用户ID")),
|
||||
request_body = ResetPasswordReq,
|
||||
responses(
|
||||
(status = 200, description = "密码重置成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "用户不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "用户管理"
|
||||
)]
|
||||
/// POST /api/v1/users/{id}/reset-password
|
||||
///
|
||||
/// 管理员重置指定用户密码。需要 `user.reset-password` 权限。
|
||||
pub async fn reset_password<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<ResetPasswordReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "user.reset-password")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
UserService::reset_password(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.new_password,
|
||||
req.version,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("密码重置成功".to_string()),
|
||||
}))
|
||||
}
|
||||
86
crates/erp-auth/src/handler/wechat_handler.rs
Normal file
86
crates/erp-auth/src/handler/wechat_handler.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use axum::extract::{FromRef, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::ApiResponse;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{LoginResp, WechatBindPhoneReq, WechatLoginReq, WechatLoginResp};
|
||||
use crate::service::wechat_service::WechatService;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/wechat/login",
|
||||
request_body = WechatLoginReq,
|
||||
responses(
|
||||
(status = 200, description = "微信登录成功", body = ApiResponse<WechatLoginResp>),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/wechat/login
|
||||
///
|
||||
/// 微信小程序登录:用 code 换 openid,查询绑定状态。
|
||||
/// 已绑定用户直接返回 JWT,未绑定用户返回 openid 供后续绑定。
|
||||
pub async fn wechat_login<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<WechatLoginReq>,
|
||||
) -> Result<Json<ApiResponse<WechatLoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
code = %req.code,
|
||||
tenant_id = %state.default_tenant_id,
|
||||
has_appid = !state.wechat_appid.is_empty(),
|
||||
has_secret = !state.wechat_secret.is_empty(),
|
||||
"微信登录请求"
|
||||
);
|
||||
|
||||
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
|
||||
let tenant_id = state.default_tenant_id;
|
||||
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
|
||||
tracing::info!(
|
||||
bound = resp.bound,
|
||||
has_token = resp.token.is_some(),
|
||||
"微信登录结果"
|
||||
);
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/auth/wechat/bind-phone",
|
||||
request_body = WechatBindPhoneReq,
|
||||
responses(
|
||||
(status = 200, description = "绑定成功", body = ApiResponse<LoginResp>),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
),
|
||||
tag = "认证"
|
||||
)]
|
||||
/// POST /api/v1/auth/wechat/bind-phone
|
||||
///
|
||||
/// 微信手机号绑定:解密手机号,创建/关联 user,签发 JWT。
|
||||
pub async fn wechat_bind_phone<S>(
|
||||
State(state): State<AuthState>,
|
||||
Json(req): Json<WechatBindPhoneReq>,
|
||||
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
// TODO: 多租户微信登录需要设计租户解析策略
|
||||
let tenant_id = state.default_tenant_id;
|
||||
let resp =
|
||||
WechatService::bind_phone(&state, tenant_id, &req.openid, &req.encrypted_data, &req.iv)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
11
crates/erp-auth/src/lib.rs
Normal file
11
crates/erp-auth/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod auth_state;
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod middleware;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
|
||||
pub use auth_state::AuthState;
|
||||
pub use module::AuthModule;
|
||||
273
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
273
crates/erp-auth/src/middleware/jwt_auth.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use dashmap::DashMap;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::request_info::REQUEST_INFO;
|
||||
use erp_core::request_info::RequestInfo;
|
||||
use erp_core::types::{DataScope, TenantContext};
|
||||
|
||||
use crate::service::token_service::TokenService;
|
||||
|
||||
type DeptIds = Vec<uuid::Uuid>;
|
||||
type DataScopes = std::collections::HashMap<String, DataScope>;
|
||||
type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
|
||||
|
||||
/// 用户权限数据缓存(user_id -> (department_ids, data_scopes, cached_at))
|
||||
/// DashMap 分片并发,读写无锁竞争
|
||||
static USER_SCOPE_CACHE: std::sync::LazyLock<DashMap<uuid::Uuid, ScopeCacheEntry>> =
|
||||
std::sync::LazyLock::new(DashMap::new);
|
||||
|
||||
/// Access Token 吊销黑名单(token_hash -> 过期时间戳)
|
||||
/// key = SHA-256(token) 前 16 字符,value = token 的 exp 时间戳
|
||||
/// 惰性清理:检查时自动移除过期条目
|
||||
static TOKEN_BLACKLIST: std::sync::LazyLock<DashMap<String, i64>> =
|
||||
std::sync::LazyLock::new(DashMap::new);
|
||||
|
||||
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// 吊销单个 access token(直到其自然过期)
|
||||
pub fn revoke_access_token(token: &str, exp: i64) {
|
||||
let hash = token_hash(token);
|
||||
TOKEN_BLACKLIST.insert(hash, exp);
|
||||
}
|
||||
|
||||
/// 吊销用户所有 token(清除权限缓存,强制下次请求重新认证)
|
||||
pub fn revoke_all_user_tokens(user_id: uuid::Uuid) {
|
||||
USER_SCOPE_CACHE.remove(&user_id);
|
||||
}
|
||||
|
||||
/// 检查 token 是否已被吊销
|
||||
fn is_token_revoked(token: &str, _exp: i64) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
// 惰性清理过期条目
|
||||
if TOKEN_BLACKLIST.len() > 10_000 {
|
||||
TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now);
|
||||
}
|
||||
let hash = token_hash(token);
|
||||
match TOKEN_BLACKLIST.get(&hash) {
|
||||
Some(exp_ts) => {
|
||||
if *exp_ts <= now {
|
||||
drop(exp_ts);
|
||||
TOKEN_BLACKLIST.remove(&hash);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn token_hash(token: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
token.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
///
|
||||
/// Extracts the `Bearer` token from the `Authorization` header, validates it
|
||||
/// using `TokenService::decode_token`, and injects a `TenantContext` into the
|
||||
/// request extensions so downstream handlers can access tenant/user identity.
|
||||
///
|
||||
/// 同时提取请求的 IP 地址和 User-Agent,通过 task_local 传递给审计服务,
|
||||
/// 使所有审计日志自动记录来源信息。
|
||||
///
|
||||
/// The `jwt_secret` parameter is passed explicitly by the server crate at
|
||||
/// middleware construction time, avoiding any circular dependency between
|
||||
/// erp-auth and erp-server.
|
||||
///
|
||||
/// When `db` is provided, the middleware queries `user_departments` to populate
|
||||
/// `department_ids` in the `TenantContext`. If `db` is `None` or the query fails,
|
||||
/// `department_ids` defaults to an empty list (equivalent to "all" data scope).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AppError::Unauthorized` if:
|
||||
/// - The `Authorization` header is missing
|
||||
/// - The header value does not start with `"Bearer "`
|
||||
/// - The token cannot be decoded or has expired
|
||||
/// - The token type is not "access"
|
||||
pub async fn jwt_auth_middleware_fn(
|
||||
jwt_secret: String,
|
||||
db: Option<sea_orm::DatabaseConnection>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
// 优先从 Authorization 头提取 token;
|
||||
// 回退到 URL query parameter ?token=xxx(SSE/EventSource 无法设置自定义头)
|
||||
let token = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "))
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
req.uri().query().and_then(|q| {
|
||||
q.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("token="))
|
||||
.map(String::from)
|
||||
})
|
||||
})
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims =
|
||||
TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
// 检查 token 是否已被吊销(密码修改/管理员强制下线)
|
||||
if is_token_revoked(&token, claims.exp) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// Verify this is an access token, not a refresh token
|
||||
if claims.token_type != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// 查询用户所属部门 ID 列表 + 权限数据范围(带 60 秒缓存)
|
||||
let cached = USER_SCOPE_CACHE.get(&claims.sub).and_then(|entry| {
|
||||
let (_, _, at) = entry.value();
|
||||
if at.elapsed() < SCOPE_CACHE_TTL {
|
||||
let (depts, scopes, _) = entry.value();
|
||||
Some((depts.clone(), scopes.clone()))
|
||||
} else {
|
||||
drop(entry);
|
||||
USER_SCOPE_CACHE.remove(&claims.sub);
|
||||
None
|
||||
}
|
||||
});
|
||||
let (department_ids, permission_data_scopes) = match cached {
|
||||
Some(hit) => hit,
|
||||
None => fetch_and_cache_scopes(claims.sub, claims.tid, &db).await,
|
||||
};
|
||||
|
||||
// 提取请求来源信息(IP + User-Agent),用于审计日志
|
||||
let request_info = RequestInfo::from_headers(req.headers());
|
||||
|
||||
let ctx = TenantContext {
|
||||
tenant_id: claims.tid,
|
||||
user_id: claims.sub,
|
||||
roles: claims.roles,
|
||||
permissions: claims.permissions,
|
||||
department_ids,
|
||||
permission_data_scopes,
|
||||
};
|
||||
|
||||
// Reconstruct the request with the TenantContext injected into extensions.
|
||||
// We cannot borrow `req` mutably after reading headers, so we rebuild.
|
||||
let (parts, body) = req.into_parts();
|
||||
let mut req = Request::from_parts(parts, body);
|
||||
req.extensions_mut().insert(ctx);
|
||||
|
||||
// 在 task_local scope 中运行后续处理,审计服务可自动读取请求信息
|
||||
Ok(REQUEST_INFO.scope(request_info, next.run(req)).await)
|
||||
}
|
||||
|
||||
/// 查询用户所属的所有部门 ID(通过 user_departments 关联表)
|
||||
async fn fetch_user_department_ids(
|
||||
user_id: uuid::Uuid,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> Vec<uuid::Uuid> {
|
||||
use crate::entity::user_department;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
user_department::Entity::find()
|
||||
.filter(user_department::Column::UserId.eq(user_id))
|
||||
.filter(user_department::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_department::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(|r| r.department_id).collect())
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "查询用户部门列表失败,默认为空");
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
/// 查询用户每个权限的数据范围(从 role_permissions 表)
|
||||
async fn fetch_permission_data_scopes(
|
||||
user_id: uuid::Uuid,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> std::collections::HashMap<String, DataScope> {
|
||||
use sea_orm::ConnectionTrait;
|
||||
|
||||
let sql = r#"
|
||||
SELECT p.code, MIN(
|
||||
CASE rp.data_scope
|
||||
WHEN 'all' THEN 0
|
||||
WHEN 'department_tree' THEN 1
|
||||
WHEN 'department' THEN 2
|
||||
WHEN 'self' THEN 3
|
||||
ELSE 0
|
||||
END
|
||||
) AS scope_rank,
|
||||
MIN(rp.data_scope) AS data_scope
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON ur.role_id = rp.role_id AND ur.tenant_id = rp.tenant_id
|
||||
JOIN permissions p ON rp.permission_id = p.id
|
||||
WHERE ur.user_id = $1
|
||||
AND ur.tenant_id = $2
|
||||
AND ur.deleted_at IS NULL
|
||||
AND rp.deleted_at IS NULL
|
||||
GROUP BY p.code
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[user_id.into(), tenant_id.into()],
|
||||
);
|
||||
|
||||
match db.query_all(stmt).await {
|
||||
Ok(rows) => {
|
||||
let mut scopes = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
if let (Ok(code), Ok(scope)) = (
|
||||
row.try_get_by_index::<String>(0),
|
||||
row.try_get_by_index::<String>(2),
|
||||
) {
|
||||
scopes.insert(code, DataScope::parse_scope(&scope));
|
||||
}
|
||||
}
|
||||
scopes
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "查询权限数据范围失败,默认全部 All");
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 查询部门 + 权限范围,并写入缓存
|
||||
async fn fetch_and_cache_scopes(
|
||||
user_id: uuid::Uuid,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &Option<sea_orm::DatabaseConnection>,
|
||||
) -> (
|
||||
Vec<uuid::Uuid>,
|
||||
std::collections::HashMap<String, DataScope>,
|
||||
) {
|
||||
let depts = match db {
|
||||
Some(conn) => fetch_user_department_ids(user_id, tenant_id, conn).await,
|
||||
None => vec![],
|
||||
};
|
||||
let scopes = match db {
|
||||
Some(conn) => fetch_permission_data_scopes(user_id, tenant_id, conn).await,
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
USER_SCOPE_CACHE.insert(
|
||||
user_id,
|
||||
(depts.clone(), scopes.clone(), std::time::Instant::now()),
|
||||
);
|
||||
// 惰性淘汰过期条目,防止 DashMap 无限增长
|
||||
if USER_SCOPE_CACHE.len() > 500 {
|
||||
let now = std::time::Instant::now();
|
||||
USER_SCOPE_CACHE.retain(|_, (_, _, at)| now.duration_since(*at) < SCOPE_CACHE_TTL);
|
||||
}
|
||||
(depts, scopes)
|
||||
}
|
||||
4
crates/erp-auth/src/middleware/mod.rs
Normal file
4
crates/erp-auth/src/middleware/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod jwt_auth;
|
||||
|
||||
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||
pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens};
|
||||
372
crates/erp-auth/src/module.rs
Normal file
372
crates/erp-auth/src/module.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use axum::Router;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{auth_handler, org_handler, role_handler, user_handler, wechat_handler};
|
||||
|
||||
/// Auth module implementing the `ErpModule` trait.
|
||||
///
|
||||
/// Manages identity, authentication, and user CRUD within the ERP platform.
|
||||
/// This module has no dependencies on other business modules.
|
||||
pub struct AuthModule;
|
||||
|
||||
impl AuthModule {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Build public (unauthenticated) routes for the auth module.
|
||||
///
|
||||
/// These routes do not require a valid JWT token.
|
||||
/// The caller wraps this into whatever state type the application uses.
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||
.route(
|
||||
"/auth/wechat/login",
|
||||
axum::routing::post(wechat_handler::wechat_login),
|
||||
)
|
||||
.route(
|
||||
"/auth/wechat/bind-phone",
|
||||
axum::routing::post(wechat_handler::wechat_bind_phone),
|
||||
)
|
||||
}
|
||||
|
||||
/// Refresh token routes — public but with higher rate limit (30/min vs 5/min for login).
|
||||
pub fn refresh_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new().route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
||||
}
|
||||
|
||||
/// Build protected (authenticated) routes for the auth module.
|
||||
///
|
||||
/// These routes require a valid JWT token, verified by the middleware layer.
|
||||
/// The caller wraps this into whatever state type the application uses.
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/logout", axum::routing::post(auth_handler::logout))
|
||||
.route(
|
||||
"/auth/change-password",
|
||||
axum::routing::post(auth_handler::change_password),
|
||||
)
|
||||
.route(
|
||||
"/users",
|
||||
axum::routing::get(user_handler::list_users).post(user_handler::create_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}",
|
||||
axum::routing::get(user_handler::get_user)
|
||||
.put(user_handler::update_user)
|
||||
.delete(user_handler::delete_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/roles",
|
||||
axum::routing::post(user_handler::assign_roles),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/reset-password",
|
||||
axum::routing::post(user_handler::reset_password),
|
||||
)
|
||||
.route(
|
||||
"/roles",
|
||||
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
|
||||
)
|
||||
// 精确匹配 /roles/permissions,必须在 /roles/{id} 之前注册
|
||||
.route(
|
||||
"/roles/permissions",
|
||||
axum::routing::get(role_handler::list_permissions),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}",
|
||||
axum::routing::get(role_handler::get_role)
|
||||
.put(role_handler::update_role)
|
||||
.delete(role_handler::delete_role),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}/permissions",
|
||||
axum::routing::get(role_handler::get_role_permissions)
|
||||
.post(role_handler::assign_permissions),
|
||||
)
|
||||
.route(
|
||||
"/permissions",
|
||||
axum::routing::get(role_handler::list_permissions),
|
||||
)
|
||||
// Organization routes
|
||||
.route(
|
||||
"/organizations",
|
||||
axum::routing::get(org_handler::list_organizations)
|
||||
.post(org_handler::create_organization),
|
||||
)
|
||||
.route(
|
||||
"/organizations/{id}",
|
||||
axum::routing::put(org_handler::update_organization)
|
||||
.delete(org_handler::delete_organization),
|
||||
)
|
||||
// Department routes (nested under organization)
|
||||
.route(
|
||||
"/organizations/{org_id}/departments",
|
||||
axum::routing::get(org_handler::list_departments)
|
||||
.post(org_handler::create_department),
|
||||
)
|
||||
.route(
|
||||
"/departments/{id}",
|
||||
axum::routing::put(org_handler::update_department)
|
||||
.delete(org_handler::delete_department),
|
||||
)
|
||||
// Position routes (nested under department)
|
||||
.route(
|
||||
"/departments/{dept_id}/positions",
|
||||
axum::routing::get(org_handler::list_positions).post(org_handler::create_position),
|
||||
)
|
||||
.route(
|
||||
"/positions/{id}",
|
||||
axum::routing::put(org_handler::update_position)
|
||||
.delete(org_handler::delete_position),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthModule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for AuthModule {
|
||||
fn name(&self) -> &str {
|
||||
"auth"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
// Auth is a foundational module with no business-module dependencies.
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||
// Auth 模块暂无跨模块事件订阅需求
|
||||
}
|
||||
|
||||
async fn on_tenant_created(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD").map_err(|_| {
|
||||
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
|
||||
erp_core::error::AppError::Internal("ERP__SUPER_ADMIN_PASSWORD 未设置".to_string())
|
||||
})?;
|
||||
crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
tracing::info!(tenant_id = %tenant_id, "Tenant auth initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_tenant_deleted(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// 软删除该租户下所有用户
|
||||
let users = crate::entity::user::Entity::find()
|
||||
.filter(crate::entity::user::Column::TenantId.eq(tenant_id))
|
||||
.filter(crate::entity::user::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
|
||||
for user_model in users {
|
||||
let current_version = user_model.version;
|
||||
let active: crate::entity::user::ActiveModel = user_model.into();
|
||||
let mut to_update: crate::entity::user::ActiveModel = active;
|
||||
to_update.deleted_at = Set(Some(now));
|
||||
to_update.updated_at = Set(now);
|
||||
to_update.version = Set(current_version + 1);
|
||||
let _ = to_update
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
tracing::info!(tenant_id = %tenant_id, "Tenant users soft-deleted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor {
|
||||
code: "user.list".into(),
|
||||
name: "查看用户列表".into(),
|
||||
description: "查看用户列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.create".into(),
|
||||
name: "创建用户".into(),
|
||||
description: "创建新用户".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.read".into(),
|
||||
name: "查看用户详情".into(),
|
||||
description: "查看用户信息".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.update".into(),
|
||||
name: "编辑用户".into(),
|
||||
description: "编辑用户信息".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.delete".into(),
|
||||
name: "删除用户".into(),
|
||||
description: "软删除用户".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "user.reset-password".into(),
|
||||
name: "重置用户密码".into(),
|
||||
description: "管理员重置指定用户密码".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.list".into(),
|
||||
name: "查看角色列表".into(),
|
||||
description: "查看角色列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.create".into(),
|
||||
name: "创建角色".into(),
|
||||
description: "创建新角色".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.read".into(),
|
||||
name: "查看角色详情".into(),
|
||||
description: "查看角色信息".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.update".into(),
|
||||
name: "编辑角色".into(),
|
||||
description: "编辑角色".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "role.delete".into(),
|
||||
name: "删除角色".into(),
|
||||
description: "删除角色".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "permission.list".into(),
|
||||
name: "查看权限".into(),
|
||||
description: "查看权限列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.list".into(),
|
||||
name: "查看组织列表".into(),
|
||||
description: "查看组织列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.create".into(),
|
||||
name: "创建组织".into(),
|
||||
description: "创建组织".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.update".into(),
|
||||
name: "编辑组织".into(),
|
||||
description: "编辑组织".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "organization.delete".into(),
|
||||
name: "删除组织".into(),
|
||||
description: "删除组织".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.list".into(),
|
||||
name: "查看部门列表".into(),
|
||||
description: "查看部门列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.create".into(),
|
||||
name: "创建部门".into(),
|
||||
description: "创建部门".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.update".into(),
|
||||
name: "编辑部门".into(),
|
||||
description: "编辑部门".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "department.delete".into(),
|
||||
name: "删除部门".into(),
|
||||
description: "删除部门".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.list".into(),
|
||||
name: "查看岗位列表".into(),
|
||||
description: "查看岗位列表".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.create".into(),
|
||||
name: "创建岗位".into(),
|
||||
description: "创建岗位".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.update".into(),
|
||||
name: "编辑岗位".into(),
|
||||
description: "编辑岗位".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "position.delete".into(),
|
||||
name: "删除岗位".into(),
|
||||
description: "删除岗位".into(),
|
||||
module: "auth".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
414
crates/erp-auth/src/service/auth_service.rs
Normal file
414
crates/erp-auth/src/service/auth_service.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{LoginResp, RoleResp, UserResp};
|
||||
use crate::entity::{role, user, user_credential, user_role};
|
||||
use crate::error::AuthError;
|
||||
use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::error::AuthResult;
|
||||
|
||||
use super::password;
|
||||
use super::token_service::TokenService;
|
||||
|
||||
/// 请求来源信息,用于审计日志记录。
|
||||
pub struct RequestInfo {
|
||||
pub ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
/// JWT configuration needed for token signing.
|
||||
pub struct JwtConfig<'a> {
|
||||
pub secret: &'a str,
|
||||
pub access_ttl_secs: i64,
|
||||
pub refresh_ttl_secs: i64,
|
||||
}
|
||||
|
||||
/// Authentication service handling login, token refresh, and logout.
|
||||
pub struct AuthService;
|
||||
|
||||
impl AuthService {
|
||||
/// Authenticate a user and issue access + refresh tokens.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Look up user by tenant + username (soft-delete aware)
|
||||
/// 2. Verify user status is "active"
|
||||
/// 3. Fetch the stored password credential
|
||||
/// 4. Verify password hash
|
||||
/// 5. Collect roles and permissions
|
||||
/// 6. Sign JWT tokens
|
||||
/// 7. Update last_login_at
|
||||
/// 8. Publish login event
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn login(
|
||||
tenant_id: Uuid,
|
||||
username: &str,
|
||||
password_plain: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
jwt: &JwtConfig<'_>,
|
||||
event_bus: &EventBus,
|
||||
req_info: Option<&RequestInfo>,
|
||||
client_type: Option<&str>,
|
||||
) -> AuthResult<LoginResp> {
|
||||
// 1. Find user by tenant_id + username
|
||||
let user_model = match user::Entity::find()
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::Username.eq(username))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
{
|
||||
Some(m) => m,
|
||||
None => {
|
||||
// 审计:用户不存在(登录失败)
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, None, "user.login_failed", "user").with_request_info(
|
||||
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Check user status
|
||||
if user_model.status != "active" {
|
||||
return Err(AuthError::UserDisabled(user_model.status.clone()));
|
||||
}
|
||||
|
||||
// 3. Find password credential
|
||||
let cred = user_credential::Entity::find()
|
||||
.filter(user_credential::Column::UserId.eq(user_model.id))
|
||||
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||
.filter(user_credential::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
// 4. Verify password
|
||||
let stored_hash = cred
|
||||
.credential_data
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
if !password::verify_password(password_plain, stored_hash)? {
|
||||
// 审计:密码错误(登录失败)
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user")
|
||||
.with_request_info(
|
||||
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
|
||||
// 5. Get roles and permissions
|
||||
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
||||
|
||||
// 纯患者角色不允许登录管理端(同时拥有医护角色则放行)
|
||||
// 小程序端 (client_type=miniprogram) 允许患者登录
|
||||
let medical_roles = ["doctor", "nurse", "admin", "health_manager", "operator"];
|
||||
let is_pure_patient =
|
||||
roles.iter().all(|r| r == "patient") && roles.iter().any(|r| r == "patient");
|
||||
let has_medical_role = roles.iter().any(|r| medical_roles.contains(&r.as_str()));
|
||||
let is_miniprogram = client_type == Some("miniprogram");
|
||||
if is_pure_patient && !has_medical_role && !is_miniprogram {
|
||||
return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string()));
|
||||
}
|
||||
|
||||
// 小程序端仅允许患者角色登录,医护角色请使用管理端
|
||||
let has_patient_role = roles.iter().any(|r| r == "patient");
|
||||
if is_miniprogram && !has_patient_role {
|
||||
return Err(AuthError::Forbidden("医护账号请使用管理端登录".to_string()));
|
||||
}
|
||||
|
||||
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
||||
|
||||
// 6. Sign tokens
|
||||
let access_token = TokenService::sign_access_token(
|
||||
user_model.id,
|
||||
tenant_id,
|
||||
roles.clone(),
|
||||
permissions,
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||
user_model.id,
|
||||
tenant_id,
|
||||
db,
|
||||
jwt.secret,
|
||||
jwt.refresh_ttl_secs,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 7. Update last_login_at
|
||||
let mut user_active: user::ActiveModel = user_model.clone().into();
|
||||
user_active.last_login_at = Set(Some(Utc::now()));
|
||||
user_active.updated_at = Set(Utc::now());
|
||||
user_active.version = Set(user_active.version.take().unwrap_or(0) + 1);
|
||||
user_active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 8. Build response
|
||||
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
|
||||
let user_resp = UserResp {
|
||||
id: user_model.id,
|
||||
username: user_model.username.clone(),
|
||||
email: user_model.email,
|
||||
phone: user_model.phone,
|
||||
display_name: user_model.display_name,
|
||||
avatar_url: user_model.avatar_url,
|
||||
status: user_model.status,
|
||||
roles: role_resps,
|
||||
version: user_model.version,
|
||||
};
|
||||
|
||||
// 9. Publish event
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"user.login",
|
||||
tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({ "user_id": user_model.id, "username": user_model.username })),
|
||||
), db).await;
|
||||
|
||||
// 审计:登录成功
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_model.id), "user.login", "user")
|
||||
.with_resource_id(user_model.id)
|
||||
.with_request_info(
|
||||
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(LoginResp {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in: jwt.access_ttl_secs as u64,
|
||||
user: user_resp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
|
||||
pub async fn refresh(
|
||||
refresh_token_str: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
jwt: &JwtConfig<'_>,
|
||||
) -> AuthResult<LoginResp> {
|
||||
// Atomically validate and revoke the old refresh token (prevents TOCTOU race)
|
||||
let claims =
|
||||
TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?;
|
||||
|
||||
// Fetch fresh roles and permissions
|
||||
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
||||
|
||||
// Sign new token pair
|
||||
let access_token = TokenService::sign_access_token(
|
||||
claims.sub,
|
||||
claims.tid,
|
||||
roles.clone(),
|
||||
permissions,
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
||||
claims.sub,
|
||||
claims.tid,
|
||||
db,
|
||||
jwt.secret,
|
||||
jwt.refresh_ttl_secs,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fetch user for the response
|
||||
let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
// 验证用户属于 JWT 中声明的租户
|
||||
if user_model.tenant_id != claims.tid {
|
||||
tracing::warn!(
|
||||
user_id = %claims.sub,
|
||||
jwt_tenant = %claims.tid,
|
||||
actual_tenant = %user_model.tenant_id,
|
||||
"Token tenant_id 与用户实际租户不匹配"
|
||||
);
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
|
||||
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
|
||||
let user_resp = UserResp {
|
||||
id: user_model.id,
|
||||
username: user_model.username,
|
||||
email: user_model.email,
|
||||
phone: user_model.phone,
|
||||
display_name: user_model.display_name,
|
||||
avatar_url: user_model.avatar_url,
|
||||
status: user_model.status,
|
||||
roles: role_resps,
|
||||
version: user_model.version,
|
||||
};
|
||||
|
||||
Ok(LoginResp {
|
||||
access_token,
|
||||
refresh_token: new_refresh_token,
|
||||
expires_in: jwt.access_ttl_secs as u64,
|
||||
user: user_resp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
|
||||
pub async fn logout(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
req_info: Option<&RequestInfo>,
|
||||
) -> AuthResult<()> {
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 清除 access token 权限缓存,强制重新认证
|
||||
revoke_access_token_cache(user_id);
|
||||
|
||||
// 审计:登出
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "user.logout", "user")
|
||||
.with_resource_id(user_id)
|
||||
.with_request_info(
|
||||
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change password for the authenticated user.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Verify current password
|
||||
/// 2. Hash the new password
|
||||
/// 3. Update the credential record
|
||||
/// 4. Revoke all existing refresh tokens (force re-login)
|
||||
pub async fn change_password(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
req_info: Option<&RequestInfo>,
|
||||
) -> AuthResult<()> {
|
||||
// 1. Find the user's password credential
|
||||
let cred = user_credential::Entity::find()
|
||||
.filter(user_credential::Column::UserId.eq(user_id))
|
||||
.filter(user_credential::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||
.filter(user_credential::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
|
||||
|
||||
// 2. Verify current password
|
||||
let stored_hash = cred
|
||||
.credential_data
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||
.ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?;
|
||||
|
||||
if !password::verify_password(current_password, stored_hash)? {
|
||||
return Err(AuthError::Validation("当前密码不正确".to_string()));
|
||||
}
|
||||
|
||||
// 3. Hash new password and update credential
|
||||
let new_hash = password::hash_password(new_password)?;
|
||||
let current_version = cred.version;
|
||||
let mut cred_active: user_credential::ActiveModel = cred.into();
|
||||
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
|
||||
cred_active.updated_at = Set(Utc::now());
|
||||
cred_active.version = Set(current_version + 1);
|
||||
cred_active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 4. Revoke all refresh tokens — force re-login on all devices
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效
|
||||
revoke_access_token_cache(user_id);
|
||||
|
||||
// 审计:密码修改
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user")
|
||||
.with_resource_id(user_id)
|
||||
.with_request_info(
|
||||
req_info.as_ref().and_then(|r| r.ip.clone()),
|
||||
req_info.as_ref().and_then(|r| r.user_agent.clone()),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(user_id = %user_id, "Password changed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch role details for a user, returning RoleResp DTOs.
|
||||
pub async fn get_user_role_resps(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<RoleResp>> {
|
||||
let user_roles = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.eq(user_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||
if role_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let roles = role::Entity::find()
|
||||
.filter(role::Column::Id.is_in(role_ids))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(roles
|
||||
.iter()
|
||||
.map(|r| RoleResp {
|
||||
id: r.id,
|
||||
name: r.name.clone(),
|
||||
code: r.code.clone(),
|
||||
description: r.description.clone(),
|
||||
is_system: r.is_system,
|
||||
version: r.version,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
414
crates/erp-auth/src/service/dept_service.rs
Normal file
414
crates/erp-auth/src/service/dept_service.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
|
||||
use crate::entity::department;
|
||||
use crate::entity::organization;
|
||||
use crate::entity::position;
|
||||
use crate::entity::user_department;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Department CRUD service -- create, read, update, soft-delete departments
|
||||
/// within an organization, supporting tree-structured hierarchy.
|
||||
pub struct DeptService;
|
||||
|
||||
impl DeptService {
|
||||
/// Fetch all departments for an organization as a nested tree.
|
||||
///
|
||||
/// Root departments (parent_id = None) form the top level.
|
||||
pub async fn list_tree(
|
||||
org_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<DepartmentResp>> {
|
||||
// Verify the organization exists
|
||||
let _org = organization::Entity::find_by_id(org_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
let items = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(org_id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(build_dept_tree(&items))
|
||||
}
|
||||
|
||||
/// Create a new department under the specified organization.
|
||||
///
|
||||
/// If `parent_id` is provided, computes `path` from the parent department.
|
||||
/// Otherwise, path is computed from the organization root.
|
||||
pub async fn create(
|
||||
org_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateDepartmentReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<DepartmentResp> {
|
||||
// Verify the organization exists
|
||||
let org = organization::Entity::find_by_id(org_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::Code.eq(code.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Check name uniqueness within the same organization
|
||||
let name_exists = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(org_id))
|
||||
.filter(department::Column::Name.eq(&req.name))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if name_exists.is_some() {
|
||||
return Err(AuthError::Validation("部门名称已存在".to_string()));
|
||||
}
|
||||
|
||||
// Compute path from parent department or organization root
|
||||
let path = if let Some(parent_id) = req.parent_id {
|
||||
let parent = department::Entity::find_by_id(parent_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| {
|
||||
d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none()
|
||||
})
|
||||
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
Some(format!("{}{}/", parent_path, parent.id))
|
||||
} else {
|
||||
// Root department under the organization
|
||||
let org_path = org.path.clone().unwrap_or_default();
|
||||
Some(format!("{}{}/", org_path, org.id))
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = department::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
org_id: Set(org_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
parent_id: Set(req.parent_id),
|
||||
manager_id: Set(req.manager_id),
|
||||
path: Set(path),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"department.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.create",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(DepartmentResp {
|
||||
id,
|
||||
org_id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
parent_id: req.parent_id,
|
||||
manager_id: req.manager_id,
|
||||
path: None,
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
children: vec![],
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable department fields (name, code, manager_id, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateDepartmentReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<DepartmentResp> {
|
||||
let model = department::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(new_code) = &req.code
|
||||
&& Some(new_code) != model.code.as_ref()
|
||||
{
|
||||
let existing = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::Code.eq(new_code.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("部门编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// If name is being changed, check uniqueness within the same org (exclude self)
|
||||
if let Some(ref new_name) = req.name
|
||||
&& new_name != &model.name
|
||||
{
|
||||
let name_exists = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(model.org_id))
|
||||
.filter(department::Column::Name.eq(new_name.as_str()))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.filter(department::Column::Id.ne(id))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if name_exists.is_some() {
|
||||
return Err(AuthError::Validation("部门名称已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let next_ver = check_version(req.version, model.version)
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
|
||||
if let Some(n) = &req.name {
|
||||
active.name = Set(n.clone());
|
||||
}
|
||||
if let Some(c) = &req.code {
|
||||
active.code = Set(Some(c.clone()));
|
||||
}
|
||||
if let Some(mgr_id) = &req.manager_id {
|
||||
active.manager_id = Set(Some(*mgr_id));
|
||||
}
|
||||
if let Some(so) = &req.sort_order {
|
||||
active.sort_order = Set(*so);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.update",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(DepartmentResp {
|
||||
id: updated.id,
|
||||
org_id: updated.org_id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
parent_id: updated.parent_id,
|
||||
manager_id: updated.manager_id,
|
||||
path: updated.path.clone(),
|
||||
sort_order: updated.sort_order,
|
||||
children: vec![],
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a department by setting the `deleted_at` timestamp.
|
||||
///
|
||||
/// Will not delete if child departments exist.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = department::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// Check for child departments
|
||||
let children = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::ParentId.eq(id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if children.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在子部门,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for positions under this department
|
||||
let positions = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::DeptId.eq(id))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if positions.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在岗位,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for users assigned to this department
|
||||
let users = user_department::Entity::find()
|
||||
.filter(user_department::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_department::Column::DepartmentId.eq(id))
|
||||
.filter(user_department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if users.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在用户,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"department.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "dept_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"department.delete",
|
||||
"department",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a nested tree of `DepartmentResp` from a flat list of models.
|
||||
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
|
||||
for item in items {
|
||||
children_map.entry(item.parent_id).or_default().push(item);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
item: &department::Model,
|
||||
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
|
||||
) -> DepartmentResp {
|
||||
let children = map
|
||||
.get(&Some(item.id))
|
||||
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||
.unwrap_or_default();
|
||||
DepartmentResp {
|
||||
id: item.id,
|
||||
org_id: item.org_id,
|
||||
name: item.name.clone(),
|
||||
code: item.code.clone(),
|
||||
parent_id: item.parent_id,
|
||||
manager_id: item.manager_id,
|
||||
path: item.path.clone(),
|
||||
sort_order: item.sort_order,
|
||||
children,
|
||||
version: item.version,
|
||||
}
|
||||
}
|
||||
|
||||
children_map
|
||||
.get(&None)
|
||||
.map(|root_items| {
|
||||
root_items
|
||||
.iter()
|
||||
.map(|item| build_node(item, &children_map))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
11
crates/erp-auth/src/service/mod.rs
Normal file
11
crates/erp-auth/src/service/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod auth_service;
|
||||
pub mod dept_service;
|
||||
pub mod org_service;
|
||||
pub mod password;
|
||||
pub mod permission_service;
|
||||
pub mod position_service;
|
||||
pub mod role_service;
|
||||
pub mod seed;
|
||||
pub mod token_service;
|
||||
pub mod user_service;
|
||||
pub mod wechat_service;
|
||||
494
crates/erp-auth/src/service/org_service.rs
Normal file
494
crates/erp-auth/src/service/org_service.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
||||
use crate::entity::department;
|
||||
use crate::entity::organization;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
||||
/// within a tenant, supporting tree-structured hierarchy with path and level.
|
||||
pub struct OrgService;
|
||||
|
||||
impl OrgService {
|
||||
/// Fetch all organizations for a tenant as a flat list (not deleted).
|
||||
pub async fn list_flat(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<organization::Model>> {
|
||||
let items = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Fetch all organizations for a tenant as a nested tree.
|
||||
///
|
||||
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
|
||||
pub async fn get_tree(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<OrganizationResp>> {
|
||||
let items = Self::list_flat(tenant_id, db).await?;
|
||||
Ok(build_org_tree(&items))
|
||||
}
|
||||
|
||||
/// Create a new organization within the current tenant.
|
||||
///
|
||||
/// If `parent_id` is provided, computes `path` from the parent's path and id,
|
||||
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateOrganizationReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<OrganizationResp> {
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Code.eq(code.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Check name uniqueness within tenant
|
||||
let name_exists = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Name.eq(&req.name))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if name_exists.is_some() {
|
||||
return Err(AuthError::Validation("组织名称已存在".to_string()));
|
||||
}
|
||||
|
||||
let (path, level) = if let Some(parent_id) = req.parent_id {
|
||||
let parent = organization::Entity::find_by_id(parent_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
|
||||
|
||||
let parent_path = parent.path.clone().unwrap_or_default();
|
||||
let computed_path = format!("{}{}/", parent_path, parent.id);
|
||||
(Some(computed_path), parent.level + 1)
|
||||
} else {
|
||||
(None, 1)
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = organization::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
parent_id: Set(req.parent_id),
|
||||
path: Set(path),
|
||||
level: Set(level),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"organization.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.create",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(OrganizationResp {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
parent_id: req.parent_id,
|
||||
path: None,
|
||||
level,
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
children: vec![],
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable organization fields (name, code, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateOrganizationReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<OrganizationResp> {
|
||||
let model = organization::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(ref new_code) = req.code
|
||||
&& Some(new_code) != model.code.as_ref()
|
||||
{
|
||||
let existing = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Code.eq(new_code.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// If name is being changed, check uniqueness (exclude self)
|
||||
if let Some(ref new_name) = req.name
|
||||
&& new_name != &model.name
|
||||
{
|
||||
let name_exists = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::Name.eq(new_name.as_str()))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.filter(organization::Column::Id.ne(id))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if name_exists.is_some() {
|
||||
return Err(AuthError::Validation("组织名称已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let next_ver = check_version(req.version, model.version)
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
|
||||
if let Some(ref name) = req.name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(ref code) = req.code {
|
||||
active.code = Set(Some(code.clone()));
|
||||
}
|
||||
if let Some(sort_order) = req.sort_order {
|
||||
active.sort_order = Set(sort_order);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.update",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(OrganizationResp {
|
||||
id: updated.id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
parent_id: updated.parent_id,
|
||||
path: updated.path.clone(),
|
||||
level: updated.level,
|
||||
sort_order: updated.sort_order,
|
||||
children: vec![],
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete an organization by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = organization::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
||||
|
||||
// Check for child organizations
|
||||
let children = organization::Entity::find()
|
||||
.filter(organization::Column::TenantId.eq(tenant_id))
|
||||
.filter(organization::Column::ParentId.eq(id))
|
||||
.filter(organization::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if children.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该组织下存在子组织,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for departments under this organization
|
||||
let depts = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if depts.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该组织下存在部门,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"organization.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "org_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"organization.delete",
|
||||
"organization",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a nested tree of `OrganizationResp` from a flat list of models.
|
||||
///
|
||||
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
||||
/// includes its children grouped by parent_id.
|
||||
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
||||
for item in items {
|
||||
children_map.entry(item.parent_id).or_default().push(item);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
item: &organization::Model,
|
||||
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
|
||||
) -> OrganizationResp {
|
||||
let children = map
|
||||
.get(&Some(item.id))
|
||||
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
||||
.unwrap_or_default();
|
||||
OrganizationResp {
|
||||
id: item.id,
|
||||
name: item.name.clone(),
|
||||
code: item.code.clone(),
|
||||
parent_id: item.parent_id,
|
||||
path: item.path.clone(),
|
||||
level: item.level,
|
||||
sort_order: item.sort_order,
|
||||
children,
|
||||
version: item.version,
|
||||
}
|
||||
}
|
||||
|
||||
children_map
|
||||
.get(&None)
|
||||
.map(|root_items| {
|
||||
root_items
|
||||
.iter()
|
||||
.map(|item| build_node(item, &children_map))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::organization;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn make_org(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
parent_id: Option<Uuid>,
|
||||
level: i32,
|
||||
version: i32,
|
||||
) -> organization::Model {
|
||||
organization::Model {
|
||||
id,
|
||||
tenant_id,
|
||||
name: name.to_string(),
|
||||
code: None,
|
||||
parent_id,
|
||||
path: None,
|
||||
level,
|
||||
sort_order: 0,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
created_by: Uuid::now_v7(),
|
||||
updated_by: Uuid::now_v7(),
|
||||
deleted_at: None,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_empty() {
|
||||
let tree = build_org_tree(&[]);
|
||||
assert!(tree.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_single_root() {
|
||||
let tid = Uuid::now_v7();
|
||||
let root_id = Uuid::now_v7();
|
||||
let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].name, "总公司");
|
||||
assert!(tree[0].children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_multiple_roots() {
|
||||
let tid = Uuid::now_v7();
|
||||
let items = vec![
|
||||
make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1),
|
||||
make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1),
|
||||
];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_nested_children() {
|
||||
let tid = Uuid::now_v7();
|
||||
let root_id = Uuid::now_v7();
|
||||
let child1_id = Uuid::now_v7();
|
||||
let child2_id = Uuid::now_v7();
|
||||
let grandchild_id = Uuid::now_v7();
|
||||
|
||||
let items = vec![
|
||||
make_org(root_id, tid, "总公司", None, 1, 1),
|
||||
make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1),
|
||||
make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1),
|
||||
make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1),
|
||||
];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 1); // one root
|
||||
assert_eq!(tree[0].children.len(), 2); // two children
|
||||
assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild
|
||||
assert_eq!(tree[0].children[0].children[0].name, "部门A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_deep_nesting() {
|
||||
let tid = Uuid::now_v7();
|
||||
let l1 = Uuid::now_v7();
|
||||
let l2 = Uuid::now_v7();
|
||||
let l3 = Uuid::now_v7();
|
||||
let l4 = Uuid::now_v7();
|
||||
|
||||
let items = vec![
|
||||
make_org(l1, tid, "L1", None, 1, 1),
|
||||
make_org(l2, tid, "L2", Some(l1), 2, 1),
|
||||
make_org(l3, tid, "L3", Some(l2), 3, 1),
|
||||
make_org(l4, tid, "L4", Some(l3), 4, 1),
|
||||
];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].children[0].children[0].children[0].name, "L4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_org_tree_preserves_version() {
|
||||
let tid = Uuid::now_v7();
|
||||
let root_id = Uuid::now_v7();
|
||||
let items = vec![make_org(root_id, tid, "测试", None, 1, 5)];
|
||||
|
||||
let tree = build_org_tree(&items);
|
||||
assert_eq!(tree[0].version, 5);
|
||||
}
|
||||
}
|
||||
56
crates/erp-auth/src/service/password.rs
Normal file
56
crates/erp-auth/src/service/password.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
|
||||
/// Hash a plaintext password using Argon2 with a random salt.
|
||||
///
|
||||
/// Returns a PHC-format string suitable for database storage.
|
||||
pub fn hash_password(plain: &str) -> AuthResult<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(plain.as_bytes(), &salt)
|
||||
.map_err(|e| AuthError::HashError(e.to_string()))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored PHC-format hash.
|
||||
///
|
||||
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
|
||||
pub fn verify_password(plain: &str, hash: &str) -> AuthResult<bool> {
|
||||
let parsed = PasswordHash::new(hash).map_err(|e| AuthError::HashError(e.to_string()))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify() {
|
||||
let hash = hash_password("test123").unwrap();
|
||||
assert!(
|
||||
verify_password("test123", &hash).unwrap(),
|
||||
"Correct password should verify"
|
||||
);
|
||||
assert!(
|
||||
!verify_password("wrong", &hash).unwrap(),
|
||||
"Wrong password should not verify"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_is_unique() {
|
||||
let hash1 = hash_password("same_password").unwrap();
|
||||
let hash2 = hash_password("same_password").unwrap();
|
||||
assert_ne!(
|
||||
hash1, hash2,
|
||||
"Two hashes of the same password should differ (different salts)"
|
||||
);
|
||||
}
|
||||
}
|
||||
38
crates/erp-auth/src/service/permission_service.rs
Normal file
38
crates/erp-auth/src/service/permission_service.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::PermissionResp;
|
||||
use crate::entity::permission;
|
||||
use crate::error::AuthResult;
|
||||
|
||||
/// Permission read-only service — list permissions within a tenant.
|
||||
///
|
||||
/// Permissions are seeded by the system and not typically created via API.
|
||||
pub struct PermissionService;
|
||||
|
||||
impl PermissionService {
|
||||
/// List all active permissions within a tenant.
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<PermissionResp>> {
|
||||
let perms = permission::Entity::find()
|
||||
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| crate::error::AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(perms
|
||||
.iter()
|
||||
.map(|p| PermissionResp {
|
||||
id: p.id,
|
||||
code: p.code.clone(),
|
||||
name: p.name.clone(),
|
||||
resource: p.resource.clone(),
|
||||
action: p.action.clone(),
|
||||
description: p.description.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
259
crates/erp-auth/src/service/position_service.rs
Normal file
259
crates/erp-auth/src/service/position_service.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
|
||||
use crate::entity::department;
|
||||
use crate::entity::position;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// Position CRUD service -- create, read, update, soft-delete positions
|
||||
/// within a department.
|
||||
pub struct PositionService;
|
||||
|
||||
impl PositionService {
|
||||
/// List all positions for a department within the given tenant.
|
||||
pub async fn list(
|
||||
dept_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<PositionResp>> {
|
||||
// Verify the department exists
|
||||
let _dept = department::Entity::find_by_id(dept_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
let items = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::DeptId.eq(dept_id))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(items
|
||||
.iter()
|
||||
.map(|p| PositionResp {
|
||||
id: p.id,
|
||||
dept_id: p.dept_id,
|
||||
name: p.name.clone(),
|
||||
code: p.code.clone(),
|
||||
level: p.level,
|
||||
sort_order: p.sort_order,
|
||||
version: p.version,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Create a new position under the specified department.
|
||||
pub async fn create(
|
||||
dept_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreatePositionReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<PositionResp> {
|
||||
// Verify the department exists
|
||||
let _dept = department::Entity::find_by_id(dept_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
|
||||
|
||||
// Check code uniqueness within tenant if code is provided
|
||||
if let Some(ref code) = req.code {
|
||||
let existing = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::Code.eq(code.as_str()))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = position::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
dept_id: Set(dept_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
level: Set(req.level.unwrap_or(1)),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"position.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "position.create", "position")
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(PositionResp {
|
||||
id,
|
||||
dept_id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
level: req.level.unwrap_or(1),
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable position fields (name, code, level, sort_order).
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdatePositionReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<PositionResp> {
|
||||
let model = position::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||
|
||||
// If code is being changed, check uniqueness
|
||||
if let Some(new_code) = &req.code
|
||||
&& Some(new_code) != model.code.as_ref()
|
||||
{
|
||||
let existing = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::Code.eq(new_code.as_str()))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("岗位编码已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let next_ver = check_version(req.version, model.version)
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let mut active: position::ActiveModel = model.into();
|
||||
|
||||
if let Some(n) = &req.name {
|
||||
active.name = Set(n.clone());
|
||||
}
|
||||
if let Some(c) = &req.code {
|
||||
active.code = Set(Some(c.clone()));
|
||||
}
|
||||
if let Some(l) = &req.level {
|
||||
active.level = Set(*l);
|
||||
}
|
||||
if let Some(so) = &req.sort_order {
|
||||
active.sort_order = Set(*so);
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "position.update", "position")
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(PositionResp {
|
||||
id: updated.id,
|
||||
dept_id: updated.dept_id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
level: updated.level,
|
||||
sort_order: updated.sort_order,
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a position by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = position::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: position::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"position.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "position_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position")
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
370
crates/erp-auth/src/service/role_service.rs
Normal file
370
crates/erp-auth/src/service/role_service.rs
Normal file
@@ -0,0 +1,370 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{PermissionResp, RoleResp};
|
||||
use crate::entity::{permission, role, role_permission};
|
||||
use crate::error::AuthError;
|
||||
use crate::error::AuthResult;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// Role CRUD service — create, read, update, soft-delete roles within a tenant,
|
||||
/// and manage role-permission assignments.
|
||||
pub struct RoleService;
|
||||
|
||||
impl RoleService {
|
||||
/// List roles within a tenant with pagination.
|
||||
///
|
||||
/// Returns `(roles, total_count)`.
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<(Vec<RoleResp>, u64)> {
|
||||
let paginator = role::Entity::find()
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let resps: Vec<RoleResp> = models
|
||||
.iter()
|
||||
.map(|m| RoleResp {
|
||||
id: m.id,
|
||||
name: m.name.clone(),
|
||||
code: m.code.clone(),
|
||||
description: m.description.clone(),
|
||||
is_system: m.is_system,
|
||||
version: m.version,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// Fetch a single role by ID, scoped to the given tenant.
|
||||
pub async fn get_by_id(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<RoleResp> {
|
||||
let model = role::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
Ok(RoleResp {
|
||||
id: model.id,
|
||||
name: model.name.clone(),
|
||||
code: model.code.clone(),
|
||||
description: model.description.clone(),
|
||||
is_system: model.is_system,
|
||||
version: model.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new role within the current tenant.
|
||||
///
|
||||
/// Validates code uniqueness, then inserts the record and publishes
|
||||
/// a `role.created` domain event.
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &str,
|
||||
code: &str,
|
||||
description: &Option<String>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<RoleResp> {
|
||||
// Check code uniqueness within tenant
|
||||
let existing = role::Entity::find()
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.filter(role::Column::Code.eq(code))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("角色编码已存在".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = role::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(name.to_string()),
|
||||
code: Set(code.to_string()),
|
||||
description: Set(description.clone()),
|
||||
is_system: Set(false),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"role.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id, "code": code }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.create", "role").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(RoleResp {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
code: code.to_string(),
|
||||
description: description.clone(),
|
||||
is_system: false,
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable role fields (name and description).
|
||||
///
|
||||
/// Code and is_system cannot be changed after creation.
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &Option<String>,
|
||||
description: &Option<String>,
|
||||
version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<RoleResp> {
|
||||
let model = role::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
let old_json = serde_json::to_value(&model).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let next_ver = check_version(version, model.version)
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let mut active: role::ActiveModel = model.into();
|
||||
|
||||
if let Some(name) = name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(desc) = description {
|
||||
active.description = Set(Some(desc.clone()));
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.update", "role")
|
||||
.with_resource_id(id)
|
||||
.with_changes(Some(old_json), Some(new_json)),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(RoleResp {
|
||||
id: updated.id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
description: updated.description.clone(),
|
||||
is_system: updated.is_system,
|
||||
version: updated.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a role by setting the `deleted_at` timestamp.
|
||||
///
|
||||
/// System roles cannot be deleted.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = role::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
if model.is_system {
|
||||
return Err(AuthError::Validation("系统角色不可删除".to_string()));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: role::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"role.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace all permission assignments for a role.
|
||||
///
|
||||
/// Soft-deletes existing assignments and creates new ones.
|
||||
pub async fn assign_permissions(
|
||||
role_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
permission_ids: &[Uuid],
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
// Verify the role exists and belongs to this tenant
|
||||
let _role = role::Entity::find_by_id(role_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
// Soft-delete existing role_permission rows
|
||||
let existing = role_permission::Entity::find()
|
||||
.filter(role_permission::Column::RoleId.eq(role_id))
|
||||
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(role_permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
for rp in existing {
|
||||
let mut active: role_permission::ActiveModel = rp.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Insert new role_permission rows
|
||||
for perm_id in permission_ids {
|
||||
let rp = role_permission::ActiveModel {
|
||||
role_id: Set(role_id),
|
||||
permission_id: Set(*perm_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
data_scope: Set("all".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
rp.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch all permissions assigned to a role.
|
||||
///
|
||||
/// Resolves through the role_permission join table.
|
||||
pub async fn get_role_permissions(
|
||||
role_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<PermissionResp>> {
|
||||
let rp_rows = role_permission::Entity::find()
|
||||
.filter(role_permission::Column::RoleId.eq(role_id))
|
||||
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(role_permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
|
||||
if perm_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let perms = permission::Entity::find()
|
||||
.filter(permission::Column::Id.is_in(perm_ids))
|
||||
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(perms
|
||||
.iter()
|
||||
.map(|p| PermissionResp {
|
||||
id: p.id,
|
||||
code: p.code.clone(),
|
||||
name: p.name.clone(),
|
||||
resource: p.resource.clone(),
|
||||
action: p.action.clone(),
|
||||
description: p.description.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
547
crates/erp-auth/src/service/seed.rs
Normal file
547
crates/erp-auth/src/service/seed.rs
Normal file
@@ -0,0 +1,547 @@
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::{permission, role, role_permission, user, user_credential, user_role};
|
||||
use crate::error::AuthError;
|
||||
|
||||
use super::password;
|
||||
|
||||
/// Permission definitions to seed for every new tenant.
|
||||
/// Each tuple is: (code, name, resource, action, description)
|
||||
///
|
||||
/// 编码使用点分隔 (`resource.action`),与 handler 中的 `require_permission` 调用保持一致。
|
||||
const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
// === Auth module ===
|
||||
("user.list", "查看用户列表", "user", "list", "查看用户列表"),
|
||||
("user.create", "创建用户", "user", "create", "创建新用户"),
|
||||
("user.read", "查看用户详情", "user", "read", "查看用户信息"),
|
||||
("user.update", "编辑用户", "user", "update", "编辑用户信息"),
|
||||
("user.delete", "删除用户", "user", "delete", "软删除用户"),
|
||||
("role.list", "查看角色列表", "role", "list", "查看角色列表"),
|
||||
("role.create", "创建角色", "role", "create", "创建新角色"),
|
||||
("role.read", "查看角色详情", "role", "read", "查看角色信息"),
|
||||
("role.update", "编辑角色", "role", "update", "编辑角色"),
|
||||
("role.delete", "删除角色", "role", "delete", "删除角色"),
|
||||
(
|
||||
"permission.list",
|
||||
"查看权限",
|
||||
"permission",
|
||||
"list",
|
||||
"查看权限列表",
|
||||
),
|
||||
(
|
||||
"organization.list",
|
||||
"查看组织列表",
|
||||
"organization",
|
||||
"list",
|
||||
"查看组织列表",
|
||||
),
|
||||
(
|
||||
"organization.create",
|
||||
"创建组织",
|
||||
"organization",
|
||||
"create",
|
||||
"创建组织",
|
||||
),
|
||||
(
|
||||
"organization.update",
|
||||
"编辑组织",
|
||||
"organization",
|
||||
"update",
|
||||
"编辑组织",
|
||||
),
|
||||
(
|
||||
"organization.delete",
|
||||
"删除组织",
|
||||
"organization",
|
||||
"delete",
|
||||
"删除组织",
|
||||
),
|
||||
(
|
||||
"department.list",
|
||||
"查看部门列表",
|
||||
"department",
|
||||
"list",
|
||||
"查看部门列表",
|
||||
),
|
||||
(
|
||||
"department.create",
|
||||
"创建部门",
|
||||
"department",
|
||||
"create",
|
||||
"创建部门",
|
||||
),
|
||||
(
|
||||
"department.update",
|
||||
"编辑部门",
|
||||
"department",
|
||||
"update",
|
||||
"编辑部门",
|
||||
),
|
||||
(
|
||||
"department.delete",
|
||||
"删除部门",
|
||||
"department",
|
||||
"delete",
|
||||
"删除部门",
|
||||
),
|
||||
(
|
||||
"position.list",
|
||||
"查看岗位列表",
|
||||
"position",
|
||||
"list",
|
||||
"查看岗位列表",
|
||||
),
|
||||
(
|
||||
"position.create",
|
||||
"创建岗位",
|
||||
"position",
|
||||
"create",
|
||||
"创建岗位",
|
||||
),
|
||||
(
|
||||
"position.update",
|
||||
"编辑岗位",
|
||||
"position",
|
||||
"update",
|
||||
"编辑岗位",
|
||||
),
|
||||
(
|
||||
"position.delete",
|
||||
"删除岗位",
|
||||
"position",
|
||||
"delete",
|
||||
"删除岗位",
|
||||
),
|
||||
// === Config module ===
|
||||
(
|
||||
"dictionary.list",
|
||||
"查看字典",
|
||||
"dictionary",
|
||||
"list",
|
||||
"查看数据字典",
|
||||
),
|
||||
(
|
||||
"dictionary.create",
|
||||
"创建字典",
|
||||
"dictionary",
|
||||
"create",
|
||||
"创建数据字典",
|
||||
),
|
||||
(
|
||||
"dictionary.update",
|
||||
"编辑字典",
|
||||
"dictionary",
|
||||
"update",
|
||||
"编辑数据字典",
|
||||
),
|
||||
(
|
||||
"dictionary.delete",
|
||||
"删除字典",
|
||||
"dictionary",
|
||||
"delete",
|
||||
"删除数据字典",
|
||||
),
|
||||
("menu.list", "查看菜单", "menu", "list", "查看菜单配置"),
|
||||
("menu.update", "编辑菜单", "menu", "update", "编辑菜单配置"),
|
||||
(
|
||||
"setting.read",
|
||||
"查看配置",
|
||||
"setting",
|
||||
"read",
|
||||
"查看系统参数",
|
||||
),
|
||||
(
|
||||
"setting.update",
|
||||
"编辑配置",
|
||||
"setting",
|
||||
"update",
|
||||
"编辑系统参数",
|
||||
),
|
||||
(
|
||||
"setting.delete",
|
||||
"删除配置",
|
||||
"setting",
|
||||
"delete",
|
||||
"删除系统参数",
|
||||
),
|
||||
(
|
||||
"numbering.list",
|
||||
"查看编号规则",
|
||||
"numbering",
|
||||
"list",
|
||||
"查看编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.create",
|
||||
"创建编号规则",
|
||||
"numbering",
|
||||
"create",
|
||||
"创建编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.update",
|
||||
"编辑编号规则",
|
||||
"numbering",
|
||||
"update",
|
||||
"编辑编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.delete",
|
||||
"删除编号规则",
|
||||
"numbering",
|
||||
"delete",
|
||||
"删除编号规则",
|
||||
),
|
||||
(
|
||||
"numbering.generate",
|
||||
"生成编号",
|
||||
"numbering",
|
||||
"generate",
|
||||
"生成文档编号",
|
||||
),
|
||||
("theme.read", "查看主题", "theme", "read", "查看主题设置"),
|
||||
(
|
||||
"theme.update",
|
||||
"编辑主题",
|
||||
"theme",
|
||||
"update",
|
||||
"编辑主题设置",
|
||||
),
|
||||
(
|
||||
"language.list",
|
||||
"查看语言",
|
||||
"language",
|
||||
"list",
|
||||
"查看语言配置",
|
||||
),
|
||||
(
|
||||
"language.update",
|
||||
"编辑语言",
|
||||
"language",
|
||||
"update",
|
||||
"编辑语言设置",
|
||||
),
|
||||
// === Workflow module ===
|
||||
(
|
||||
"workflow.create",
|
||||
"创建流程",
|
||||
"workflow",
|
||||
"create",
|
||||
"创建流程定义",
|
||||
),
|
||||
(
|
||||
"workflow.list",
|
||||
"查看流程",
|
||||
"workflow",
|
||||
"list",
|
||||
"查看流程列表",
|
||||
),
|
||||
(
|
||||
"workflow.read",
|
||||
"查看流程详情",
|
||||
"workflow",
|
||||
"read",
|
||||
"查看流程定义详情",
|
||||
),
|
||||
(
|
||||
"workflow.update",
|
||||
"编辑流程",
|
||||
"workflow",
|
||||
"update",
|
||||
"编辑流程定义",
|
||||
),
|
||||
(
|
||||
"workflow.publish",
|
||||
"发布流程",
|
||||
"workflow",
|
||||
"publish",
|
||||
"发布流程定义",
|
||||
),
|
||||
(
|
||||
"workflow.start",
|
||||
"发起流程",
|
||||
"workflow",
|
||||
"start",
|
||||
"发起流程实例",
|
||||
),
|
||||
(
|
||||
"workflow.approve",
|
||||
"审批任务",
|
||||
"workflow",
|
||||
"approve",
|
||||
"审批流程任务",
|
||||
),
|
||||
(
|
||||
"workflow.delegate",
|
||||
"委派任务",
|
||||
"workflow",
|
||||
"delegate",
|
||||
"委派流程任务",
|
||||
),
|
||||
// === Message module ===
|
||||
(
|
||||
"message.list",
|
||||
"查看消息",
|
||||
"message",
|
||||
"list",
|
||||
"查看消息列表",
|
||||
),
|
||||
("message.send", "发送消息", "message", "send", "发送新消息"),
|
||||
(
|
||||
"message.template.list",
|
||||
"查看消息模板",
|
||||
"message.template",
|
||||
"list",
|
||||
"查看消息模板列表",
|
||||
),
|
||||
(
|
||||
"message.template.create",
|
||||
"创建消息模板",
|
||||
"message.template",
|
||||
"create",
|
||||
"创建消息模板",
|
||||
),
|
||||
(
|
||||
"message.template.manage",
|
||||
"管理消息模板",
|
||||
"message.template",
|
||||
"manage",
|
||||
"编辑、删除消息模板",
|
||||
),
|
||||
// === Plugin module ===
|
||||
(
|
||||
"plugin.admin",
|
||||
"插件管理",
|
||||
"plugin",
|
||||
"admin",
|
||||
"管理插件全生命周期",
|
||||
),
|
||||
("plugin.list", "查看插件", "plugin", "list", "查看插件列表"),
|
||||
// === Server level ===
|
||||
(
|
||||
"tenant.manage",
|
||||
"租户管理",
|
||||
"tenant",
|
||||
"manage",
|
||||
"管理租户级设置(密钥轮换等)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||
const READ_PERM_INDICES: &[usize] = &[
|
||||
0, // user.list
|
||||
2, // user.read
|
||||
5, // role.list
|
||||
7, // role.read
|
||||
10, // permission.list
|
||||
11, // organization.list
|
||||
15, // department.list
|
||||
19, // position.list
|
||||
23, // dictionary.list
|
||||
28, // menu.list
|
||||
30, // setting.read
|
||||
32, // numbering.list
|
||||
37, // theme.read
|
||||
39, // language.list
|
||||
43, // workflow.list
|
||||
44, // workflow.read
|
||||
49, // message.list
|
||||
51, // message.template.list
|
||||
54, // plugin.list
|
||||
];
|
||||
|
||||
/// Seed default auth data for a new tenant.
|
||||
///
|
||||
/// Creates:
|
||||
/// - 56 permissions covering auth/config/workflow/message/plugin modules
|
||||
/// - An "admin" system role with all permissions
|
||||
/// - A "viewer" system role with read-only permissions
|
||||
/// - A super-admin user with the admin role and a password credential
|
||||
pub async fn seed_tenant_auth(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
super_admin_password: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
let now = chrono::Utc::now();
|
||||
let system_user_id = Uuid::nil();
|
||||
|
||||
// 1. Create permissions
|
||||
let mut perm_ids: Vec<Uuid> = Vec::with_capacity(DEFAULT_PERMISSIONS.len());
|
||||
for (code, name, resource, action, desc) in DEFAULT_PERMISSIONS {
|
||||
let perm_id = Uuid::now_v7();
|
||||
perm_ids.push(perm_id);
|
||||
|
||||
let perm = permission::ActiveModel {
|
||||
id: Set(perm_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
code: Set(code.to_string()),
|
||||
name: Set(name.to_string()),
|
||||
resource: Set(resource.to_string()),
|
||||
action: Set(action.to_string()),
|
||||
description: Set(Some(desc.to_string())),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
perm.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 2. Create "admin" role with all permissions
|
||||
let admin_role_id = Uuid::now_v7();
|
||||
let admin_role = role::ActiveModel {
|
||||
id: Set(admin_role_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set("管理员".to_string()),
|
||||
code: Set("admin".to_string()),
|
||||
description: Set(Some("系统管理员,拥有所有权限".to_string())),
|
||||
is_system: Set(true),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
admin_role
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Assign all permissions to admin role
|
||||
for perm_id in &perm_ids {
|
||||
let rp = role_permission::ActiveModel {
|
||||
role_id: Set(admin_role_id),
|
||||
permission_id: Set(*perm_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
data_scope: Set("all".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
rp.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 3. Create "viewer" role with read-only permissions
|
||||
let viewer_role_id = Uuid::now_v7();
|
||||
let viewer_role = role::ActiveModel {
|
||||
id: Set(viewer_role_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set("查看者".to_string()),
|
||||
code: Set("viewer".to_string()),
|
||||
description: Set(Some("只读用户,可查看所有数据".to_string())),
|
||||
is_system: Set(true),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
viewer_role
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Assign read permissions to viewer role
|
||||
for idx in READ_PERM_INDICES {
|
||||
if *idx < perm_ids.len() {
|
||||
let rp = role_permission::ActiveModel {
|
||||
role_id: Set(viewer_role_id),
|
||||
permission_id: Set(perm_ids[*idx]),
|
||||
tenant_id: Set(tenant_id),
|
||||
data_scope: Set("all".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
rp.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create super admin user
|
||||
let admin_user_id = Uuid::now_v7();
|
||||
let password_hash = password::hash_password(super_admin_password)?;
|
||||
|
||||
let admin_user = user::ActiveModel {
|
||||
id: Set(admin_user_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
username: Set("admin".to_string()),
|
||||
email: Set(None),
|
||||
phone: Set(None),
|
||||
display_name: Set(Some("系统管理员".to_string())),
|
||||
avatar_url: Set(None),
|
||||
status: Set("active".to_string()),
|
||||
last_login_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
admin_user
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Create password credential for admin user
|
||||
let cred = user_credential::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
user_id: Set(admin_user_id),
|
||||
credential_type: Set("password".to_string()),
|
||||
credential_data: Set(Some(serde_json::json!({ "hash": password_hash }))),
|
||||
verified: Set(true),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
cred.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 5. Assign admin role to admin user
|
||||
let user_role_assignment = user_role::ActiveModel {
|
||||
user_id: Set(admin_user_id),
|
||||
role_id: Set(admin_role_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user_id),
|
||||
updated_by: Set(system_user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
user_role_assignment
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
tenant_id = %tenant_id,
|
||||
admin_user_id = %admin_user_id,
|
||||
"Seeded tenant auth: admin user, 2 roles, {} permissions",
|
||||
DEFAULT_PERMISSIONS.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
326
crates/erp-auth/src/service/token_service.rs
Normal file
326
crates/erp-auth/src/service/token_service.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::{permission, role, role_permission, user_role, user_token};
|
||||
use crate::error::AuthError;
|
||||
|
||||
use crate::error::AuthResult;
|
||||
|
||||
/// JWT claims embedded in access and refresh tokens.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Subject — the user ID
|
||||
pub sub: Uuid,
|
||||
/// Tenant ID
|
||||
pub tid: Uuid,
|
||||
/// Role codes assigned to this user
|
||||
pub roles: Vec<String>,
|
||||
/// Permission codes granted to this user
|
||||
pub permissions: Vec<String>,
|
||||
/// Expiry (unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Issued at (unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Token type: "access" or "refresh"
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Stateless service for JWT token signing, validation, and revocation.
|
||||
pub struct TokenService;
|
||||
|
||||
impl TokenService {
|
||||
/// Sign a short-lived access token containing roles and permissions.
|
||||
pub fn sign_access_token(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
roles: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
secret: &str,
|
||||
ttl_secs: i64,
|
||||
) -> AuthResult<String> {
|
||||
let now = Utc::now();
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
tid: tenant_id,
|
||||
roles,
|
||||
permissions,
|
||||
exp: now.timestamp() + ttl_secs,
|
||||
iat: now.timestamp(),
|
||||
token_type: "access".to_string(),
|
||||
};
|
||||
let header = jsonwebtoken::Header::default();
|
||||
let encoded = jsonwebtoken::encode(
|
||||
&header,
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?;
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
/// Sign a long-lived refresh token and persist its SHA-256 hash in the database.
|
||||
///
|
||||
/// Returns the raw token string (sent to client) and the database row ID.
|
||||
pub async fn sign_refresh_token(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
secret: &str,
|
||||
ttl_secs: i64,
|
||||
) -> AuthResult<(String, Uuid)> {
|
||||
let now = Utc::now();
|
||||
let token_id = Uuid::now_v7();
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
tid: tenant_id,
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
exp: now.timestamp() + ttl_secs,
|
||||
iat: now.timestamp(),
|
||||
token_type: "refresh".to_string(),
|
||||
};
|
||||
let raw_token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::default(),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?;
|
||||
|
||||
// Store the SHA-256 hash — the raw token is never persisted.
|
||||
let hash = sha256_hex(&raw_token);
|
||||
|
||||
let token_model = user_token::ActiveModel {
|
||||
id: Set(token_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
user_id: Set(user_id),
|
||||
token_hash: Set(hash),
|
||||
token_type: Set("refresh".to_string()),
|
||||
expires_at: Set(now + chrono::Duration::seconds(ttl_secs)),
|
||||
revoked_at: Set(None),
|
||||
device_info: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(user_id),
|
||||
updated_by: Set(user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
token_model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok((raw_token, token_id))
|
||||
}
|
||||
|
||||
/// Validate a refresh token against the database.
|
||||
///
|
||||
/// Returns the database row ID and decoded claims.
|
||||
pub async fn validate_refresh_token(
|
||||
token: &str,
|
||||
db: &DatabaseConnection,
|
||||
secret: &str,
|
||||
) -> AuthResult<(Uuid, Claims)> {
|
||||
let claims = Self::decode_token(token, secret)?;
|
||||
if claims.token_type != "refresh" {
|
||||
return Err(AuthError::Validation("不是 refresh token".to_string()));
|
||||
}
|
||||
|
||||
let hash = sha256_hex(token);
|
||||
let token_row = user_token::Entity::find()
|
||||
.filter(user_token::Column::TokenHash.eq(hash))
|
||||
.filter(user_token::Column::TenantId.eq(claims.tid))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
Ok((token_row.id, claims))
|
||||
}
|
||||
|
||||
/// Decode and validate any JWT token, returning the claims.
|
||||
pub fn decode_token(token: &str, secret: &str) -> AuthResult<Claims> {
|
||||
let data = jsonwebtoken::decode::<Claims>(
|
||||
token,
|
||||
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
|
||||
&jsonwebtoken::Validation::default(),
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
/// Revoke a specific refresh token by database ID.
|
||||
/// Verifies that the token belongs to the specified user for security.
|
||||
pub async fn revoke_token(
|
||||
token_id: Uuid,
|
||||
user_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
let token_row = user_token::Entity::find_by_id(token_id)
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
let mut active: user_token::ActiveModel = token_row.into();
|
||||
active.revoked_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically validate and revoke a refresh token by hash.
|
||||
/// This prevents TOCTOU race conditions during concurrent refresh requests.
|
||||
/// Returns the decoded claims on success, or TokenRevoked if already consumed.
|
||||
pub async fn validate_and_revoke_atomic(
|
||||
token: &str,
|
||||
db: &DatabaseConnection,
|
||||
secret: &str,
|
||||
) -> AuthResult<Claims> {
|
||||
let claims = Self::decode_token(token, secret)?;
|
||||
if claims.token_type != "refresh" {
|
||||
return Err(AuthError::Validation("不是 refresh token".to_string()));
|
||||
}
|
||||
|
||||
let hash = sha256_hex(token);
|
||||
let now = Utc::now();
|
||||
let result = user_token::Entity::update_many()
|
||||
.col_expr(
|
||||
user_token::Column::RevokedAt,
|
||||
sea_orm::sea_query::Expr::value(Some(now.naive_utc())),
|
||||
)
|
||||
.col_expr(
|
||||
user_token::Column::UpdatedAt,
|
||||
sea_orm::sea_query::Expr::value(now.naive_utc()),
|
||||
)
|
||||
.col_expr(
|
||||
user_token::Column::Version,
|
||||
sea_orm::sea_query::Expr::col(user_token::Column::Version).add(1),
|
||||
)
|
||||
.filter(user_token::Column::TokenHash.eq(&hash))
|
||||
.filter(user_token::Column::UserId.eq(claims.sub))
|
||||
.filter(user_token::Column::TenantId.eq(claims.tid))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Revoke all non-revoked refresh tokens for a given user within a tenant.
|
||||
pub async fn revoke_all_user_tokens(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
let tokens = user_token::Entity::find()
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.filter(user_token::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
for token in tokens {
|
||||
let mut active: user_token::ActiveModel = token.into();
|
||||
active.revoked_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a user's permission codes through user_roles -> role_permissions -> permissions.
|
||||
pub async fn get_user_permissions(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> AuthResult<Vec<String>> {
|
||||
let user_role_rows = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.eq(user_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_role::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||
if role_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let role_perm_rows = role_permission::Entity::find()
|
||||
.filter(role_permission::Column::RoleId.is_in(role_ids))
|
||||
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(role_permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let perm_ids: Vec<Uuid> = role_perm_rows.iter().map(|rp| rp.permission_id).collect();
|
||||
if perm_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let perms = permission::Entity::find()
|
||||
.filter(permission::Column::Id.is_in(perm_ids))
|
||||
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(perms.iter().map(|p| p.code.clone()).collect())
|
||||
}
|
||||
|
||||
/// Look up a user's role codes through user_roles -> roles.
|
||||
pub async fn get_user_roles(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> AuthResult<Vec<String>> {
|
||||
let user_role_rows = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.eq(user_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_role::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||
if role_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let roles = role::Entity::find()
|
||||
.filter(role::Column::Id.is_in(role_ids))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(roles.iter().map(|r| r.code.clone()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a SHA-256 hex digest of the input string.
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let hash = Sha256::digest(input.as_bytes());
|
||||
format!("{:x}", hash)
|
||||
}
|
||||
629
crates/erp-auth/src/service/user_service.rs
Normal file
629
crates/erp-auth/src/service/user_service.rs
Normal file
@@ -0,0 +1,629 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
||||
use crate::entity::{role, user, user_credential, user_role};
|
||||
use crate::error::AuthError;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
use crate::error::AuthResult;
|
||||
|
||||
use super::password;
|
||||
|
||||
/// User CRUD service — create, read, update, soft-delete users within a tenant.
|
||||
pub struct UserService;
|
||||
|
||||
impl UserService {
|
||||
/// Create a new user with a password credential.
|
||||
///
|
||||
/// Validates username uniqueness within the tenant, hashes the password,
|
||||
/// and publishes a `user.created` event.
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateUserReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<UserResp> {
|
||||
// Check username uniqueness within tenant
|
||||
let existing = user::Entity::find()
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::Username.eq(&req.username))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("用户名已存在".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let user_id = Uuid::now_v7();
|
||||
|
||||
// Insert user record
|
||||
let user_model = user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
username: Set(req.username.clone()),
|
||||
email: Set(req.email.clone()),
|
||||
phone: Set(req.phone.clone()),
|
||||
display_name: Set(req.display_name.clone()),
|
||||
avatar_url: Set(None),
|
||||
status: Set("active".to_string()),
|
||||
last_login_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
user_model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Insert password credential
|
||||
let hash = password::hash_password(&req.password)?;
|
||||
let cred = user_credential::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
user_id: Set(user_id),
|
||||
credential_type: Set("password".to_string()),
|
||||
credential_data: Set(Some(serde_json::json!({ "hash": hash }))),
|
||||
verified: Set(true),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
cred.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// Publish domain event
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"user.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": user_id, "username": req.username }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.create", "user")
|
||||
.with_resource_id(user_id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(UserResp {
|
||||
id: user_id,
|
||||
username: req.username.clone(),
|
||||
email: req.email.clone(),
|
||||
phone: req.phone.clone(),
|
||||
display_name: req.display_name.clone(),
|
||||
avatar_url: None,
|
||||
status: "active".to_string(),
|
||||
roles: vec![],
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch a single user by ID, scoped to the given tenant.
|
||||
pub async fn get_by_id(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<UserResp> {
|
||||
let user_model = user::Entity::find()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
|
||||
Ok(model_to_resp(&user_model, roles))
|
||||
}
|
||||
|
||||
/// List users within a tenant with pagination and optional search.
|
||||
///
|
||||
/// Returns `(users, total_count)`. When `search` is provided, filters
|
||||
/// by username using case-insensitive substring match.
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
search: Option<&str>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<(Vec<UserResp>, u64)> {
|
||||
let mut query = user::Entity::find()
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(term) = search
|
||||
&& !term.is_empty()
|
||||
{
|
||||
use sea_orm::sea_query::Expr;
|
||||
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
|
||||
}
|
||||
|
||||
let paginator = query.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let mut resps = Vec::with_capacity(models.len());
|
||||
// 批量查询所有用户的角色(N+1 → 3 固定查询)
|
||||
let user_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||||
let role_map = Self::fetch_batch_user_role_resps(&user_ids, tenant_id, db).await;
|
||||
|
||||
for m in models {
|
||||
let roles = role_map.get(&m.id).cloned().unwrap_or_default();
|
||||
resps.push(model_to_resp(&m, roles));
|
||||
}
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// Update editable user fields.
|
||||
///
|
||||
/// Supports updating email, phone, display_name, and status.
|
||||
/// Status must be one of: "active", "disabled", "locked".
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateUserReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<UserResp> {
|
||||
let user_model = user::Entity::find()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
let old_json = serde_json::to_value(&user_model).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let next_ver = check_version(req.version, user_model.version)
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let mut active: user::ActiveModel = user_model.into();
|
||||
|
||||
if let Some(email) = &req.email {
|
||||
active.email = Set(Some(email.clone()));
|
||||
}
|
||||
if let Some(phone) = &req.phone {
|
||||
active.phone = Set(Some(phone.clone()));
|
||||
}
|
||||
if let Some(display_name) = &req.display_name {
|
||||
active.display_name = Set(Some(display_name.clone()));
|
||||
}
|
||||
if let Some(status) = &req.status {
|
||||
if !["active", "disabled", "locked"].contains(&status.as_str()) {
|
||||
return Err(AuthError::Validation("无效的状态值".to_string()));
|
||||
}
|
||||
active.status = Set(status.clone());
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
|
||||
.with_resource_id(id)
|
||||
.with_changes(Some(old_json), Some(new_json)),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
|
||||
Ok(model_to_resp(&updated, roles))
|
||||
}
|
||||
|
||||
/// Soft-delete a user by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let user_model = user::Entity::find()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
let current_version = user_model.version;
|
||||
let mut active: user::ActiveModel = user_model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"user.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "user_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace all role assignments for a user within a tenant.
|
||||
pub async fn assign_roles(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
role_ids: &[Uuid],
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<RoleResp>> {
|
||||
// 验证用户存在
|
||||
let _user = user::Entity::find()
|
||||
.filter(user::Column::Id.eq(user_id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
// 验证所有角色存在且属于当前租户
|
||||
if !role_ids.is_empty() {
|
||||
let found = role::Entity::find()
|
||||
.filter(role::Column::Id.is_in(role_ids.iter().copied()))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if found.len() != role_ids.len() {
|
||||
return Err(AuthError::Validation(
|
||||
"部分角色不存在或不属于当前租户".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧的角色分配
|
||||
user_role::Entity::delete_many()
|
||||
.filter(user_role::Column::UserId.eq(user_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 创建新的角色分配
|
||||
let now = chrono::Utc::now();
|
||||
for &role_id in role_ids {
|
||||
let assignment = user_role::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
role_id: Set(role_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
assignment
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.assign_roles", "user")
|
||||
.with_resource_id(user_id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Self::fetch_user_role_resps(user_id, tenant_id, db).await
|
||||
}
|
||||
|
||||
/// 批量查询多用户的角色,返回 user_id → RoleResp 映射。
|
||||
///
|
||||
/// 使用 3 次固定查询替代 N+1:用户角色关联 → 角色 → 分组组装。
|
||||
async fn fetch_batch_user_role_resps(
|
||||
user_ids: &[Uuid],
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> HashMap<Uuid, Vec<RoleResp>> {
|
||||
if user_ids.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
// 1. 批量查询 user_role 关联
|
||||
let user_roles: Vec<user_role::Model> = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.is_in(user_ids.iter().copied()))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||
|
||||
// 2. 批量查询角色
|
||||
let roles: Vec<role::Model> = if role_ids.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
role::Entity::find()
|
||||
.filter(role::Column::Id.is_in(role_ids.iter().copied()))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let role_map: HashMap<Uuid, &role::Model> = roles.iter().map(|r| (r.id, r)).collect();
|
||||
|
||||
// 3. 按 user_id 分组
|
||||
let mut result: HashMap<Uuid, Vec<RoleResp>> = HashMap::new();
|
||||
for ur in &user_roles {
|
||||
let resp = role_map
|
||||
.get(&ur.role_id)
|
||||
.map(|r| RoleResp {
|
||||
id: r.id,
|
||||
name: r.name.clone(),
|
||||
code: r.code.clone(),
|
||||
description: r.description.clone(),
|
||||
is_system: r.is_system,
|
||||
version: r.version,
|
||||
})
|
||||
.unwrap_or_else(|| RoleResp {
|
||||
id: ur.role_id,
|
||||
name: "Unknown".into(),
|
||||
code: "unknown".into(),
|
||||
description: None,
|
||||
is_system: false,
|
||||
version: 0,
|
||||
});
|
||||
result.entry(ur.user_id).or_default().push(resp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 管理员重置指定用户密码。
|
||||
pub async fn reset_password(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
new_password: &str,
|
||||
version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
// 1. 验证用户存在且属于当前租户
|
||||
let user_model = user::Entity::find()
|
||||
.filter(user::Column::Id.eq(user_id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
let _next_version = check_version(version, user_model.version)
|
||||
.map_err(|_| AuthError::Validation("版本冲突,请刷新后重试".to_string()))?;
|
||||
|
||||
// 2. 查找密码凭证
|
||||
let cred = user_credential::Entity::find()
|
||||
.filter(user_credential::Column::UserId.eq(user_id))
|
||||
.filter(user_credential::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||
.filter(user_credential::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
|
||||
|
||||
// 3. 哈希新密码并更新凭证
|
||||
let new_hash = password::hash_password(new_password)?;
|
||||
let cred_version = cred.version;
|
||||
let mut cred_active: user_credential::ActiveModel = cred.into();
|
||||
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
|
||||
cred_active.updated_at = Set(Utc::now());
|
||||
cred_active.updated_by = Set(operator_id);
|
||||
cred_active.version = Set(cred_version + 1);
|
||||
cred_active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
// 4. 吊销所有 refresh token
|
||||
super::token_service::TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 5. 审计日志
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "user.reset_password", "user")
|
||||
.with_resource_id(user_id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(user_id = %user_id, operator_id = %operator_id, "Password reset by admin");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch role details for a single user, returning RoleResp DTOs.
|
||||
async fn fetch_user_role_resps(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<RoleResp>> {
|
||||
let user_roles = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.eq(user_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||
if role_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let roles = role::Entity::find()
|
||||
.filter(role::Column::Id.is_in(role_ids))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(roles
|
||||
.iter()
|
||||
.map(|r| RoleResp {
|
||||
id: r.id,
|
||||
name: r.name.clone(),
|
||||
code: r.code.clone(),
|
||||
description: r.description.clone(),
|
||||
is_system: r.is_system,
|
||||
version: r.version,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
|
||||
pub(crate) fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
||||
UserResp {
|
||||
id: m.id,
|
||||
username: m.username.clone(),
|
||||
email: m.email.clone(),
|
||||
phone: m.phone.clone(),
|
||||
display_name: m.display_name.clone(),
|
||||
avatar_url: m.avatar_url.clone(),
|
||||
status: m.status.clone(),
|
||||
roles,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::RoleResp;
|
||||
use crate::entity::user;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn make_user_model(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
username: &str,
|
||||
status: &str,
|
||||
version: i32,
|
||||
) -> user::Model {
|
||||
user::Model {
|
||||
id,
|
||||
tenant_id,
|
||||
username: username.to_string(),
|
||||
email: None,
|
||||
phone: None,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
status: status.to_string(),
|
||||
last_login_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
created_by: Uuid::now_v7(),
|
||||
updated_by: Uuid::now_v7(),
|
||||
deleted_at: None,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_to_resp_maps_basic_fields() {
|
||||
let id = Uuid::now_v7();
|
||||
let tid = Uuid::now_v7();
|
||||
let m = make_user_model(id, tid, "alice", "active", 1);
|
||||
let resp = model_to_resp(&m, vec![]);
|
||||
assert_eq!(resp.id, id);
|
||||
assert_eq!(resp.username, "alice");
|
||||
assert_eq!(resp.status, "active");
|
||||
assert_eq!(resp.version, 1);
|
||||
assert!(resp.roles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_to_resp_includes_roles() {
|
||||
let id = Uuid::now_v7();
|
||||
let tid = Uuid::now_v7();
|
||||
let m = make_user_model(id, tid, "bob", "active", 2);
|
||||
let roles = vec![
|
||||
RoleResp {
|
||||
id: Uuid::now_v7(),
|
||||
name: "管理员".to_string(),
|
||||
code: "admin".to_string(),
|
||||
description: None,
|
||||
is_system: true,
|
||||
version: 1,
|
||||
},
|
||||
RoleResp {
|
||||
id: Uuid::now_v7(),
|
||||
name: "用户".to_string(),
|
||||
code: "user".to_string(),
|
||||
description: None,
|
||||
is_system: false,
|
||||
version: 1,
|
||||
},
|
||||
];
|
||||
let resp = model_to_resp(&m, roles);
|
||||
assert_eq!(resp.roles.len(), 2);
|
||||
assert_eq!(resp.roles[0].code, "admin");
|
||||
assert_eq!(resp.version, 2);
|
||||
}
|
||||
}
|
||||
575
crates/erp-auth/src/service/wechat_service.rs
Normal file
575
crates/erp-auth/src/service/wechat_service.rs
Normal file
@@ -0,0 +1,575 @@
|
||||
use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
|
||||
use base64::Engine;
|
||||
use cbc::Decryptor;
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{LoginResp, UserResp, WechatLoginResp};
|
||||
use crate::entity::wechat_user;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use crate::service::auth_service::JwtConfig;
|
||||
use crate::service::token_service::TokenService;
|
||||
use erp_core::sanitize::sanitize_string;
|
||||
|
||||
type Aes128CbcDec = Decryptor<aes::Aes128>;
|
||||
|
||||
/// 内存降级缓存(Redis 不可用时使用)
|
||||
struct SessionEntry {
|
||||
session_key: String,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
static MEMORY_FALLBACK: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
const SESSION_TTL_SECS: u64 = 300;
|
||||
|
||||
/// Redis key 前缀
|
||||
const REDIS_KEY_PREFIX: &str = "wechat:session:";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatSessionResp {
|
||||
openid: Option<String>,
|
||||
session_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
unionid: Option<String>,
|
||||
errcode: Option<i32>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
pub struct WechatService;
|
||||
|
||||
impl WechatService {
|
||||
pub async fn login(
|
||||
state: &AuthState,
|
||||
tenant_id: Uuid,
|
||||
code: &str,
|
||||
) -> AuthResult<WechatLoginResp> {
|
||||
tracing::info!(
|
||||
appid = %state.wechat_appid,
|
||||
code = %code,
|
||||
"fetch_session 开始"
|
||||
);
|
||||
let session = fetch_session(
|
||||
&state.wechat_appid,
|
||||
&state.wechat_secret,
|
||||
code,
|
||||
state.wechat_dev_mode,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let openid = session
|
||||
.openid
|
||||
.clone()
|
||||
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
||||
|
||||
// 缓存 session_key(Redis 优先,内存降级)
|
||||
if let Some(sk) = &session.session_key
|
||||
&& let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await
|
||||
{
|
||||
tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
|
||||
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||
cache.insert(
|
||||
openid.clone(),
|
||||
SessionEntry {
|
||||
session_key: sk.clone(),
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let existing = wechat_user::Entity::find()
|
||||
.filter(wechat_user::Column::Openid.eq(&openid))
|
||||
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||
.filter(wechat_user::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
if let Some(wu) = existing {
|
||||
let token = build_login_resp(
|
||||
&state.db,
|
||||
wu.user_id,
|
||||
tenant_id,
|
||||
&JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(WechatLoginResp {
|
||||
bound: true,
|
||||
openid,
|
||||
token: Some(token),
|
||||
})
|
||||
} else {
|
||||
Ok(WechatLoginResp {
|
||||
bound: false,
|
||||
openid,
|
||||
token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bind_phone(
|
||||
state: &AuthState,
|
||||
tenant_id: Uuid,
|
||||
openid: &str,
|
||||
encrypted_data: &str,
|
||||
iv: &str,
|
||||
) -> AuthResult<LoginResp> {
|
||||
// Dev 模式:mock session_key 无法解密真实微信加密数据,直接使用 mock 手机号
|
||||
let phone = if state.wechat_dev_mode {
|
||||
let hash = openid
|
||||
.bytes()
|
||||
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
|
||||
let suffix = hash % 10000;
|
||||
tracing::warn!(%openid, mock_phone = format!("1380000{suffix:04}"), "开发模式:跳过手机号解密,使用 mock 手机号");
|
||||
format!("1380000{suffix:04}")
|
||||
} else {
|
||||
let session_key = Self::get_session_key(&state.redis, openid).await?;
|
||||
decrypt_phone_number(&session_key, encrypted_data, iv)?
|
||||
};
|
||||
|
||||
let existing = wechat_user::Entity::find()
|
||||
.filter(wechat_user::Column::Openid.eq(openid))
|
||||
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||
.filter(wechat_user::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("该微信已绑定账号".to_string()));
|
||||
}
|
||||
|
||||
let user_id =
|
||||
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone, &state.crypto).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let wu = wechat_user::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
openid: Set(openid.to_string()),
|
||||
union_id: Set(None),
|
||||
user_id: Set(user_id),
|
||||
phone: Set(Some(phone.clone())),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
wu.insert(&state.db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
build_login_resp(
|
||||
&state.db,
|
||||
user_id,
|
||||
tenant_id,
|
||||
&JwtConfig {
|
||||
secret: &state.jwt_secret,
|
||||
access_ttl_secs: state.access_ttl_secs,
|
||||
refresh_ttl_secs: state.refresh_ttl_secs,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_or_create_user_by_phone(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
phone: &str,
|
||||
crypto: &erp_core::crypto::PiiCrypto,
|
||||
) -> AuthResult<Uuid> {
|
||||
use crate::entity::user;
|
||||
|
||||
let existing = user::Entity::find()
|
||||
.filter(user::Column::Phone.eq(phone))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
if let Some(u) = existing {
|
||||
return Ok(u.id);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let user_id = Uuid::now_v7();
|
||||
let suffix = &phone[phone.len().saturating_sub(4)..];
|
||||
|
||||
let new_user = user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
username: Set(format!("wx_{}", suffix)),
|
||||
display_name: Set(Some(sanitize_string(&format!("微信用户{}", suffix)))),
|
||||
phone: Set(Some(phone.to_string())),
|
||||
email: Set(None),
|
||||
avatar_url: Set(None),
|
||||
status: Set("active".to_string()),
|
||||
last_login_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(user_id),
|
||||
updated_by: Set(user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
new_user
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
// 自动分配 patient 角色
|
||||
Self::assign_patient_role(db, tenant_id, user_id).await?;
|
||||
|
||||
// 自动创建或关联 patient 记录
|
||||
Self::ensure_patient_record(db, tenant_id, user_id, phone, crypto).await?;
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
async fn assign_patient_role(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AuthResult<()> {
|
||||
use crate::entity::role;
|
||||
use crate::entity::user_role;
|
||||
|
||||
let patient_role = role::Entity::find()
|
||||
.filter(role::Column::Code.eq("patient"))
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
if let Some(r) = patient_role {
|
||||
let now = Utc::now();
|
||||
let ur = user_role::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
role_id: Set(r.id),
|
||||
tenant_id: Set(tenant_id),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(user_id),
|
||||
updated_by: Set(user_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
ur.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
tracing::info!(%user_id, role_id = %r.id, "已为新用户分配 patient 角色");
|
||||
} else {
|
||||
tracing::warn!(%tenant_id, "patient 角色不存在,跳过角色分配");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 自动创建或关联 patient 记录。
|
||||
///
|
||||
/// 1. 如果已有 user_id 关联的 patient → 跳过
|
||||
/// 2. 如果手机号盲索引匹配到未绑定的已有患者 → 合并(关联 user_id)
|
||||
/// 3. 否则 → 创建新的 patient 记录
|
||||
async fn ensure_patient_record(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
phone: &str,
|
||||
crypto: &erp_core::crypto::PiiCrypto,
|
||||
) -> AuthResult<()> {
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
|
||||
// 使用 raw SQL 避免跨 crate 依赖 erp-health 的 entity
|
||||
let result: Option<sea_orm::QueryResult> = db
|
||||
.query_one(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT id FROM patient WHERE user_id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||||
[user_id.into(), tenant_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
if result.is_some() {
|
||||
tracing::debug!(%user_id, "patient 记录已存在,跳过创建");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 智能合并:用手机号盲索引查找未绑定的已有患者(管理员/护士建档)
|
||||
let phone_hash = erp_core::crypto::hmac_hash(crypto.hmac_key(), phone);
|
||||
let blind_match: Option<sea_orm::QueryResult> = db
|
||||
.query_one(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT bi.entity_id AS patient_id
|
||||
FROM blind_index bi
|
||||
JOIN patient p ON p.id = bi.entity_id AND p.tenant_id = $2 AND p.deleted_at IS NULL
|
||||
WHERE bi.entity_type = 'patient'
|
||||
AND bi.field_name = 'emergency_contact_phone'
|
||||
AND bi.blind_hash = $1
|
||||
AND bi.tenant_id = $2
|
||||
AND p.user_id IS NULL
|
||||
LIMIT 1"#,
|
||||
[phone_hash.as_str().into(), tenant_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
if let Some(row) = blind_match {
|
||||
let patient_id: Uuid = row
|
||||
.try_get("", "patient_id")
|
||||
.map_err(|e| AuthError::DbError(format!("blind_index parse: {}", e)))?;
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"UPDATE patient SET user_id = $1, updated_at = NOW(), updated_by = $1 WHERE id = $2 AND user_id IS NULL",
|
||||
[user_id.into(), patient_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
tracing::info!(%user_id, %patient_id, "手机号盲索引合并 patient");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = &phone[phone.len().saturating_sub(4)..];
|
||||
let patient_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"INSERT INTO patient (id, tenant_id, user_id, name, gender, status, verification_status, source, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
VALUES ($1, $2, $3, $4, NULL, 'active', 'pending', 'wechat_miniprogram', $5, $5, $3, $3, NULL, 1)
|
||||
ON CONFLICT DO NOTHING"#,
|
||||
[
|
||||
patient_id.into(),
|
||||
tenant_id.into(),
|
||||
user_id.into(),
|
||||
sanitize_string(&format!("微信用户{}", suffix)).into(),
|
||||
now.into(),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?;
|
||||
|
||||
tracing::info!(%user_id, %patient_id, "已自动创建 patient 记录");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_session_key_redis(
|
||||
redis: &Option<redis::Client>,
|
||||
openid: &str,
|
||||
session_key: &str,
|
||||
) -> AuthResult<()> {
|
||||
let client = redis
|
||||
.as_ref()
|
||||
.ok_or_else(|| AuthError::DbError("Redis 未配置".into()))?;
|
||||
let mut conn = client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(format!("Redis 连接失败: {e}")))?;
|
||||
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||
redis::cmd("SET")
|
||||
.arg(&key)
|
||||
.arg(session_key)
|
||||
.arg("EX")
|
||||
.arg(SESSION_TTL_SECS)
|
||||
.query_async::<String>(&mut conn)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(format!("Redis SET 失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_session_key(redis: &Option<redis::Client>, openid: &str) -> AuthResult<String> {
|
||||
// 1. 尝试 Redis
|
||||
if let Some(client) = redis
|
||||
&& let Ok(mut conn) = client.get_multiplexed_async_connection().await
|
||||
{
|
||||
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||
let result: Option<String> = redis::cmd("GETDEL")
|
||||
.arg(&key)
|
||||
.query_async::<Option<String>>(&mut conn)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
if let Some(sk) = result {
|
||||
return Ok(sk);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 降级到内存
|
||||
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||
if let Some(entry) = cache.get(openid) {
|
||||
if entry.created_at.elapsed().as_secs() < SESSION_TTL_SECS {
|
||||
let sk = entry.session_key.clone();
|
||||
cache.remove(openid);
|
||||
return Ok(sk);
|
||||
}
|
||||
cache.remove(openid);
|
||||
}
|
||||
|
||||
Err(AuthError::Validation(
|
||||
"未找到 session_key,请重新登录".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-128-CBC 解密微信手机号
|
||||
fn decrypt_phone_number(session_key: &str, encrypted_data: &str, iv: &str) -> AuthResult<String> {
|
||||
let engine = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
let key_bytes = engine
|
||||
.decode(session_key)
|
||||
.map_err(|e| AuthError::Validation(format!("session_key base64 解码失败: {}", e)))?;
|
||||
let iv_bytes = engine
|
||||
.decode(iv)
|
||||
.map_err(|e| AuthError::Validation(format!("iv base64 解码失败: {}", e)))?;
|
||||
let ciphertext = engine
|
||||
.decode(encrypted_data)
|
||||
.map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?;
|
||||
|
||||
if key_bytes.len() != 16 {
|
||||
return Err(AuthError::Validation("session_key 长度不正确".to_string()));
|
||||
}
|
||||
if iv_bytes.len() != 16 {
|
||||
return Err(AuthError::Validation("iv 长度不正确".to_string()));
|
||||
}
|
||||
|
||||
let decryptor = Aes128CbcDec::new_from_slices(&key_bytes, &iv_bytes)
|
||||
.map_err(|e| AuthError::Validation(format!("AES 初始化失败: {}", e)))?;
|
||||
|
||||
let mut buf = ciphertext;
|
||||
let decrypted = decryptor
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||
.map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?;
|
||||
|
||||
let plaintext = String::from_utf8(decrypted.to_vec())
|
||||
.map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?;
|
||||
|
||||
// 微信返回的 JSON 包含 watermark 等字段,提取 phone_number
|
||||
let info: serde_json::Value = serde_json::from_str(&plaintext)
|
||||
.map_err(|e| AuthError::Validation(format!("解密结果 JSON 解析失败: {}", e)))?;
|
||||
|
||||
info.get("phoneNumber")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| AuthError::Validation("解密结果中无 phoneNumber".to_string()))
|
||||
}
|
||||
|
||||
async fn build_login_resp(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
jwt: &JwtConfig<'_>,
|
||||
) -> AuthResult<LoginResp> {
|
||||
use crate::entity::user;
|
||||
use crate::service::auth_service::AuthService;
|
||||
|
||||
let user_model = user::Entity::find_by_id(user_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?
|
||||
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||
|
||||
let roles = TokenService::get_user_roles(user_id, tenant_id, db).await?;
|
||||
let permissions = TokenService::get_user_permissions(user_id, tenant_id, db).await?;
|
||||
|
||||
let access_token = TokenService::sign_access_token(
|
||||
user_id,
|
||||
tenant_id,
|
||||
roles.clone(),
|
||||
permissions,
|
||||
jwt.secret,
|
||||
jwt.access_ttl_secs,
|
||||
)?;
|
||||
let (refresh_token, _) =
|
||||
TokenService::sign_refresh_token(user_id, tenant_id, db, jwt.secret, jwt.refresh_ttl_secs)
|
||||
.await?;
|
||||
|
||||
let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?;
|
||||
|
||||
Ok(LoginResp {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in: jwt.access_ttl_secs as u64,
|
||||
user: UserResp {
|
||||
id: user_model.id,
|
||||
username: user_model.username,
|
||||
email: user_model.email,
|
||||
phone: user_model.phone,
|
||||
display_name: user_model.display_name,
|
||||
avatar_url: user_model.avatar_url,
|
||||
status: user_model.status,
|
||||
roles: role_resps,
|
||||
version: user_model.version,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_session(
|
||||
appid: &str,
|
||||
secret: &str,
|
||||
code: &str,
|
||||
dev_mode: bool,
|
||||
) -> AuthResult<WechatSessionResp> {
|
||||
// 开发模式降级:跳过 jscode2session,为 DevTools 模拟器生成确定性 mock openid
|
||||
if dev_mode {
|
||||
let mock_openid = format!("dev_mock_{}", &code[..8.min(code.len())]);
|
||||
tracing::warn!(%mock_openid, "开发模式:使用 mock openid(跳过 jscode2session)");
|
||||
return Ok(WechatSessionResp {
|
||||
openid: Some(mock_openid),
|
||||
session_key: Some("dev_mock_session_key".to_string()),
|
||||
unionid: None,
|
||||
errcode: None,
|
||||
errmsg: None,
|
||||
});
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get("https://api.weixin.qq.com/sns/jscode2session")
|
||||
.query(&[
|
||||
("appid", appid),
|
||||
("secret", secret),
|
||||
("js_code", code),
|
||||
("grant_type", "authorization_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(format!("微信 API 请求失败: {}", e)))?;
|
||||
|
||||
let session: WechatSessionResp = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?;
|
||||
|
||||
if let Some(errcode) = session.errcode
|
||||
&& errcode != 0
|
||||
{
|
||||
let msg = session.errmsg.clone().unwrap_or_default();
|
||||
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
|
||||
return Err(AuthError::Validation(format!(
|
||||
"微信登录失败 ({}): {}",
|
||||
errcode, msg
|
||||
)));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
has_openid = session.openid.is_some(),
|
||||
has_session_key = session.session_key.is_some(),
|
||||
"微信 jscode2session 成功"
|
||||
);
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
Reference in New Issue
Block a user