feat(auth): implement core service layer (password, JWT, auth, user CRUD)

- error.rs: AuthError with proper HTTP status mapping
- service/password.rs: Argon2 hash/verify with tests
- service/token_service.rs: JWT sign/validate, token DB storage with SHA-256 hash
- service/auth_service.rs: login/refresh/logout flows with event publishing
- service/user_service.rs: user CRUD with soft delete and tenant isolation
- Added sha2 dependency to workspace for token hashing
This commit is contained in:
iven
2026-04-11 03:05:17 +08:00
parent 411a07caa1
commit edc41a1500
9 changed files with 916 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
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}")]
Validation(String),
}
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::HashError(_) => AppError::Internal(err.to_string()),
AuthError::JwtError(_) => AppError::Unauthorized,
}
}
}
pub type AuthResult<T> = Result<T, AuthError>;

View File

@@ -1,2 +1,4 @@
pub mod dto;
pub mod entity;
pub mod error;
pub mod service;

View File

@@ -0,0 +1,248 @@
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 erp_core::events::EventBus;
use crate::error::AuthResult;
use super::password;
use super::token_service::TokenService;
/// 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
pub async fn login(
tenant_id: Uuid,
username: &str,
password_plain: &str,
db: &sea_orm::DatabaseConnection,
jwt_secret: &str,
access_ttl_secs: i64,
refresh_ttl_secs: i64,
event_bus: &EventBus,
) -> AuthResult<LoginResp> {
// 1. Find user by tenant_id + username
let user_model = 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()))?
.ok_or(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)? {
return Err(AuthError::InvalidCredentials);
}
// 5. Get roles and permissions
let roles: Vec<String> =
TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
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,
access_ttl_secs,
)?;
let (refresh_token, _) = TokenService::sign_refresh_token(
user_model.id,
tenant_id,
db,
jwt_secret,
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
.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,
};
// 9. Publish event
event_bus.publish(erp_core::events::DomainEvent::new(
"user.login",
tenant_id,
serde_json::json!({ "user_id": user_model.id, "username": user_model.username }),
));
Ok(LoginResp {
access_token,
refresh_token,
expires_in: 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_secret: &str,
access_ttl_secs: i64,
refresh_ttl_secs: i64,
) -> AuthResult<LoginResp> {
// Validate existing refresh token
let (old_token_id, claims) =
TokenService::validate_refresh_token(refresh_token_str, db, jwt_secret).await?;
// Revoke the old token (rotation)
TokenService::revoke_token(old_token_id, db).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,
access_ttl_secs,
)?;
let (new_refresh_token, _) = TokenService::sign_refresh_token(
claims.sub,
claims.tid,
db,
jwt_secret,
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)?;
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,
};
Ok(LoginResp {
access_token,
refresh_token: new_refresh_token,
expires_in: 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,
) -> AuthResult<()> {
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await
}
/// Fetch role details for a user, returning RoleResp DTOs.
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,
})
.collect())
}
}

View File

@@ -0,0 +1,4 @@
pub mod auth_service;
pub mod password;
pub mod token_service;
pub mod user_service;

View File

@@ -0,0 +1,56 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
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)"
);
}
}

View File

@@ -0,0 +1,271 @@
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::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.
pub async fn revoke_token(token_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
let token_row = user_token::Entity::find_by_id(token_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
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(())
}
/// 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
.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))
.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))
.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))
.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)
}

View File

@@ -0,0 +1,289 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
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::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 }),
));
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![],
})
}
/// 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_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
.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.
///
/// Returns `(users, total_count)`.
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<(Vec<UserResp>, u64)> {
let paginator = user::Entity::find()
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::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) as u64;
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let mut resps = Vec::with_capacity(models.len());
for m in models {
let roles: Vec<RoleResp> = Self::fetch_user_role_resps(m.id, tenant_id, db)
.await
.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_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("用户不存在".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);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
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_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
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
.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 }),
));
Ok(())
}
/// Fetch RoleResp DTOs for a given user within a tenant.
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,
})
.collect())
}
}
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
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,
}
}