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

+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>
);
}