From 3b41e73f82db44637ef07b18f7be82f06b530519 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 12 Apr 2026 15:57:33 +0800 Subject: [PATCH] fix: resolve E2E audit findings and add Phase C frontend pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix audit_log handler multi-tenant bug: use Extension 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 --- apps/web/src/api/auditLogs.ts | 31 +++ apps/web/src/api/languages.ts | 36 +++ apps/web/src/api/messages.ts | 2 +- apps/web/src/pages/Settings.tsx | 4 + .../web/src/pages/settings/AuditLogViewer.tsx | 156 +++++++++++++ .../src/pages/settings/LanguageManager.tsx | 210 ++++++++++++++++++ crates/erp-auth/src/handler/user_handler.rs | 39 +++- crates/erp-auth/src/module.rs | 4 + crates/erp-auth/src/service/user_service.rs | 64 ++++++ crates/erp-server/src/handlers/audit_log.rs | 26 ++- .../erp-workflow/src/service/task_service.rs | 7 +- 11 files changed, 567 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/api/auditLogs.ts create mode 100644 apps/web/src/api/languages.ts create mode 100644 apps/web/src/pages/settings/AuditLogViewer.tsx create mode 100644 apps/web/src/pages/settings/LanguageManager.tsx diff --git a/apps/web/src/api/auditLogs.ts b/apps/web/src/api/auditLogs.ts new file mode 100644 index 0000000..56bd945 --- /dev/null +++ b/apps/web/src/api/auditLogs.ts @@ -0,0 +1,31 @@ +import client from './client'; +import type { PaginatedResponse } from './users'; + +export interface AuditLogItem { + id: string; + tenant_id: string; + action: string; + resource_type: string; + resource_id: string; + user_id: string; + old_value?: string; + new_value?: string; + ip_address?: string; + user_agent?: string; + created_at: string; +} + +export interface AuditLogQuery { + resource_type?: string; + user_id?: string; + page?: number; + page_size?: number; +} + +export async function listAuditLogs(query: AuditLogQuery = {}) { + const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>( + '/audit-logs', + { params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } }, + ); + return data.data; +} diff --git a/apps/web/src/api/languages.ts b/apps/web/src/api/languages.ts new file mode 100644 index 0000000..c3c6663 --- /dev/null +++ b/apps/web/src/api/languages.ts @@ -0,0 +1,36 @@ +import client from './client'; + +// --- Types --- + +export interface LanguageInfo { + code: string; + name: string; + enabled: boolean; + translations?: Record; +} + +export interface UpdateLanguageRequest { + name?: string; + enabled?: boolean; + translations?: Record; +} + +// --- API Functions --- + +export async function listLanguages(): Promise { + const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>( + '/config/languages', + ); + return data.data; +} + +export async function updateLanguage( + code: string, + req: UpdateLanguageRequest, +): Promise { + const { data } = await client.put<{ success: boolean; data: LanguageInfo }>( + `/config/languages/${code}`, + req, + ); + return data.data; +} diff --git a/apps/web/src/api/messages.ts b/apps/web/src/api/messages.ts index 5c22e94..b92dd23 100644 --- a/apps/web/src/api/messages.ts +++ b/apps/web/src/api/messages.ts @@ -81,7 +81,7 @@ export async function deleteMessage(id: string) { export async function sendMessage(req: SendMessageRequest) { const { data } = await client.post<{ success: boolean; data: MessageInfo }>( - '/messages/send', + '/messages', req, ); return data.data; diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index ed3c27e..719f283 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -1,17 +1,21 @@ import { Tabs } from 'antd'; import DictionaryManager from './settings/DictionaryManager'; +import LanguageManager from './settings/LanguageManager'; import MenuConfig from './settings/MenuConfig'; import NumberingRules from './settings/NumberingRules'; import SystemSettings from './settings/SystemSettings'; import ThemeSettings from './settings/ThemeSettings'; +import AuditLogViewer from './settings/AuditLogViewer'; const Settings: React.FC = () => { const items = [ { key: 'dictionaries', label: '数据字典', children: }, + { key: 'languages', label: '语言管理', children: }, { key: 'menus', label: '菜单配置', children: }, { key: 'numbering', label: '编号规则', children: }, { key: 'settings', label: '系统参数', children: }, { key: 'theme', label: '主题设置', children: }, + { key: 'audit-log', label: '审计日志', children: }, ]; return ; diff --git a/apps/web/src/pages/settings/AuditLogViewer.tsx b/apps/web/src/pages/settings/AuditLogViewer.tsx new file mode 100644 index 0000000..9dd9397 --- /dev/null +++ b/apps/web/src/pages/settings/AuditLogViewer.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Table, Select, Input, Space, Card, Typography, Tag, message } from 'antd'; +import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; +import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs'; + +const RESOURCE_TYPE_OPTIONS = [ + { value: 'user', label: '用户' }, + { value: 'role', label: '角色' }, + { value: 'organization', label: '组织' }, + { value: 'department', label: '部门' }, + { value: 'position', label: '岗位' }, + { value: 'process_instance', label: '流程实例' }, + { value: 'dictionary', label: '字典' }, + { value: 'menu', label: '菜单' }, + { value: 'setting', label: '设置' }, + { value: 'numbering_rule', label: '编号规则' }, +]; + +const ACTION_COLOR_MAP: Record = { + create: 'green', + update: 'blue', + delete: 'red', +}; + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +export default function AuditLogViewer() { + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState({ page: 1, page_size: 20 }); + + const fetchLogs = useCallback(async (params: AuditLogQuery) => { + setLoading(true); + try { + const result = await listAuditLogs(params); + setLogs(result.data); + setTotal(result.total); + } catch { + message.error('加载审计日志失败'); + } + setLoading(false); + }, []); + + useEffect(() => { + fetchLogs(query); + }, [query, fetchLogs]); + + const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => { + setQuery((prev) => ({ + ...prev, + [field]: value || undefined, + page: 1, + })); + }; + + const handleTableChange = (pagination: TablePaginationConfig) => { + setQuery((prev) => ({ + ...prev, + page: pagination.current, + page_size: pagination.pageSize, + })); + }; + + const columns: ColumnsType = [ + { + title: '操作', + dataIndex: 'action', + key: 'action', + width: 120, + render: (action: string) => ( + {action} + ), + }, + { + title: '资源类型', + dataIndex: 'resource_type', + key: 'resource_type', + width: 140, + }, + { + title: '资源 ID', + dataIndex: 'resource_id', + key: 'resource_id', + width: 200, + ellipsis: true, + }, + { + title: '操作用户', + dataIndex: 'user_id', + key: 'user_id', + width: 200, + ellipsis: true, + }, + { + title: '时间', + dataIndex: 'created_at', + key: 'created_at', + width: 200, + render: (value: string) => formatDateTime(value), + }, + ]; + + return ( +
+ + 审计日志 + + + + + handleFilterChange('user_id', e.target.value)} + /> + + + + `共 ${t} 条`, + }} + scroll={{ x: 900 }} + /> + + ); +} diff --git a/apps/web/src/pages/settings/LanguageManager.tsx b/apps/web/src/pages/settings/LanguageManager.tsx new file mode 100644 index 0000000..fb693bf --- /dev/null +++ b/apps/web/src/pages/settings/LanguageManager.tsx @@ -0,0 +1,210 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Table, + Switch, + Modal, + Form, + Input, + Button, + Space, + Typography, + message, + Card, +} from 'antd'; +import { EditOutlined } from '@ant-design/icons'; +import { + listLanguages, + updateLanguage, + type LanguageInfo, +} from '../../api/languages'; + +// --- Component --- + +export default function LanguageManager() { + const [languages, setLanguages] = useState([]); + const [loading, setLoading] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingLang, setEditingLang] = useState(null); + const [editForm] = Form.useForm(); + + const fetchLanguages = useCallback(async () => { + setLoading(true); + try { + const result = await listLanguages(); + setLanguages(result); + } catch { + message.error('加载语言列表失败'); + } + setLoading(false); + }, []); + + useEffect(() => { + fetchLanguages(); + }, [fetchLanguages]); + + // --- Enable / Disable Toggle --- + + const handleToggle = async (record: LanguageInfo, enabled: boolean) => { + try { + await updateLanguage(record.code, { enabled }); + setLanguages((prev) => + prev.map((lang) => + lang.code === record.code ? { ...lang, enabled } : lang, + ), + ); + message.success(enabled ? '已启用' : '已禁用'); + } catch { + message.error('操作失败'); + } + }; + + // --- Edit Modal --- + + const openEdit = (lang: LanguageInfo) => { + setEditingLang(lang); + editForm.setFieldsValue({ + name: lang.name, + translations: lang.translations + ? Object.entries(lang.translations) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + : '', + }); + setEditModalOpen(true); + }; + + const closeEdit = () => { + setEditModalOpen(false); + setEditingLang(null); + editForm.resetFields(); + }; + + const handleEditSubmit = async (values: { name: string; translations: string }) => { + if (!editingLang) return; + + const translations: Record = {}; + if (values.translations?.trim()) { + for (const line of values.translations.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + const key = trimmed.slice(0, eqIndex).trim(); + const val = trimmed.slice(eqIndex + 1).trim(); + if (key) { + translations[key] = val; + } + } + } + + try { + const updated = await updateLanguage(editingLang.code, { + name: values.name, + translations, + }); + setLanguages((prev) => + prev.map((lang) => + lang.code === editingLang.code ? updated : lang, + ), + ); + message.success('语言更新成功'); + closeEdit(); + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || '更新失败'; + message.error(errorMsg); + } + }; + + // --- Columns --- + + const columns = [ + { + title: '语言代码', + dataIndex: 'code', + key: 'code', + width: 160, + }, + { + title: '语言名称', + dataIndex: 'name', + key: 'name', + width: 200, + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + width: 120, + render: (enabled: boolean, record: LanguageInfo) => ( + handleToggle(record, checked)} /> + ), + }, + { + title: '翻译条目数', + key: 'translationCount', + width: 140, + render: (_: unknown, record: LanguageInfo) => + record.translations ? Object.keys(record.translations).length : 0, + }, + { + title: '操作', + key: 'actions', + render: (_: unknown, record: LanguageInfo) => ( + + + + ), + }, + ]; + + return ( +
+ + 语言管理 + + + +
+ + + {/* Edit Modal */} + editForm.submit()} + > +
+ + + + + + + +
+ + ); +} diff --git a/crates/erp-auth/src/handler/user_handler.rs b/crates/erp-auth/src/handler/user_handler.rs index 1948f22..6c40af3 100644 --- a/crates/erp-auth/src/handler/user_handler.rs +++ b/crates/erp-auth/src/handler/user_handler.rs @@ -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, +} + +/// Assign roles response. +#[derive(Debug, Serialize)] +pub struct AssignRolesResp { + pub roles: Vec, +} + +/// 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + 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 }))) +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 55d59df..6a62a16 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -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), diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index 97d997a..3eaead6 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -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> { + // 验证用户存在 + 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, diff --git a/crates/erp-server/src/handlers/audit_log.rs b/crates/erp-server/src/handlers/audit_log.rs index 6263b30..396a0cd 100644 --- a/crates/erp-server/src/handlers/audit_log.rs +++ b/crates/erp-server/src/handlers/audit_log.rs @@ -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, +/// 租户隔离通过 JWT 中间件注入的 TenantContext 实现。 +pub async fn list_audit_logs( + State(db): State, + Extension(ctx): Extension, Query(params): Query, -) -> Result, AppError> { +) -> Result, AppError> +where + sea_orm::DatabaseConnection: FromRef, + 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 { +pub fn audit_log_router() -> Router +where + sea_orm::DatabaseConnection: FromRef, + S: Clone + Send + Sync + 'static, +{ Router::new().route("/audit-logs", get(list_audit_logs)) } diff --git a/crates/erp-workflow/src/service/task_service.rs b/crates/erp-workflow/src/service/task_service.rs index 357657d..e8615f8 100644 --- a/crates/erp-workflow/src/service/task_service.rs +++ b/crates/erp-workflow/src/service/task_service.rs @@ -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(