feat: P0生产加固 — 认证安全+操作审计+错误提示

P0-1 认证安全:
- JWT secret 改为环境变量配置, 未设置则随机生成(重启失效)
- 新增 LoginRateLimitMiddleware: 5次/分钟登录频率限制(滑动窗口)
- 429 响应 + 前端 toast 提示

P0-2 操作审计日志:
- 新增 OperationLog 模型 (user/username/action/resource/detail/ip)
- 新增 AuditLogMiddleware 自动记录 POST/PUT/DELETE/PATCH 操作
- 新增 GET /audit-logs API (分页/过滤/统计)
- 前端 AuditLogsPage 对接真实 API (字段/路径修正)

P0-3 实例密码加密:
- 已确认 crypto.py AES-256-GCM 加密存储, 无需修改

P1 前端错误提示:
- 新增 useToast hook + ToastContainer 组件
- request.ts 拦截器: 4xx/5xx 错误 toast 显示后端 detail 信息
- 422 验证错误提取第一条 msg
- 网络失败显示通用提示

1182 tests passed
This commit is contained in:
logan committed 2026-05-25 12:57:59 +08:00
1 parent 0576b253e4
commit 344c519209
15 files changed
+510 -97

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
"""操作审计日志 API。"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.operation_log import OperationLog
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
@router.get("")
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
username: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""查询操作审计日志(仅管理员)。"""
query = select(OperationLog)
count_query = select(func.count(OperationLog.id))
if action:
query = query.where(OperationLog.action == action)
count_query = count_query.where(OperationLog.action == action)
if resource_type:
query = query.where(OperationLog.resource_type == resource_type)
count_query = count_query.where(OperationLog.resource_type == resource_type)
if username:
query = query.where(OperationLog.username.contains(username))
count_query = count_query.where(OperationLog.username.contains(username))
total = (await db.execute(count_query)).scalar() or 0
query = query.order_by(desc(OperationLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
logs = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": l.id,
"user_id": l.user_id,
"username": l.username,
"action": l.action,
"resource_type": l.resource_type,
"resource_id": l.resource_id,
"detail": l.detail,
"ip_address": l.ip_address,
"created_at": l.created_at.isoformat() if l.created_at else None,
}
for l in logs
],
}
@router.get("/stats")
async def audit_log_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""审计日志统计概览。"""
# 最近7天操作分布
from datetime import datetime, timedelta, timezone
since = datetime.now(timezone.utc) - timedelta(days=7)
result = await db.execute(
select(OperationLog.action, func.count(OperationLog.id))
.where(OperationLog.created_at >= since)
.group_by(OperationLog.action)
)
action_stats = {row[0]: row[1] for row in result.all()}
# 操作最多的用户
result = await db.execute(
select(OperationLog.username, func.count(OperationLog.id))
.where(OperationLog.created_at >= since)
.group_by(OperationLog.username)
.order_by(desc(func.count(OperationLog.id)))
.limit(5)
)
top_users = [{"username": row[0], "count": row[1]} for row in result.all()]
return {"action_stats": action_stats, "top_users": top_users}
+12 -2
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,11 +12,16 @@ router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == req.username)) result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.hashed_password): if not user or not verify_password(req.password, user.hashed_password):
# 记录登录失败
from app.services.audit_logger import log_operation
await log_operation(db, action="login_failed", username=req.username,
detail=f"登录失败: 用户名或密码错误", request=request)
await db.commit()
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active: if not user.is_active:
@@ -26,6 +31,11 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=403, detail="访客账户已禁用,请联系管理员") raise HTTPException(status_code=403, detail="访客账户已禁用,请联系管理员")
user.last_login = datetime.now(timezone.utc) user.last_login = datetime.now(timezone.utc)
# 记录登录成功
from app.services.audit_logger import log_operation
await log_operation(db, action="login", user_id=user.id, username=user.username,
detail=f"登录成功", request=request)
await db.commit() await db.commit()
token = create_access_token(data={"sub": str(user.id), "role": user.role}) token = create_access_token(data={"sub": str(user.id), "role": user.role})
+2 -1
View File
@@ -7,7 +7,7 @@ from app.api.v1.endpoints import (
instance_group, masking_rule, rollback, instance_group, masking_rule, rollback,
workflow, impact, schema_drift, pool, workflow, impact, schema_drift, pool,
mongo, query_history, sql_assistant, db_types, mongo, query_history, sql_assistant, db_types,
settings, scheduled_task, settings, scheduled_task, audit_log,
) )
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
@@ -41,3 +41,4 @@ api_router.include_router(sql_assistant.router)
api_router.include_router(db_types.router) api_router.include_router(db_types.router)
api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"]) api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"])
api_router.include_router(scheduled_task.router) api_router.include_router(scheduled_task.router)
api_router.include_router(audit_log.router)
+129
View File
@@ -0,0 +1,129 @@
"""操作审计中间件 — 自动记录所有 POST/PUT/DELETE/PATCH 请求。"""
import time
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.database import async_session
from app.models.operation_log import OperationLog
# 不记录审计的路径(高频/非业务)
_EXCLUDE_PREFIXES = (
"/api/v1/audit-logs",
"/api/v1/dashboard",
"/docs",
"/openapi",
"/redoc",
"/api/v1/query/execute", # 查询执行单独记录
)
# 资源类型映射: URL前缀 -> 资源类型
_RESOURCE_MAP = {
"/api/v1/tickets": "ticket",
"/api/v1/instances": "instance",
"/api/v1/users": "user",
"/api/v1/audit-rules": "audit_rule",
"/api/v1/notifications": "notification",
"/api/v1/auth": "auth",
"/api/v1/workflows": "workflow",
"/api/v1/settings": "system_setting",
}
# action 映射
_ACTION_MAP = {
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
}
class AuditLogMiddleware(BaseHTTPMiddleware):
"""自动审计中间件 — 记录所有写操作。"""
async def dispatch(self, request: Request, call_next):
# 只审计写操作
if request.method not in ("POST", "PUT", "PATCH", "DELETE"):
return await call_next(request)
path = request.url.path
# 跳过排除列表
if any(path.startswith(p) for p in _EXCLUDE_PREFIXES):
return await call_next(request)
# 特殊: POST /auth/login 在 auth.py 中单独记录
if path == "/api/v1/auth/login" or path == "/api/v1/auth/logout":
return await call_next(request)
response = await call_next(request)
# 异步记录审计日志(不阻塞响应)
try:
await self._record(request, response.status_code)
except Exception:
pass
return response
@staticmethod
async def _record(request: Request, status_code: int):
path = request.url.path
method = request.method
# 确定 action
action = _ACTION_MAP.get(method, method.lower())
if path.endswith("/review"):
action = "approve" if status_code < 300 else "reject"
elif path.endswith("/execute"):
action = "execute"
elif path.endswith("/cancel"):
action = "cancel"
# 确定资源类型
resource_type = "other"
for prefix, rtype in _RESOURCE_MAP.items():
if path.startswith(prefix):
resource_type = rtype
break
# 提取 resource_id
resource_id = None
parts = path.rstrip("/").split("/")
for p in parts:
if p.isdigit():
resource_id = int(p)
break
# 获取操作者信息
user_id = None
username = None
try:
from app.core.security import decode_token
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
payload = decode_token(auth_header[7:])
user_id = int(payload.get("sub", 0))
username = payload.get("role", "unknown")
except Exception:
pass
# IP
forwarded = request.headers.get("x-forwarded-for")
ip_address = forwarded.split(",")[0].strip() if forwarded else (
request.client.host if request.client else None
)
# 写入DB
async with async_session() as db:
log = OperationLog(
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=f"{method} {path} -> {status_code}",
ip_address=ip_address,
)
db.add(log)
await db.commit()
+17 -3
View File
@@ -2,12 +2,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache from functools import lru_cache
import secrets
class Settings(BaseSettings): class Settings(BaseSettings):
ENV: str = "development" ENV: str = "development"
DEBUG: bool = True DEBUG: bool = True
SECRET_KEY: str = "dev-secret-key-change-in-production" # JWT secret: 生产环境必须通过环境变量设置
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # 若未设置则每次启动随机生成(重启后旧 token 失效)
SECRET_KEY: str = ""
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8小时
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
DATABASE_URL: str = "sqlite+aiosqlite:///./sqlite3.db" DATABASE_URL: str = "sqlite+aiosqlite:///./sqlite3.db"
@@ -18,9 +23,18 @@ class Settings(BaseSettings):
# CORS: production comma-separated origins (e.g. "https://sql.example.com,https://sql2.example.com") # CORS: production comma-separated origins (e.g. "https://sql.example.com,https://sql2.example.com")
CORS_ORIGINS: str = "" CORS_ORIGINS: str = ""
# 登录频率限制: 每IP每分钟最多尝试次数
LOGIN_RATE_LIMIT: int = 10
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@lru_cache() @lru_cache()
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() s = Settings()
if not s.SECRET_KEY:
s.SECRET_KEY = secrets.token_urlsafe(32)
if s.ENV == "development":
import logging
logging.getLogger(__name__).info("SECRET_KEY 未设置,已自动生成(重启后失效)")
return s
+50
View File
@@ -0,0 +1,50 @@
"""登录频率限制 — 基于IP的滑动窗口计数器。"""
import time
from collections import defaultdict
from typing import Dict, List, Tuple
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import get_settings
class LoginRateLimitMiddleware(BaseHTTPMiddleware):
"""限制每IP每分钟的登录尝试次数。"""
def __init__(self, app, **kwargs):
super().__init__(app, **kwargs)
self._attempts: Dict[str, List[float]] = defaultdict(list)
async def dispatch(self, request: Request, call_next):
if request.url.path == "/api/v1/auth/login" and request.method == "POST":
client_ip = self._get_client_ip(request)
settings = get_settings()
max_attempts = settings.LOGIN_RATE_LIMIT
now = time.time()
# 清理60秒前的记录
self._attempts[client_ip] = [
t for t in self._attempts[client_ip] if now - t < 60
]
if len(self._attempts[client_ip]) >= max_attempts:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录尝试过于频繁,请稍后再试(限制: {max_attempts}次/分钟)",
)
response = await call_next(request)
# 只在登录失败时记录(成功不计入限制)
if response.status_code != 200:
self._attempts[client_ip].append(now)
return response
return await call_next(request)
@staticmethod
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+4
View File
@@ -7,6 +7,8 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from app.core.config import get_settings from app.core.config import get_settings
from app.core.rate_limit import LoginRateLimitMiddleware
from app.core.audit_middleware import AuditLogMiddleware
from app.core.database import init_db, async_session from app.core.database import init_db, async_session
from app.core.security import hash_password from app.core.security import hash_password
from app.models.user import User from app.models.user import User
@@ -124,6 +126,8 @@ app = FastAPI(
_cors_origins = ["*"] if settings.DEBUG else [ _cors_origins = ["*"] if settings.DEBUG else [
o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip() o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()
] ]
app.add_middleware(AuditLogMiddleware)
app.add_middleware(LoginRateLimitMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=_cors_origins, allow_origins=_cors_origins,
+1
View File
@@ -2,3 +2,4 @@ from app.models.instance_group import InstanceGroup, user_groups, instance_group
from app.models.system_setting import SystemSetting # noqa: F401 from app.models.system_setting import SystemSetting # noqa: F401
from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401 from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401
from app.models.ticket_sql_item import TicketSqlItem # noqa: F401 from app.models.ticket_sql_item import TicketSqlItem # noqa: F401
from app.models.operation_log import OperationLog # noqa: F401
+27
View File
@@ -0,0 +1,27 @@
"""操作审计日志模型。"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Index
from sqlalchemy.sql import func
from app.core.database import Base
class OperationLog(Base):
__tablename__ = "operation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=True, comment="操作者ID(系统操作为空)")
username = Column(String(50), nullable=True, comment="操作者用户名")
action = Column(String(50), nullable=False, comment="操作类型: login/create/update/delete/execute/approve/reject")
resource_type = Column(String(50), nullable=True, comment="资源类型: ticket/instance/user/audit_rule/notification/workflow")
resource_id = Column(Integer, nullable=True, comment="资源ID")
detail = Column(Text, nullable=True, comment="操作详情")
ip_address = Column(String(50), nullable=True, comment="操作者IP")
user_agent = Column(String(255), nullable=True, comment="浏览器UA")
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_oplog_user_id", "user_id"),
Index("ix_oplog_action", "action"),
Index("ix_oplog_resource", "resource_type", "resource_id"),
Index("ix_oplog_created_at", "created_at"),
)
+45
View File
@@ -0,0 +1,45 @@
"""操作审计日志服务 — 轻量级,各 endpoint 直接调用。"""
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Request
from app.models.operation_log import OperationLog
async def log_operation(
db: AsyncSession,
*,
action: str,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
detail: Optional[str] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
request: Optional[Request] = None,
) -> None:
"""记录操作日志(fire-and-forget,不抛异常)。"""
try:
ip_address = None
user_agent = None
if request:
forwarded = request.headers.get("x-forwarded-for")
ip_address = forwarded.split(",")[0].strip() if forwarded else (
request.client.host if request.client else None
)
user_agent = request.headers.get("user-agent", "")[:255]
log = OperationLog(
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=detail,
ip_address=ip_address,
user_agent=user_agent,
)
db.add(log)
await db.flush()
except Exception:
# 审计日志不应影响业务流程
pass
+2
View File
@@ -21,6 +21,7 @@ import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
import TicketCreatePage from '@/pages/TicketCreatePage'; import TicketCreatePage from '@/pages/TicketCreatePage';
import TicketDetailPage from '@/pages/TicketDetailPage'; import TicketDetailPage from '@/pages/TicketDetailPage';
import InstanceGroupPage from '@/pages/InstanceGroupPage'; import InstanceGroupPage from '@/pages/InstanceGroupPage';
import { ToastContainer } from '@/components/ToastContainer';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘 // 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() { function RouteGuard() {
@@ -86,6 +87,7 @@ function App() {
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<AppRoutes /> <AppRoutes />
<ToastContainer />
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</BrowserRouter> </BrowserRouter>
+15 -18
View File
@@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { toast } from '@/hooks/useToast';
const request = axios.create({ const request = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
@@ -10,40 +11,36 @@ request.interceptors.request.use((config) => {
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
// Debug logging
console.log(
`[API] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`,
config.params ? 'params=' + JSON.stringify(config.params) : '',
config.data ? 'data=' + JSON.stringify(config.data).substring(0, 200) : ''
);
return config; return config;
}); });
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
console.log(
`[API] ${response.status} ${response.config.url}`,
Array.isArray(response.data) ? `(${response.data.length} items)` : ''
);
return response.data; return response.data;
}, },
(error) => { (error) => {
const status = error.response?.status; const status = error.response?.status;
const url = error.config?.url;
const detail = error.response?.data?.detail; const detail = error.response?.data?.detail;
const msg = detail || error.message || '未知错误';
if (status === 401) { if (status === 401) {
console.warn(`[API] 401 Unauthorized: ${url} — redirecting to login`);
localStorage.removeItem('token'); localStorage.removeItem('token');
window.location.href = '/login'; window.location.href = '/login';
} else if (status === 500) { } else if (status === 429) {
console.error(`[API] 500 Internal Error: ${url}`, detail || error.message); toast('warning', msg);
} else if (status === 422) { } else if (status === 422) {
console.warn(`[API] 422 Validation Error: ${url}`, error.response?.data); // 验证错误 — 取第一个错误信息
const errors = error.response?.data?.detail;
if (Array.isArray(errors)) {
const first = errors[0];
toast('error', first?.msg || '请求参数错误');
} else {
toast('error', typeof errors === 'string' ? errors : '请求参数错误');
}
} else if (status && status >= 400) {
toast('error', msg);
} else { } else {
console.warn(`[API] ${status || 'NETWORK'} Error: ${url}`, detail || error.message); toast('error', '网络连接失败');
} }
return Promise.reject(error); return Promise.reject(error);
@@ -0,0 +1,42 @@
import { useToast } from '@/hooks/useToast';
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
const ICONS = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const COLORS = {
success: 'bg-green-500/10 border-green-500/30 text-green-400',
error: 'bg-red-500/10 border-red-500/30 text-red-400',
warning: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400',
info: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
};
export function ToastContainer() {
const { toasts, remove } = useToast();
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => {
const Icon = ICONS[t.type];
return (
<div
key={t.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm animate-in slide-in-from-right fade-in duration-200 ${COLORS[t.type]}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="text-sm flex-1">{t.message}</span>
<button onClick={() => remove(t.id)} className="shrink-0 opacity-50 hover:opacity-100">
<X className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
);
}
+22 -29
View File
@@ -1,40 +1,33 @@
import { useState, useEffect, useCallback } from 'react'; import { useCallback, useState } from 'react';
import { CheckCircle, XCircle, X } from 'lucide-react';
export interface Toast { export interface Toast {
id: number;
type: 'success' | 'error' | 'warning' | 'info';
message: string; message: string;
type: 'success' | 'error'; }
let toastId = 0;
let _addToast: ((t: Toast) => void) | null = null;
export function toast(type: Toast['type'], message: string) {
if (_addToast) {
_addToast({ id: ++toastId, type, message });
}
} }
export function useToast() { export function useToast() {
const [toast, setToast] = useState<Toast | null>(null); const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(() => { _addToast = useCallback((t: Toast) => {
if (!toast) return; setToasts(prev => [...prev, t]);
const t = setTimeout(() => setToast(null), 3000); setTimeout(() => {
return () => clearTimeout(t); setToasts(prev => prev.filter(x => x.id !== t.id));
}, [toast]); }, 4000);
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
}, []); }, []);
const ToastComponent = () => { const remove = useCallback((id: number) => {
if (!toast) return null; setToasts(prev => prev.filter(x => x.id !== id));
const isSuccess = toast.type === 'success'; }, []);
return (
<div
className={`fixed bottom-6 right-6 z-[100] flex items-center gap-2 rounded-lg px-4 py-3 text-sm shadow-lg
animate-[fadeInUp_0.25s_ease-out] ${isSuccess ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}
>
{isSuccess ? <CheckCircle className="h-4 w-4 shrink-0" /> : <XCircle className="h-4 w-4 shrink-0" />}
<span>{toast.message}</span>
<button onClick={() => setToast(null)} className="ml-1 hover:opacity-70">
<X className="h-3 w-3" />
</button>
</div>
);
};
return { toast, showToast, ToastComponent }; return { toasts, remove };
} }
+48 -44
View File
@@ -17,11 +17,10 @@ interface AuditLog {
user_id: number; user_id: number;
username: string; username: string;
action: string; action: string;
target_type: string; resource_type: string;
target_id: number | null; resource_id: number | null;
detail: string | null; detail: string | null;
ip_address: string | null; ip_address: string | null;
result_status: 'success' | 'failed' | 'warning';
created_at: string | null; created_at: string | null;
} }
@@ -35,29 +34,27 @@ interface AuditLogResponse {
/* ───────── Constants ───────── */ /* ───────── Constants ───────── */
const ACTION_COLORS: Record<string, string> = { const ACTION_COLORS: Record<string, string> = {
CREATE_TICKET: 'bg-blue-500/15 text-blue-400', create: 'bg-blue-500/15 text-blue-400',
REVIEW_TICKET: 'bg-yellow-500/15 text-yellow-400', approve: 'bg-yellow-500/15 text-yellow-400',
EXECUTE_TICKET: 'bg-green-500/15 text-green-400', execute: 'bg-green-500/15 text-green-400',
CREATE_INSTANCE: 'bg-purple-500/15 text-purple-400', update: 'bg-purple-500/15 text-purple-400',
DELETE_INSTANCE: 'bg-red-500/15 text-red-400', delete: 'bg-red-500/15 text-red-400',
CREATE_USER: 'bg-indigo-500/15 text-indigo-400', login: 'bg-emerald-500/15 text-emerald-400',
DELETE_USER: 'bg-red-500/15 text-red-400', login_failed: 'bg-red-500/15 text-red-400',
QUERY: 'bg-cyan-500/15 text-cyan-400', cancel: 'bg-orange-500/15 text-orange-400',
LOGIN: 'bg-emerald-500/15 text-emerald-400', reject: 'bg-red-500/15 text-red-400',
LOGOUT: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
}; };
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
CREATE_TICKET: '创建工单', create: '创建',
REVIEW_TICKET: '审批工单', update: '更新',
EXECUTE_TICKET: '执行工单', delete: '删除',
CREATE_INSTANCE: '创建实例', approve: '审批',
DELETE_INSTANCE: '删除实例', execute: '执行',
CREATE_USER: '创建用户', cancel: '取消',
DELETE_USER: '删除用户', reject: '拒绝',
QUERY: '执行查询', login: '登录',
LOGIN: '登录', login_failed: '登录失败',
LOGOUT: '登出',
}; };
const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = { const STATUS_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string }> = {
@@ -70,8 +67,12 @@ const TARGET_ICONS: Record<string, React.ElementType> = {
ticket: FileText, ticket: FileText,
instance: Globe, instance: Globe,
user: User, user: User,
query: Search, auth: User,
system: AlertTriangle, audit_rule: Search,
notification: AlertTriangle,
workflow: FileText,
system_setting: AlertTriangle,
other: FileText,
}; };
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6']; const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6'];
@@ -81,9 +82,9 @@ const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#e
function exportLogsToCSV(logs: AuditLog[]) { function exportLogsToCSV(logs: AuditLog[]) {
const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间']; const headers = ['ID', '操作人', '操作类型', '目标类型', '目标ID', '详情', 'IP地址', '结果', '时间'];
const rows = logs.map(l => [ const rows = logs.map(l => [
l.id, l.username, ACTION_LABELS[l.action] || l.action, l.target_type, l.id, l.username, ACTION_LABELS[l.action] || l.action, l.resource_type,
l.target_id || '', l.detail || '', l.ip_address || '', l.resource_id || '', l.detail || '', l.ip_address || '',
l.result_status === 'success' ? '成功' : l.result_status === 'failed' ? '失败' : '警告', l.action.includes('fail') || l.action === 'reject' ? '失败' : '成功',
l.created_at || '', l.created_at || '',
]); ]);
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
@@ -123,7 +124,7 @@ export default function AuditLogsPage() {
try { try {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filterAction) params.set('action', filterAction); if (filterAction) params.set('action', filterAction);
const res = await request.get(`/audit/logs?${params}`); const res = await request.get(`/audit-logs?${params}`);
const data = (res?.data ?? res ?? {}) as AuditLogResponse; const data = (res?.data ?? res ?? {}) as AuditLogResponse;
const items = data.items || []; const items = data.items || [];
setLogs(items); setLogs(items);
@@ -141,8 +142,8 @@ export default function AuditLogsPage() {
// Client-side filtering for advanced filters // Client-side filtering for advanced filters
const filteredLogs = useMemo(() => { const filteredLogs = useMemo(() => {
return logs.filter(log => { return logs.filter(log => {
if (filterTarget && log.target_type !== filterTarget) return false; if (filterTarget && log.resource_type !== filterTarget) return false;
if (filterStatus && log.result_status !== filterStatus) return false; if (filterStatus && log.action.includes('fail') !== (filterStatus === 'failed')) return false;
if (filterDateRange && log.created_at) { if (filterDateRange && log.created_at) {
const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000); const days = Math.floor((Date.now() - new Date(log.created_at).getTime()) / 86400000);
if (filterDateRange === '1d' && days > 1) return false; if (filterDateRange === '1d' && days > 1) return false;
@@ -184,12 +185,14 @@ export default function AuditLogsPage() {
}, [logs]); }, [logs]);
const statusDistribution = useMemo(() => { const statusDistribution = useMemo(() => {
const map: Record<string, number> = { success: 0, failed: 0, warning: 0 }; let success = 0, failed = 0;
logs.forEach(l => { map[l.result_status] = (map[l.result_status] || 0) + 1; }); logs.forEach(l => {
if (l.action.includes('fail') || l.action === 'reject') failed++;
else success++;
});
return [ return [
{ name: '成功', value: map.success, color: '#22c55e' }, { name: '成功', value: success, color: '#22c55e' },
{ name: '失败', value: map.failed, color: '#f43f5e' }, { name: '失败', value: failed, color: '#f43f5e' },
{ name: '警告', value: map.warning, color: '#eab308' },
]; ];
}, [logs]); }, [logs]);
@@ -349,8 +352,9 @@ export default function AuditLogsPage() {
<option value="ticket">工单</option> <option value="ticket">工单</option>
<option value="instance">实例</option> <option value="instance">实例</option>
<option value="user">用户</option> <option value="user">用户</option>
<option value="query">查询</option> <option value="auth">认证</option>
<option value="system">系统</option> <option value="workflow">审批流</option>
<option value="notification">通知</option>
</select> </select>
{/* Status filter */} {/* Status filter */}
@@ -362,7 +366,6 @@ export default function AuditLogsPage() {
<option value="">全部结果</option> <option value="">全部结果</option>
<option value="success">成功</option> <option value="success">成功</option>
<option value="failed">失败</option> <option value="failed">失败</option>
<option value="warning">警告</option>
</select> </select>
{/* Date range */} {/* Date range */}
@@ -436,9 +439,10 @@ export default function AuditLogsPage() {
) : ( ) : (
paginatedLogs.map(log => { paginatedLogs.map(log => {
const isExpanded = expandedId === log.id; const isExpanded = expandedId === log.id;
const statusConfig = STATUS_CONFIG[log.result_status] || STATUS_CONFIG.success; const isFailed = log.action.includes('fail') || log.action === 'reject';
const statusConfig = isFailed ? STATUS_CONFIG.failed : STATUS_CONFIG.success;
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
const TargetIcon = TARGET_ICONS[log.target_type] || FileText; const TargetIcon = TARGET_ICONS[log.resource_type] || FileText;
return ( return (
<Fragment key={log.id}> <Fragment key={log.id}>
<tr <tr
@@ -467,8 +471,8 @@ export default function AuditLogsPage() {
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" /> <TargetIcon className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
<span className="text-[var(--dg-text-secondary)]">{log.target_type}</span> <span className="text-[var(--dg-text-secondary)]">{log.resource_type}</span>
{log.target_id && <span className="text-[var(--dg-text-muted)] text-xs">#{log.target_id}</span>} {log.resource_id && <span className="text-[var(--dg-text-muted)] text-xs">#{log.resource_id}</span>}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono"> <td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs font-mono">
@@ -505,7 +509,7 @@ export default function AuditLogsPage() {
</div> </div>
<div> <div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div> <div className="text-xs text-[var(--dg-text-muted)] mb-1">目标ID</div>
<div className="text-sm font-mono">{log.target_id || 'N/A'}</div> <div className="text-sm font-mono">{log.resource_id || 'N/A'}</div>
</div> </div>
<div> <div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div> <div className="text-xs text-[var(--dg-text-muted)] mb-1">时间</div>