功能修复: 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 统一格式化
569 lines
19 KiB
Rust
569 lines
19 KiB
Rust
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);
|
||
}
|
||
}
|