- Update CLAUDE.md architecture snapshot: all phases complete - Update wiki/index.md: module descriptions and progress table - All 6 phases of ERP platform base are now implemented Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
302 lines
10 KiB
Rust
302 lines
10 KiB
Rust
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 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 {
|
|
if !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());
|
|
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,
|
|
}
|
|
}
|