From 1c1555e4178f693927b5c5779a489b8f636a133c Mon Sep 17 00:00:00 2001 From: gitadmin Date: Tue, 23 Jun 2026 10:59:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 audit.ts 核心函数(writeAuditLog、diffObjects、getClientIP) - 新增审计日志 API(分页查询、CSV 导出) - 新增审计日志前端页面 - 更新 Sidebar 添加审计日志菜单项 - 添加审计权限配置 - 在所有写操作 API 中集成审计日志 - 更新 CLAUDE.md 添加审计日志开发规范 --- CLAUDE.md | 16 ++ src/app/(app)/settings/audit-logs/page.tsx | 308 +++++++++++++++++++++ src/app/(app)/settings/roles/page.tsx | 2 + src/app/api/api-keys/[id]/route.ts | 16 +- src/app/api/api-keys/route.ts | 11 + src/app/api/audit-logs/cleanup/route.ts | 40 +++ src/app/api/audit-logs/export/route.ts | 103 +++++++ src/app/api/audit-logs/route.ts | 72 +++++ src/app/api/auth/login/route.ts | 12 + src/app/api/auth/logout/route.ts | 22 +- src/app/api/reports/[id]/generate/route.ts | 12 + src/app/api/reports/route.ts | 22 ++ src/app/api/roles/[id]/route.ts | 28 ++ src/app/api/roles/route.ts | 12 + src/app/api/tickets/[id]/route.ts | 30 ++ src/app/api/tickets/batch/route.ts | 13 + src/app/api/tickets/export/route.ts | 11 + src/app/api/tickets/external/route.ts | 18 +- src/app/api/tickets/import/route.ts | 15 + src/app/api/tickets/route.ts | 11 + src/app/api/users/[id]/route.ts | 28 ++ src/app/api/users/route.ts | 12 + src/app/api/users/sync-emails/route.ts | 10 + src/components/layout/Sidebar.tsx | 1 + src/lib/audit.ts | 81 ++++++ src/lib/db-schema.ts | 13 + 26 files changed, 915 insertions(+), 4 deletions(-) create mode 100644 src/app/(app)/settings/audit-logs/page.tsx create mode 100644 src/app/api/audit-logs/cleanup/route.ts create mode 100644 src/app/api/audit-logs/export/route.ts create mode 100644 src/app/api/audit-logs/route.ts create mode 100644 src/lib/audit.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8653459..466afeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ npm run import # 导入工单 | `src/lib/docx-export.ts` | Word 文档生成 | | `src/lib/excel.ts` | Excel 解析与导出 | | `src/lib/pdf.ts` | PDF 生成(puppeteer) | +| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) | | `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput | | `src/types/report.ts` | ReportType / ReportData | @@ -242,6 +243,21 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai - **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验 - **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供 - **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')` +- **审计日志**:所有写操作 API(POST/PUT/DELETE)必须添加审计日志,使用 `writeAuditLog()` 函数: + ```typescript + import { writeAuditLog, getClientIP } from '@/lib/audit' + + // 在操作成功后调用 + writeAuditLog({ + userId: user.id, // issue-ai 使用 user.id + apiKeyId: null, // 或 apiKeyInfo?.id + action: 'create' | 'update' | 'delete' | 'batch_update' | 'import' | 'export' | 'generate', + entityType: 'ticket' | 'report' | 'user' | 'role' | 'api_key' | 'auth', + entityId: ticketId, // 可选 + details: { created: { ticket_no, device_ip } }, // 或 { changes: diffObjects(old, new) } 或 { deleted: { ... } } + ipAddress: getClientIP(request) + }) + ``` - **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。两处必须遵守: 1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` 2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。 diff --git a/src/app/(app)/settings/audit-logs/page.tsx b/src/app/(app)/settings/audit-logs/page.tsx new file mode 100644 index 0000000..0a4fc57 --- /dev/null +++ b/src/app/(app)/settings/audit-logs/page.tsx @@ -0,0 +1,308 @@ +'use client' +import { useState, useEffect } from 'react' +import { Card, Button, Table, Badge, Modal } from '@/components/ui' + +interface AuditLog { + id: number + user_id: number | null + api_key_id: number | null + action: string + entity_type: string + entity_id: number | null + details: string | null + ip_address: string | null + created_at: string + username: string | null + api_key_name: string | null +} + +const ACTION_LABELS: Record = { + create: { label: '创建', color: 'green' }, + update: { label: '更新', color: 'blue' }, + delete: { label: '删除', color: 'red' }, + batch_update: { label: '批量更新', color: 'blue' }, + import: { label: '导入', color: 'blue' }, + export: { label: '导出', color: 'gray' }, + login: { label: '登录', color: 'gray' }, + logout: { label: '登出', color: 'gray' }, +} + +const ENTITY_LABELS: Record = { + ticket: '工单', + report: '报告', + user: '用户', + role: '角色', + api_key: 'API Key', + audit_log: '审计日志', + auth: '认证', +} + +export default function AuditLogsPage() { + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize] = useState(20) + + // 过滤器 + const [actionFilter, setActionFilter] = useState('') + const [entityFilter, setEntityFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + + // 详情 Modal + const [detailLog, setDetailLog] = useState(null) + + async function fetchLogs() { + setLoading(true) + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('pageSize', String(pageSize)) + if (actionFilter) params.set('action', actionFilter) + if (entityFilter) params.set('entity_type', entityFilter) + if (dateFrom) params.set('date_from', dateFrom) + if (dateTo) params.set('date_to', dateTo) + + try { + const res = await fetch(`/api/audit-logs?${params}`) + if (res.ok) { + const data = await res.json() + setLogs(data.logs) + setTotal(data.total) + } + } catch { /* ignore */ } + setLoading(false) + } + + useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo]) + + function resetFilters() { + setActionFilter('') + setEntityFilter('') + setDateFrom('') + setDateTo('') + setPage(1) + } + + function setQuickDate(days: number) { + const today = new Date() + const from = new Date(today) + from.setDate(from.getDate() - days) + const fromStr = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}` + const toStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + setDateFrom(fromStr) + setDateTo(toStr) + } + + async function handleExport() { + const params = new URLSearchParams() + if (actionFilter) params.set('action', actionFilter) + if (entityFilter) params.set('entity_type', entityFilter) + if (dateFrom) params.set('date_from', dateFrom) + if (dateTo) params.set('date_to', dateTo) + + const res = await fetch(`/api/audit-logs/export?${params}`) + if (res.ok) { + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv` + a.click() + URL.revokeObjectURL(url) + } + } + + function formatDetails(log: AuditLog): string { + if (!log.details) return '-' + try { + const details = JSON.parse(log.details) + if (details.changes) { + const keys = Object.keys(details.changes) + return keys.slice(0, 2).join(', ') + (keys.length > 2 ? '...' : '') + } + if (details.created) return '新建记录' + if (details.deleted) return '删除记录' + if (details.export) return `导出 ${details.export.count} 条` + if (details.batch_update) return `批量更新 ${details.batch_update.count} 条` + if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新` + return '-' + } catch { return '-' } + } + + return ( +
+
+
+

