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:
31
apps/web/src/api/auditLogs.ts
Normal file
31
apps/web/src/api/auditLogs.ts
Normal file
@@ -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<AuditLogItem> }>(
|
||||||
|
'/audit-logs',
|
||||||
|
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
36
apps/web/src/api/languages.ts
Normal file
36
apps/web/src/api/languages.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface LanguageInfo {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
translations?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLanguageRequest {
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
translations?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
|
||||||
|
export async function listLanguages(): Promise<LanguageInfo[]> {
|
||||||
|
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
|
||||||
|
'/config/languages',
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLanguage(
|
||||||
|
code: string,
|
||||||
|
req: UpdateLanguageRequest,
|
||||||
|
): Promise<LanguageInfo> {
|
||||||
|
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
|
||||||
|
`/config/languages/${code}`,
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ export async function deleteMessage(id: string) {
|
|||||||
|
|
||||||
export async function sendMessage(req: SendMessageRequest) {
|
export async function sendMessage(req: SendMessageRequest) {
|
||||||
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
|
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
|
||||||
'/messages/send',
|
'/messages',
|
||||||
req,
|
req,
|
||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import DictionaryManager from './settings/DictionaryManager';
|
import DictionaryManager from './settings/DictionaryManager';
|
||||||
|
import LanguageManager from './settings/LanguageManager';
|
||||||
import MenuConfig from './settings/MenuConfig';
|
import MenuConfig from './settings/MenuConfig';
|
||||||
import NumberingRules from './settings/NumberingRules';
|
import NumberingRules from './settings/NumberingRules';
|
||||||
import SystemSettings from './settings/SystemSettings';
|
import SystemSettings from './settings/SystemSettings';
|
||||||
import ThemeSettings from './settings/ThemeSettings';
|
import ThemeSettings from './settings/ThemeSettings';
|
||||||
|
import AuditLogViewer from './settings/AuditLogViewer';
|
||||||
|
|
||||||
const Settings: React.FC = () => {
|
const Settings: React.FC = () => {
|
||||||
const items = [
|
const items = [
|
||||||
{ key: 'dictionaries', label: '数据字典', children: <DictionaryManager /> },
|
{ key: 'dictionaries', label: '数据字典', children: <DictionaryManager /> },
|
||||||
|
{ key: 'languages', label: '语言管理', children: <LanguageManager /> },
|
||||||
{ key: 'menus', label: '菜单配置', children: <MenuConfig /> },
|
{ key: 'menus', label: '菜单配置', children: <MenuConfig /> },
|
||||||
{ key: 'numbering', label: '编号规则', children: <NumberingRules /> },
|
{ key: 'numbering', label: '编号规则', children: <NumberingRules /> },
|
||||||
{ key: 'settings', label: '系统参数', children: <SystemSettings /> },
|
{ key: 'settings', label: '系统参数', children: <SystemSettings /> },
|
||||||
{ key: 'theme', label: '主题设置', children: <ThemeSettings /> },
|
{ key: 'theme', label: '主题设置', children: <ThemeSettings /> },
|
||||||
|
{ key: 'audit-log', label: '审计日志', children: <AuditLogViewer /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return <Tabs defaultActiveKey="dictionaries" items={items} />;
|
return <Tabs defaultActiveKey="dictionaries" items={items} />;
|
||||||
|
|||||||
156
apps/web/src/pages/settings/AuditLogViewer.tsx
Normal file
156
apps/web/src/pages/settings/AuditLogViewer.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<AuditLogItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState<AuditLogQuery>({ 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<AuditLogItem> = [
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 120,
|
||||||
|
render: (action: string) => (
|
||||||
|
<Tag color={ACTION_COLOR_MAP[action] ?? 'default'}>{action}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
||||||
|
审计日志
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="资源类型"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={RESOURCE_TYPE_OPTIONS}
|
||||||
|
value={query.resource_type}
|
||||||
|
onChange={(value) => handleFilterChange('resource_type', value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="操作用户 ID"
|
||||||
|
style={{ width: 240 }}
|
||||||
|
value={query.user_id ?? ''}
|
||||||
|
onChange={(e) => handleFilterChange('user_id', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={logs}
|
||||||
|
loading={loading}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
current: query.page,
|
||||||
|
pageSize: query.page_size,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
apps/web/src/pages/settings/LanguageManager.tsx
Normal file
210
apps/web/src/pages/settings/LanguageManager.tsx
Normal file
@@ -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<LanguageInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(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<string, string> = {};
|
||||||
|
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) => (
|
||||||
|
<Switch checked={enabled} onChange={(checked) => 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) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
||||||
|
语言管理
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={languages}
|
||||||
|
rowKey="code"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`编辑语言 - ${editingLang?.name ?? ''}`}
|
||||||
|
open={editModalOpen}
|
||||||
|
onCancel={closeEdit}
|
||||||
|
onOk={() => editForm.submit()}
|
||||||
|
>
|
||||||
|
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="语言名称"
|
||||||
|
rules={[{ required: true, message: '请输入语言名称' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="translations"
|
||||||
|
label="翻译内容"
|
||||||
|
extra="每行一条,格式:key=value"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={10} placeholder={'common.save=保存\ncommon.cancel=取消'} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::Extension;
|
use axum::Extension;
|
||||||
use axum::extract::{FromRef, Path, Query, State};
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use erp_core::error::AppError;
|
use erp_core::error::AppError;
|
||||||
@@ -9,7 +9,7 @@ use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth_state::AuthState;
|
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 erp_core::rbac::require_permission;
|
||||||
use crate::service::user_service::UserService;
|
use crate::service::user_service::UserService;
|
||||||
|
|
||||||
@@ -151,3 +151,38 @@ where
|
|||||||
message: Some("用户已删除".to_string()),
|
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 })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ impl AuthModule {
|
|||||||
.put(user_handler::update_user)
|
.put(user_handler::update_user)
|
||||||
.delete(user_handler::delete_user),
|
.delete(user_handler::delete_user),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/roles",
|
||||||
|
axum::routing::post(user_handler::assign_roles),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/roles",
|
"/roles",
|
||||||
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
|
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
|
||||||
|
|||||||
@@ -277,6 +277,70 @@ impl UserService {
|
|||||||
Ok(())
|
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.
|
/// Fetch RoleResp DTOs for a given user within a tenant.
|
||||||
async fn fetch_user_role_resps(
|
async fn fetch_user_role_resps(
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Extension, FromRef, Query, State};
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
use erp_core::entity::audit_log;
|
use erp_core::entity::audit_log;
|
||||||
use erp_core::error::AppError;
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::types::TenantContext;
|
||||||
|
|
||||||
/// 审计日志查询参数。
|
/// 审计日志查询参数。
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -30,13 +30,19 @@ pub struct AuditLogResponse {
|
|||||||
/// GET /audit-logs
|
/// GET /audit-logs
|
||||||
///
|
///
|
||||||
/// 分页查询审计日志,支持按 resource_type 和 user_id 过滤。
|
/// 分页查询审计日志,支持按 resource_type 和 user_id 过滤。
|
||||||
pub async fn list_audit_logs(
|
/// 租户隔离通过 JWT 中间件注入的 TenantContext 实现。
|
||||||
State(state): State<AppState>,
|
pub async fn list_audit_logs<S>(
|
||||||
|
State(db): State<sea_orm::DatabaseConnection>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Query(params): Query<AuditLogQuery>,
|
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 = params.page.unwrap_or(1).max(1);
|
||||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
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()
|
let mut q = audit_log::Entity::find()
|
||||||
.filter(audit_log::Column::TenantId.eq(tenant_id));
|
.filter(audit_log::Column::TenantId.eq(tenant_id));
|
||||||
@@ -50,7 +56,7 @@ pub async fn list_audit_logs(
|
|||||||
|
|
||||||
let paginator = q
|
let paginator = q
|
||||||
.order_by_desc(audit_log::Column::CreatedAt)
|
.order_by_desc(audit_log::Column::CreatedAt)
|
||||||
.paginate(&state.db, page_size);
|
.paginate(&db, page_size);
|
||||||
|
|
||||||
let total = paginator
|
let total = paginator
|
||||||
.num_items()
|
.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))
|
Router::new().route("/audit-logs", get(list_audit_logs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,12 @@ impl TaskService {
|
|||||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
"task.completed",
|
"task.completed",
|
||||||
tenant_id,
|
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;
|
), db).await;
|
||||||
|
|
||||||
audit_service::record(
|
audit_service::record(
|
||||||
|
|||||||
Reference in New Issue
Block a user