feat: Phase 3 - AES encryption, SQL audit engine, audit logs (85 tests)

Phase 3.1: Instance password AES-256-GCM encryption
Phase 3.2: SQL audit rule engine (10 rules)
Phase 3.3: Auto-audit on ticket creation
Phase 3.4: Operation audit logs + GET /audit/logs
Tests: 44 -> 85 (41 new), Frontend: AuditLogsPage
This commit is contained in:
logan committed 2026-05-22 13:08:55 +08:00
1 parent 27989274f5
commit 66b09e4d85
16 files changed
+1146 -6

No files matched your search

+2
View File
@@ -15,6 +15,7 @@ import WorkflowPage from '@/pages/WorkflowPage';
import AutoTaskPage from '@/pages/AutoTaskPage';
import AnnouncementPage from '@/pages/AnnouncementPage';
import ConfigPlatformPage from '@/pages/ConfigPlatformPage';
import AuditLogsPage from '@/pages/AuditLogsPage';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() {
@@ -60,6 +61,7 @@ function AppRoutes() {
<Route path="users" element={<UsersPage />} />
<Route path="data-permission" element={<DataPermissionPage />} />
<Route path="audit-rules" element={<AuditRulesPage />} />
<Route path="audit-logs" element={<AuditLogsPage />} />
<Route path="workflow" element={<WorkflowPage />} />
<Route path="auto-task" element={<AutoTaskPage />} />
<Route path="announcement" element={<AnnouncementPage />} />
+1
View File
@@ -46,6 +46,7 @@ const routePermissionMap: Record<string, UserRole[]> = {
users: ['admin'],
'data-permission': ['admin', 'dba'],
'audit-rules': ['admin', 'dba'],
'audit-logs': ['admin', 'dba'],
workflow: ['admin', 'dba'],
'auto-task': ['admin'],
announcement: ['admin'],
+3 -2
View File
@@ -32,6 +32,7 @@ export const menuItems = [
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
{ id: 'audit-rules', label: '审核规则', icon: 'Shield', section: 'system', roles: ['admin', 'dba'] },
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'] },
@@ -165,8 +166,8 @@ export const notificationChannels: NotificationChannel[] = [
];
export const roles: Role[] = [
{ id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'data-permission', 'audit-rules', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] },
{ id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'data-permission', 'audit-rules', 'workflow'] },
{ id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'data-permission', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] },
{ id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'data-permission', 'audit-rules', 'audit-logs', 'workflow'] },
{ id: 'developer', name: '开发人员', description: '可以提交变更工单和执行数据查询', icon: 'Code', iconClass: 'text-green-400 bg-green-400/10', userCount: 28, permissionCount: 12, permissions: ['dashboard', 'tickets', 'queries'] },
{ id: 'guest', name: '访客', description: '仅可查看,无法执行任何操作', icon: 'Eye', iconClass: 'text-gray-400 bg-gray-400/10', userCount: 8, permissionCount: 4, permissions: ['dashboard', 'queries'] },
];
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import request from '../api/request';
interface AuditLog {
id: number;
user_id: number;
action: string;
target_type: string;
target_id: number | null;
detail: string | null;
ip_address: string | null;
created_at: string | null;
}
interface AuditLogResponse {
total: number;
page: number;
page_size: number;
items: AuditLog[];
}
const ACTION_COLORS: Record<string, string> = {
CREATE_TICKET: 'bg-blue-100 text-blue-800',
REVIEW_TICKET: 'bg-yellow-100 text-yellow-800',
EXECUTE_TICKET: 'bg-green-100 text-green-800',
CREATE_INSTANCE: 'bg-purple-100 text-purple-800',
DELETE_INSTANCE: 'bg-red-100 text-red-800',
CREATE_USER: 'bg-indigo-100 text-indigo-800',
DELETE_USER: 'bg-red-100 text-red-800',
QUERY: 'bg-cyan-100 text-cyan-800',
};
const TARGET_ICONS: Record<string, string> = {
ticket: '📋',
instance: '🗄️',
user: '👤',
query: '🔍',
};
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [filterAction, setFilterAction] = useState('');
const pageSize = 20;
useEffect(() => {
fetchLogs();
}, [page, filterAction]);
const fetchLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filterAction) params.set('action', filterAction);
const res = await request.get(`/audit/logs?${params}`);
const data = res.data as AuditLogResponse;
setLogs(data.items || []);
setTotal(data.total);
} catch (e) {
console.error('获取审计日志失败', e);
} finally {
setLoading(false);
}
};
const totalPages = Math.ceil(total / pageSize);
const formatDate = (d: string | null) => {
if (!d) return '-';
return new Date(d).toLocaleString('zh-CN');
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800">审计日志</h2>
<span className="text-sm text-gray-500">共 {total} 条记录</span>
</div>
{/* Filters */}
<div className="flex gap-2">
{['', 'CREATE_TICKET', 'REVIEW_TICKET', 'EXECUTE_TICKET', 'CREATE_INSTANCE', 'DELETE_INSTANCE', 'QUERY'].map(action => (
<button
key={action}
onClick={() => { setFilterAction(action); setPage(1); }}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filterAction === action
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{action || '全部'}
</button>
))}
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-gray-600">
<th className="px-4 py-3 text-left font-medium">操作</th>
<th className="px-4 py-3 text-left font-medium">目标</th>
<th className="px-4 py-3 text-left font-medium">详情</th>
<th className="px-4 py-3 text-left font-medium">时间</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">加载中...</td></tr>
) : logs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">暂无审计日志</td></tr>
) : (
logs.map(log => (
<tr key={log.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-gray-100 text-gray-600'}`}>
{log.action}
</span>
</td>
<td className="px-4 py-3">
<span className="mr-1">{TARGET_ICONS[log.target_type] || '📌'}</span>
<span className="text-gray-600">{log.target_type}</span>
{log.target_id && <span className="text-gray-400 ml-1">#{log.target_id}</span>}
</td>
<td className="px-4 py-3 text-gray-500 max-w-md truncate">{log.detail || '-'}</td>
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{formatDate(log.created_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 rounded bg-gray-100 text-gray-600 disabled:opacity-50"
>
上一页
</button>
<span className="px-3 py-1 text-gray-500">{page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 rounded bg-gray-100 text-gray-600 disabled:opacity-50"
>
下一页
</button>
</div>
)}
</div>
);
}