审计日志

+

系统操作与事件记录,保留 180 天

+
+
+ +
+
+ + {/* 过滤器 */} + +
+ + + + + { setDateFrom(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900" + /> + ~ + { setDateTo(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900" + /> + +
+ + + +
+ + +
+
+ + {/* 表格 */} + + {loading ? ( +
加载中...
+ ) : ( + + {logs.map((log) => ( + + + + + + + + + ))} + {logs.length === 0 && ( + + + + )} +
{log.created_at} +
+ {log.username || '-'} + {log.api_key_id && [API]} +
+
+ + {ACTION_LABELS[log.action]?.label || log.action} + + {ENTITY_LABELS[log.entity_type] || log.entity_type} + + {log.ip_address || '-'}
暂无数据
+ )} +
+ + {/* 分页 */} + {total > pageSize && ( +
+ + + 第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页 + + +
+ )} + + {/* 详情 Modal */} + setDetailLog(null)} + title="审计日志详情" + > + {detailLog && ( +
+
+
+ 时间: + {detailLog.created_at} +
+
+ 用户: + {detailLog.username || '-'} + {detailLog.api_key_id && [API]} +
+
+ 操作: + + {ACTION_LABELS[detailLog.action]?.label || detailLog.action} + +
+
+ 对象: + {ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type} + {detailLog.entity_id && #{detailLog.entity_id}} +
+
+ IP: + {detailLog.ip_address || '-'} +
+
+ + {detailLog.details && ( +
+ 详情: +
+                  {JSON.stringify(JSON.parse(detailLog.details), null, 2)}
+                
+
+ )} +
+ )} +
+
+ ) +} diff --git a/src/app/(app)/settings/roles/page.tsx b/src/app/(app)/settings/roles/page.tsx index 2b792c1..e96b034 100644 --- a/src/app/(app)/settings/roles/page.tsx +++ b/src/app/(app)/settings/roles/page.tsx @@ -28,6 +28,8 @@ const allPermissions = [ { key: 'roles:write', label: '编辑角色' }, { key: 'api-keys:read', label: '查看 API Key' }, { key: 'api-keys:write', label: '编辑 API Key' }, + { key: 'audit-logs:read', label: '查看审计日志' }, + { key: 'audit-logs:export', label: '导出审计日志' }, ] export default function RolesPage() { diff --git a/src/app/api/api-keys/[id]/route.ts b/src/app/api/api-keys/[id]/route.ts index 9c2f49c..077b076 100644 --- a/src/app/api/api-keys/[id]/route.ts +++ b/src/app/api/api-keys/[id]/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { getDb } from '@/lib/db' import { verifyToken } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' async function getSession() { const cookieStore = await cookies() @@ -49,9 +50,22 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ } const { id } = await params - const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id) + const existing = getDb().prepare('SELECT id, name FROM api_keys WHERE id = ?').get(id) as { id: number; name: string } | undefined if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 }) + const snapshot = { id: existing.id, name: existing.name } + getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id) + + writeAuditLog({ + userId: session.id, + apiKeyId: null, + action: 'delete', + entityType: 'api_key', + entityId: Number(id), + details: { deleted: snapshot }, + ipAddress: getClientIP(_request), + }) + return NextResponse.json({ success: true }) } diff --git a/src/app/api/api-keys/route.ts b/src/app/api/api-keys/route.ts index 97674eb..282880e 100644 --- a/src/app/api/api-keys/route.ts +++ b/src/app/api/api-keys/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { getDb } from '@/lib/db' import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' async function getSession() { const cookieStore = await cookies() @@ -44,6 +45,16 @@ export async function POST(request: Request) { 'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)' ).run(name, keyHash, perms, expires_at || null, session.id) + writeAuditLog({ + userId: session.id, + apiKeyId: null, + action: 'create', + entityType: 'api_key', + entityId: result.lastInsertRowid as number, + details: { created: { name } }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 }) } catch (e) { const msg = e instanceof Error ? e.message : '创建失败' diff --git a/src/app/api/audit-logs/cleanup/route.ts b/src/app/api/audit-logs/cleanup/route.ts new file mode 100644 index 0000000..463d451 --- /dev/null +++ b/src/app/api/audit-logs/cleanup/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' +import { initDatabase } from '@/lib/db-schema' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function DELETE(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + // 检查权限 + if (user.role !== 'admin') { + if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + const db = getDb() + + // 清理 180 天前的日志 + const result = db.prepare( + "DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')" + ).run() + + // 自审计:记录清理操作 + writeAuditLog({ + userId: user.id, + action: 'cleanup', + entityType: 'audit_log', + details: { + cleanup: { + deleted: result.changes + } + }, + ipAddress: getClientIP(request) + }) + + return NextResponse.json({ deleted: result.changes }) +} diff --git a/src/app/api/audit-logs/export/route.ts b/src/app/api/audit-logs/export/route.ts new file mode 100644 index 0000000..bdea72d --- /dev/null +++ b/src/app/api/audit-logs/export/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' +import { initDatabase } from '@/lib/db-schema' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function GET(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + // 检查权限 + if (user.role !== 'admin') { + if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + const db = getDb() + const { searchParams } = new URL(request.url) + const action = searchParams.get('action') + const entityType = searchParams.get('entity_type') + const userId = searchParams.get('user_id') + const dateFrom = searchParams.get('date_from') + const dateTo = searchParams.get('date_to') + + let whereClause = 'WHERE 1=1' + const params: unknown[] = [] + + if (action) { + whereClause += ' AND a.action = ?' + params.push(action) + } + if (entityType) { + whereClause += ' AND a.entity_type = ?' + params.push(entityType) + } + if (userId) { + whereClause += ' AND a.user_id = ?' + params.push(parseInt(userId)) + } + if (dateFrom) { + whereClause += ' AND a.created_at >= ?' + params.push(dateFrom) + } + if (dateTo) { + whereClause += ' AND a.created_at <= ?' + params.push(dateTo + ' 23:59:59') + } + + const logs = db.prepare( + `SELECT a.*, u.username, ak.name as api_key_name + FROM audit_logs a + LEFT JOIN users u ON a.user_id = u.id + LEFT JOIN api_keys ak ON a.api_key_id = ak.id + ${whereClause} + ORDER BY a.created_at DESC` + ).all(...params) + + // 生成 CSV + const BOM = '' + const headers = ['ID', '时间', '用户', 'API Key', '操作', '对象类型', '对象ID', '详情', 'IP地址'] + const rows = logs.map((log: Record) => [ + log.id, + log.created_at, + log.username || '', + log.api_key_name || '', + log.action, + log.entity_type, + log.entity_id || '', + log.details ? String(log.details).replace(/"/g, '""') : '', + log.ip_address || '' + ]) + + const csvContent = BOM + [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n') + + // 自审计:记录导出操作 + writeAuditLog({ + userId: user.id, + action: 'export', + entityType: 'audit_log', + details: { + export: { + filter: { entity_type: entityType, action, user_id: userId, date_from: dateFrom, date_to: dateTo }, + count: logs.length + } + }, + ipAddress: getClientIP(request) + }) + + const today = new Date() + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="audit-logs-${dateStr}.csv"` + } + }) +} diff --git a/src/app/api/audit-logs/route.ts b/src/app/api/audit-logs/route.ts new file mode 100644 index 0000000..0423df4 --- /dev/null +++ b/src/app/api/audit-logs/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' +import { initDatabase } from '@/lib/db-schema' + +export async function GET(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + // 检查权限 + if (user.role !== 'admin') { + if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:read')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + const db = getDb() + const { searchParams } = new URL(request.url) + const page = Math.max(1, parseInt(searchParams.get('page') || '1')) + const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20'))) + const action = searchParams.get('action') + const entityType = searchParams.get('entity_type') + const userId = searchParams.get('user_id') + const dateFrom = searchParams.get('date_from') + const dateTo = searchParams.get('date_to') + + let whereClause = 'WHERE 1=1' + const params: unknown[] = [] + + if (action) { + whereClause += ' AND a.action = ?' + params.push(action) + } + if (entityType) { + whereClause += ' AND a.entity_type = ?' + params.push(entityType) + } + if (userId) { + whereClause += ' AND a.user_id = ?' + params.push(parseInt(userId)) + } + if (dateFrom) { + whereClause += ' AND a.created_at >= ?' + params.push(dateFrom) + } + if (dateTo) { + whereClause += ' AND a.created_at <= ?' + params.push(dateTo + ' 23:59:59') + } + + const countResult = db.prepare( + `SELECT COUNT(*) as total FROM audit_logs a ${whereClause}` + ).get(...params) as { total: number } + + const logs = db.prepare( + `SELECT a.*, u.username, ak.name as api_key_name + FROM audit_logs a + LEFT JOIN users u ON a.user_id = u.id + LEFT JOIN api_keys ak ON a.api_key_id = ak.id + ${whereClause} + ORDER BY a.created_at DESC + LIMIT ? OFFSET ?` + ).all(...params, pageSize, (page - 1) * pageSize) + + return NextResponse.json({ + logs, + total: countResult.total, + page, + pageSize + }) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 71c07c1..8c474ae 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,6 +6,7 @@ import { ldapAuth } from '@/lib/ldap' import { getDb } from '@/lib/db' import { getUserPermissions } from '@/lib/permissions' import bcrypt from 'bcryptjs' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function POST(request: NextRequest) { try { @@ -84,6 +85,17 @@ export async function POST(request: NextRequest) { const response = NextResponse.json({ user: { id: userId, username, display_name: displayName, role, permissions: getUserPermissions(role) }, }) + + writeAuditLog({ + userId, + apiKeyId: null, + action: 'login', + entityType: 'auth', + entityId: userId, + details: { username, displayName }, + ipAddress: getClientIP(request), + }) + response.cookies.set('session_issue', localToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/', diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 32dba4c..89bb1b2 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,25 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' +import { getCurrentUser } from '@/lib/auth' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function POST(request: NextRequest) { + // 先获取当前用户信息(用于审计日志),再清除 cookie + const user = await getCurrentUser() -export async function POST() { const r = NextResponse.json({ success: true }) + + if (user) { + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'logout', + entityType: 'auth', + entityId: user.id, + details: { username: user.username }, + ipAddress: getClientIP(request), + }) + } + r.cookies.set('session_issue', '', { maxAge: 0, path: '/' }) r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' }) return r diff --git a/src/app/api/reports/[id]/generate/route.ts b/src/app/api/reports/[id]/generate/route.ts index 7894c68..cbd5c28 100644 --- a/src/app/api/reports/[id]/generate/route.ts +++ b/src/app/api/reports/[id]/generate/route.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { generateMonthlyReport } from '@/lib/monthly-report' import { generateWeeklyReport } from '@/lib/weekly-report' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function POST( _request: NextRequest, @@ -47,6 +48,17 @@ export async function POST( } const updated = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'generate', + entityType: 'report', + entityId: parseInt(id), + details: { generate: { report_type: report.report_type } }, + ipAddress: getClientIP(_request) + }) + return NextResponse.json({ report: updated }) } catch (e) { const msg = e instanceof Error ? e.message : '生成失败' diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index 7116e89..592c9b7 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import fs from 'fs' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function GET() { try { @@ -60,6 +61,17 @@ export async function POST(request: NextRequest) { // 3. 返回报告(状态为 ready),前端自动跳转预览页 const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'create', + entityType: 'report', + entityId: reportId, + details: { created: { report_type, period_start, period_end } }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ report }, { status: 201 }) } catch (e) { const msg = e instanceof Error ? e.message : '创建失败' @@ -95,6 +107,16 @@ export async function DELETE(request: NextRequest) { const placeholders = ids.map(() => '?').join(',') db.prepare(`DELETE FROM reports WHERE id IN (${placeholders})`).run(...ids) + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'delete', + entityType: 'report', + entityId: null, + details: { deleted: ids }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ success: true, deleted: ids.length }) } catch (e) { const msg = e instanceof Error ? e.message : '批量删除失败' diff --git a/src/app/api/roles/[id]/route.ts b/src/app/api/roles/[id]/route.ts index 681da32..493a17f 100644 --- a/src/app/api/roles/[id]/route.ts +++ b/src/app/api/roles/[id]/route.ts @@ -3,6 +3,7 @@ import { getDb } from '@/lib/db' import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' +import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -18,6 +19,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 }) + // 获取更新前的完整记录用于审计日志 + const beforeUpdate = { ...existing } + const fields: string[] = [] const values: unknown[] = [] @@ -30,6 +34,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'update', + entityType: 'role', + entityId: Number(id), + details: { changes: diffObjects(beforeUpdate, role as Record) }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ role }) } catch (e) { const msg = e instanceof Error ? e.message : '更新失败' @@ -52,7 +67,20 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 }) } + const snapshot = { id: existing.id, name: existing.name, display_name: existing.display_name } + db.prepare('DELETE FROM roles WHERE id = ?').run(id) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'delete', + entityType: 'role', + entityId: Number(id), + details: { deleted: snapshot }, + ipAddress: getClientIP(_request), + }) + return NextResponse.json({ success: true }) } catch (e) { const msg = e instanceof Error ? e.message : '删除失败' diff --git a/src/app/api/roles/route.ts b/src/app/api/roles/route.ts index f545bea..9bdb38c 100644 --- a/src/app/api/roles/route.ts +++ b/src/app/api/roles/route.ts @@ -3,6 +3,7 @@ import { getDb } from '@/lib/db' import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function GET() { try { @@ -39,6 +40,17 @@ export async function POST(request: NextRequest) { const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(name, display_name, JSON.stringify(permissions || [])) const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'create', + entityType: 'role', + entityId: result.lastInsertRowid as number, + details: { created: { name, display_name } }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ role }, { status: 201 }) } catch (e) { const msg = e instanceof Error ? e.message : '创建失败' diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 084f837..30843cf 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { getAssetByIp } from '@/lib/assets-client' +import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -106,6 +107,21 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id) + + // 审计日志:记录变更 + const changes = diffObjects(existing, body) + if (Object.keys(changes).length > 0) { + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'update', + entityType: 'ticket', + entityId: Number(id), + details: { changes }, + ipAddress: getClientIP(request), + }) + } + return NextResponse.json({ ticket }) } catch (e) { const msg = e instanceof Error ? e.message : '更新失败' @@ -126,6 +142,9 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis const existing = db.prepare('SELECT current_status, created_by FROM tickets WHERE id = ?').get(id) as { current_status: string; created_by: number | null } | undefined if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 }) + // 获取工单快照用于审计日志 + const snapshot = db.prepare('SELECT id, ticket_no, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record | undefined + // 非管理员不可删除已办工单,非创建人不可删除待办工单 const completedStatuses = ['resolved', 'closed'] if (user.role !== 'admin') { @@ -138,6 +157,17 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis } db.prepare('DELETE FROM tickets WHERE id = ?').run(id) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'delete', + entityType: 'ticket', + entityId: Number(id), + details: { deleted: snapshot }, + ipAddress: getClientIP(_request), + }) + return NextResponse.json({ success: true }) } catch (e) { const msg = e instanceof Error ? e.message : '删除失败' diff --git a/src/app/api/tickets/batch/route.ts b/src/app/api/tickets/batch/route.ts index 8e28c42..39d55a7 100644 --- a/src/app/api/tickets/batch/route.ts +++ b/src/app/api/tickets/batch/route.ts @@ -3,6 +3,7 @@ import { getDb } from '@/lib/db' import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function PUT(request: NextRequest) { try { @@ -44,6 +45,18 @@ export async function PUT(request: NextRequest) { updated += result.changes } + if (updated > 0) { + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'batch_update', + entityType: 'ticket', + entityId: null, + details: { updated, total: updates.length }, + ipAddress: getClientIP(request), + }) + } + return NextResponse.json({ updated, total: updates.length }) } catch (e) { const msg = e instanceof Error ? e.message : '批量更新失败' diff --git a/src/app/api/tickets/export/route.ts b/src/app/api/tickets/export/route.ts index e638365..e6e0b24 100644 --- a/src/app/api/tickets/export/route.ts +++ b/src/app/api/tickets/export/route.ts @@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { exportTicketsToExcel } from '@/lib/excel' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function GET(request: NextRequest) { try { @@ -47,6 +48,16 @@ export async function GET(request: NextRequest) { const buffer = exportTicketsToExcel(tickets) + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'export', + entityType: 'ticket', + entityId: null, + details: { count: tickets.length }, + ipAddress: getClientIP(request), + }) + return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', diff --git a/src/app/api/tickets/external/route.ts b/src/app/api/tickets/external/route.ts index f1b982a..a07c18d 100644 --- a/src/app/api/tickets/external/route.ts +++ b/src/app/api/tickets/external/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getDb } from '@/lib/db' import { initDatabase } from '@/lib/db-schema' import { verifyApiKey } from '@/lib/auth' +import { writeAuditLog, getClientIP } from '@/lib/audit' function verifyEnvApiKey(key: string): boolean { const allowed = process.env.ALLOWED_API_KEYS || '' @@ -27,6 +28,7 @@ export async function POST(request: NextRequest) { const authHeader = request.headers.get('authorization') let authenticated = false + let apiKeyInfo: { id: number; name: string; permissions: string[] } | null = null if (authHeader?.startsWith('Bearer ak_')) { const key = authHeader.slice(7) @@ -34,7 +36,10 @@ export async function POST(request: NextRequest) { authenticated = true } else { const keyInfo = verifyApiKey(key) - if (keyInfo) authenticated = true + if (keyInfo) { + authenticated = true + apiKeyInfo = keyInfo + } } } @@ -81,6 +86,17 @@ export async function POST(request: NextRequest) { ) const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId) + + writeAuditLog({ + userId: null, + apiKeyId: apiKeyInfo?.id ?? null, + action: 'create', + entityType: 'ticket', + entityId: ticketId, + details: { created: { ticket_no: ticketNo, device_ip: body.device_ip || null } }, + ipAddress: getClientIP(request) + }) + return NextResponse.json({ ticket, created: true }, { status: 201 }) } catch (e) { const msg = e instanceof Error ? e.message : '创建失败' diff --git a/src/app/api/tickets/import/route.ts b/src/app/api/tickets/import/route.ts index d4f949f..f19801c 100644 --- a/src/app/api/tickets/import/route.ts +++ b/src/app/api/tickets/import/route.ts @@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { parseExcelTickets } from '@/lib/excel' +import { writeAuditLog, getClientIP } from '@/lib/audit' function validateTicketNo(ticketNo: string): string | null { if (!/^\d{14}$/.test(ticketNo)) { @@ -120,6 +121,20 @@ export async function POST(request: NextRequest) { transaction() + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'import', + entityType: 'ticket', + entityId: null, + details: { + created: imported.length, + overwritten: conflicts.length, + errors: errors.length, + }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ success: true, imported: imported.length, diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index 7a055b3..ce5a611 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -3,6 +3,7 @@ import { getDb } from '@/lib/db' import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function GET(request: NextRequest) { try { @@ -155,6 +156,16 @@ export async function POST(request: NextRequest) { } } + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'create', + entityType: 'ticket', + entityId: ticketId, + details: { created: { ticket_no: ticketNo, device_ip: body.device_ip, device_name: body.device_name } }, + ipAddress: getClientIP(request) + }) + const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid) return NextResponse.json({ ticket }, { status: 201 }) } catch (e) { diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 8ce244a..751b498 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -3,6 +3,7 @@ import { getDb } from '@/lib/db' import { initDatabase } from '@/lib/db-schema' import { getCurrentUser, hashPassword } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' +import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { initDatabase() @@ -35,6 +36,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) + // 获取更新前的完整记录用于审计日志 + const beforeUpdate = db.prepare('SELECT id, username, display_name, email, role, is_active FROM users WHERE id = ?').get(id) as Record + // 禁止修改系统保留用户的角色 if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) { return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 }) @@ -59,6 +63,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ last_login_at, CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online FROM users WHERE id = ?`).get(id) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'update', + entityType: 'user', + entityId: Number(id), + details: { changes: diffObjects(beforeUpdate, updated as Record) }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ user: updated }) } catch (e) { const msg = e instanceof Error ? e.message : '更新失败' @@ -83,7 +98,20 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 }) } + const snapshot = db.prepare('SELECT id, username, display_name, role FROM users WHERE id = ?').get(id) as Record + db.prepare('DELETE FROM users WHERE id = ?').run(id) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'delete', + entityType: 'user', + entityId: Number(id), + details: { deleted: snapshot }, + ipAddress: getClientIP(_request), + }) + return NextResponse.json({ success: true }) } catch (e) { const msg = e instanceof Error ? e.message : '删除失败' diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 5a7bf4e..9cde643 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { hashPassword } from '@/lib/auth' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function GET() { try { @@ -48,6 +49,17 @@ export async function POST(request: NextRequest) { ).run(username, hash, display_name, email || null, role || 'viewer') const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid) + + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'create', + entityType: 'user', + entityId: result.lastInsertRowid as number, + details: { created: { username, display_name, role: role || 'viewer' } }, + ipAddress: getClientIP(request), + }) + return NextResponse.json({ user: newUser }, { status: 201 }) } catch (e) { const msg = e instanceof Error ? e.message : '创建失败' diff --git a/src/app/api/users/sync-emails/route.ts b/src/app/api/users/sync-emails/route.ts index ee97c08..fd745de 100644 --- a/src/app/api/users/sync-emails/route.ts +++ b/src/app/api/users/sync-emails/route.ts @@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { ldapGetUserInfo } from '@/lib/ldap' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function POST(_request: NextRequest) { initDatabase() @@ -29,5 +30,14 @@ export async function POST(_request: NextRequest) { } } + writeAuditLog({ + userId: user.id, + apiKeyId: null, + action: 'sync_emails', + entityType: 'user', + details: { sync_emails: { synced, failed } }, + ipAddress: getClientIP(_request) + }) + return NextResponse.json({ synced, failed, total: users.length }) } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 647e880..5a3c699 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ const settingsItems = [ { href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' }, { href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' }, { href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' }, + { href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' }, ] function hasAnyAdminPerm(permissions: string[]): boolean { diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..6635c90 --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,81 @@ +import { getDb } from './db' + +interface AuditLogOptions { + userId?: number | null + apiKeyId?: number | null + action: string + entityType: string + entityId?: number | null + details?: Record | null + ipAddress?: string | null +} + +export function writeAuditLog(opts: AuditLogOptions): void { + const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts + + try { + const db = getDb() + + // 每日清理(每天首次写入触发) + // 注意:禁止使用 toISOString(),会返回 UTC 时间导致时区偏移 + const now = new Date() + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + + const lastCleanup = db.prepare( + "SELECT value FROM settings WHERE key = 'audit_cleanup_date'" + ).get() as { value: string } | undefined + + if (!lastCleanup || lastCleanup.value !== today) { + db.prepare( + "DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')" + ).run() + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)" + ).run(today) + } + + // 写入审计日志 + db.prepare(` + INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + userId ?? null, + apiKeyId ?? null, + action, + entityType, + entityId ?? null, + details ? JSON.stringify(details) : null, + ipAddress ?? null + ) + } catch (e) { + // 审计写入失败不阻断主操作 + console.error('审计日志写入失败:', e) + } +} + +export function diffObjects( + before: Record, + after: Record +): Record { + const changes: Record = {} + const keys = new Set([...Object.keys(before), ...Object.keys(after)]) + + for (const key of keys) { + // 跳过系统字段 + if (['created_at', 'updated_at'].includes(key)) continue + + const from = before[key] + const to = after[key] + if (JSON.stringify(from) !== JSON.stringify(to)) { + changes[key] = { from, to } + } + } + + return changes +} + +export function getClientIP(request: Request): string | null { + const forwarded = request.headers.get('x-forwarded-for') + if (forwarded) return forwarded.split(',')[0].trim() + return request.headers.get('x-real-ip') ?? null +} diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts index abf5657..96f8c60 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -45,6 +45,19 @@ export function initDatabase(): void { } } catch { /* 迁移失败则保持原样 */ } + // 迁移:audit_logs 表添加 api_key_id 列 + try { db.exec('ALTER TABLE audit_logs ADD COLUMN api_key_id INTEGER REFERENCES api_keys(id)') } catch { /* 列已存在 */ } + + // 迁移:新增 settings 表(审计日志每日清理日期跟踪) + try { + db.exec("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + } catch { /* 表已存在 */ } + + // 迁移:新增 audit_logs 索引 + try { + db.exec("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)") + } catch { /* 索引已存在 */ } + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin') if (!existing) { const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'