fix: resolve E2E audit findings and add Phase C frontend pages

- Fix audit_log handler multi-tenant bug: use Extension<TenantContext>
  instead of hardcoded default_tenant_id
- Fix sendMessage route mismatch: frontend /messages/send → /messages
- Add POST /users/{id}/roles backend route for role assignment
- Add task.completed event payload: started_by + instance_id for
  notification delivery
- Add audit log viewer frontend page (AuditLogViewer.tsx)
- Add language management frontend page (LanguageManager.tsx)
- Add api/auditLogs.ts and api/languages.ts modules
This commit is contained in:
iven
2026-04-12 15:57:33 +08:00
parent 14f431efff
commit 3b41e73f82
11 changed files with 567 additions and 12 deletions

View File

@@ -1,7 +1,7 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::Json;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use validator::Validate;
use erp_core::error::AppError;
@@ -9,7 +9,7 @@ use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{CreateUserReq, UpdateUserReq, UserResp};
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
use erp_core::rbac::require_permission;
use crate::service::user_service::UserService;
@@ -151,3 +151,38 @@ where
message: Some("用户已删除".to_string()),
}))
}
/// Assign roles request body.
#[derive(Debug, Deserialize)]
pub struct AssignRolesReq {
pub role_ids: Vec<Uuid>,
}
/// Assign roles response.
#[derive(Debug, Serialize)]
pub struct AssignRolesResp {
pub roles: Vec<RoleResp>,
}
/// POST /api/v1/users/:id/roles
///
/// Replace all role assignments for a user within the current tenant.
/// Requires the `user.update` permission.
pub async fn assign_roles<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<AssignRolesReq>,
) -> Result<Json<ApiResponse<AssignRolesResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.update")?;
let roles =
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db)
.await?;
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
}

View File

@@ -53,6 +53,10 @@ impl AuthModule {
.put(user_handler::update_user)
.delete(user_handler::delete_user),
)
.route(
"/users/{id}/roles",
axum::routing::post(user_handler::assign_roles),
)
.route(
"/roles",
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),

View File

@@ -277,6 +277,70 @@ impl UserService {
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_by_id(user_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()))?;
// 验证所有角色存在且属于当前租户
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
}
/// Fetch RoleResp DTOs for a given user within a tenant.
async fn fetch_user_role_resps(
user_id: Uuid,

View File

@@ -1,13 +1,13 @@
use axum::extract::{Query, State};
use axum::extract::{Extension, FromRef, Query, State};
use axum::response::Json;
use axum::routing::get;
use axum::Router;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use crate::state::AppState;
use erp_core::entity::audit_log;
use erp_core::error::AppError;
use erp_core::types::TenantContext;
/// 审计日志查询参数。
#[derive(Debug, Deserialize)]
@@ -30,13 +30,19 @@ pub struct AuditLogResponse {
/// GET /audit-logs
///
/// 分页查询审计日志,支持按 resource_type 和 user_id 过滤。
pub async fn list_audit_logs(
State(state): State<AppState>,
/// 租户隔离通过 JWT 中间件注入的 TenantContext 实现。
pub async fn list_audit_logs<S>(
State(db): State<sea_orm::DatabaseConnection>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<AuditLogQuery>,
) -> Result<Json<AuditLogResponse>, AppError> {
) -> Result<Json<AuditLogResponse>, AppError>
where
sea_orm::DatabaseConnection: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let tenant_id = state.default_tenant_id;
let tenant_id = ctx.tenant_id;
let mut q = audit_log::Entity::find()
.filter(audit_log::Column::TenantId.eq(tenant_id));
@@ -50,7 +56,7 @@ pub async fn list_audit_logs(
let paginator = q
.order_by_desc(audit_log::Column::CreatedAt)
.paginate(&state.db, page_size);
.paginate(&db, page_size);
let total = paginator
.num_items()
@@ -70,6 +76,10 @@ pub async fn list_audit_logs(
}))
}
pub fn audit_log_router() -> Router<AppState> {
pub fn audit_log_router<S>() -> Router<S>
where
sea_orm::DatabaseConnection: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new().route("/audit-logs", get(list_audit_logs))
}

View File

@@ -241,7 +241,12 @@ impl TaskService {
event_bus.publish(erp_core::events::DomainEvent::new(
"task.completed",
tenant_id,
serde_json::json!({ "task_id": id, "outcome": req.outcome }),
serde_json::json!({
"task_id": id,
"instance_id": instance_id,
"started_by": instance.started_by,
"outcome": req.outcome,
}),
), db).await;
audit_service::record(