Files
hms/crates/erp-auth/src/service/user_service.rs
iven 6d5a711d2c
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复:
1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查
2. 仪表盘统计容错:单个查询失败返回零值而非 500
3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致
4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径
5. 积分端点权限码:health.health-data.list → health.points.list
6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage
7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档

Clippy 全 workspace 清零(14→0 errors):
- erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处
- erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处
- erp-ai: 修复 dead_code、unused import 等 11 处
- erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处
- erp-server-migration: 修复 enum_variant_names 5 处
- erp-auth/config/workflow/message: 各 1-3 处

工程改进:
- lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy)
- cargo fmt 统一格式化
2026-05-07 23:43:14 +08:00

569 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
/// 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);
}
}