From edc41a150054532e71dad014ac0cd5709d235c7c Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 03:05:17 +0800 Subject: [PATCH] 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 --- Cargo.toml | 3 + crates/erp-auth/Cargo.toml | 1 + crates/erp-auth/src/error.rs | 42 +++ crates/erp-auth/src/lib.rs | 2 + crates/erp-auth/src/service/auth_service.rs | 248 ++++++++++++++++ crates/erp-auth/src/service/mod.rs | 4 + crates/erp-auth/src/service/password.rs | 56 ++++ crates/erp-auth/src/service/token_service.rs | 271 +++++++++++++++++ crates/erp-auth/src/service/user_service.rs | 289 +++++++++++++++++++ 9 files changed, 916 insertions(+) create mode 100644 crates/erp-auth/src/error.rs create mode 100644 crates/erp-auth/src/service/auth_service.rs create mode 100644 crates/erp-auth/src/service/mod.rs create mode 100644 crates/erp-auth/src/service/password.rs create mode 100644 crates/erp-auth/src/service/token_service.rs create mode 100644 crates/erp-auth/src/service/user_service.rs diff --git a/Cargo.toml b/Cargo.toml index 39d0edd..812faaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,9 @@ jsonwebtoken = "9" # Password hashing argon2 = "0.5" +# Cryptographic hashing (token storage) +sha2 = "0.10" + # API docs utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } # utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用 diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml index c61ba49..0145b8f 100644 --- a/crates/erp-auth/Cargo.toml +++ b/crates/erp-auth/Cargo.toml @@ -18,5 +18,6 @@ anyhow.workspace = true thiserror.workspace = true jsonwebtoken.workspace = true argon2.workspace = true +sha2.workspace = true validator.workspace = true utoipa.workspace = true diff --git a/crates/erp-auth/src/error.rs b/crates/erp-auth/src/error.rs new file mode 100644 index 0000000..2f1a6a3 --- /dev/null +++ b/crates/erp-auth/src/error.rs @@ -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 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 = Result; diff --git a/crates/erp-auth/src/lib.rs b/crates/erp-auth/src/lib.rs index 127022d..3151d51 100644 --- a/crates/erp-auth/src/lib.rs +++ b/crates/erp-auth/src/lib.rs @@ -1,2 +1,4 @@ pub mod dto; pub mod entity; +pub mod error; +pub mod service; diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs new file mode 100644 index 0000000..c95cbca --- /dev/null +++ b/crates/erp-auth/src/service/auth_service.rs @@ -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 { + // 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 = + 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 { + // 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 = + 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> { + 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 = 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()) + } +} diff --git a/crates/erp-auth/src/service/mod.rs b/crates/erp-auth/src/service/mod.rs new file mode 100644 index 0000000..ed40a5b --- /dev/null +++ b/crates/erp-auth/src/service/mod.rs @@ -0,0 +1,4 @@ +pub mod auth_service; +pub mod password; +pub mod token_service; +pub mod user_service; diff --git a/crates/erp-auth/src/service/password.rs b/crates/erp-auth/src/service/password.rs new file mode 100644 index 0000000..41fc1e5 --- /dev/null +++ b/crates/erp-auth/src/service/password.rs @@ -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 { + 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 { + 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)" + ); + } +} diff --git a/crates/erp-auth/src/service/token_service.rs b/crates/erp-auth/src/service/token_service.rs new file mode 100644 index 0000000..5a924c0 --- /dev/null +++ b/crates/erp-auth/src/service/token_service.rs @@ -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, + /// Permission codes granted to this user + pub permissions: Vec, + /// 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, + permissions: Vec, + secret: &str, + ttl_secs: i64, + ) -> AuthResult { + 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 { + let data = jsonwebtoken::decode::( + 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> { + 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 = 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 = 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> { + 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 = 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) +} diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs new file mode 100644 index 0000000..e6c6d85 --- /dev/null +++ b/crates/erp-auth/src/service/user_service.rs @@ -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 { + // 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 { + 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, 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 = 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 { + 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> { + 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 = 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) -> 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, + } +}