diff --git a/CLAUDE.md b/CLAUDE.md index 92e6ed0..e9c12b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ npm run import # 导入设备数据 | `src/lib/permissions.ts` | 权限检查(按角色 JSON 匹配) | | `src/lib/issue-client.ts` | 调用 issue API 获取设备历史工单 | | `src/lib/excel.ts` | Excel 模板生成 + 导入解析 | +| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) | | `src/types/index.ts` | User / Asset / ApiKey / PaginatedResult 等类型 | | `src/app/api/assets/` | 资产 CRUD + 批量修改 + 导入/导出 + 高级查询 | @@ -224,6 +225,21 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets - **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 - **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供 +- **审计日志**:所有写操作 API(POST/PUT/DELETE)必须添加审计日志,使用 `writeAuditLog()` 函数: + ```typescript + import { writeAuditLog, getClientIP } from '@/lib/audit' + + // 在操作成功后调用 + writeAuditLog({ + userId: session.userId, + apiKeyId: null, // 或 apiKey?.id + action: 'create' | 'update' | 'delete' | 'batch_update' | 'import' | 'export', + entityType: 'asset' | 'user' | 'role' | 'api_key' | 'auth', + entityId: result.lastInsertRowid as number, // 可选 + details: { created: { ... } }, // 或 { 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..22cb557 --- /dev/null +++ b/src/app/(app)/settings/audit-logs/page.tsx @@ -0,0 +1,328 @@ +'use client' +import { useState, useEffect } from 'react' +import Table, { Column } from '@/components/ui/Table' +import Button from '@/components/ui/Button' +import Modal from '@/components/ui/Modal' +import Badge from '@/components/ui/Badge' + +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' }, + sync_emails: { label: '同步邮箱', color: 'blue' }, +} + +const ENTITY_LABELS: Record = { + asset: '资产', + 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 '-' } + } + + const columns: Column[] = [ + { + key: 'created_at', + title: '时间', + width: '160px', + render: (r) => {r.created_at} + }, + { + key: 'username', + title: '用户', + width: '120px', + render: (r) => ( +
+ {r.username || '-'} + {r.api_key_id && [API]} +
+ ) + }, + { + key: 'action', + title: '操作', + width: '80px', + render: (r) => { + const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const } + return {info.label} + } + }, + { + key: 'entity_type', + title: '对象', + width: '80px', + render: (r) => ENTITY_LABELS[r.entity_type] || r.entity_type + }, + { + key: 'details', + title: '详情', + render: (r) => ( + + ) + }, + { + key: 'ip_address', + title: 'IP', + width: '120px', + render: (r) => {r.ip_address || '-'} + }, + ] + + 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 ? ( +
加载中...
+ ) : ( + String(r.id)} /> + )} + + {/* 分页 */} + {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 bea861e..dc5f69b 100644 --- a/src/app/(app)/settings/roles/page.tsx +++ b/src/app/(app)/settings/roles/page.tsx @@ -29,6 +29,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: '导出审计日志' }, ] const BUILTIN_ROLES = ['admin', 'editor', 'viewer'] diff --git a/src/app/api/api-keys/[id]/route.ts b/src/app/api/api-keys/[id]/route.ts index 0297a3e..0cde53e 100644 --- a/src/app/api/api-keys/[id]/route.ts +++ b/src/app/api/api-keys/[id]/route.ts @@ -3,10 +3,11 @@ import { cookies } from 'next/headers' import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' -export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { const session = await getSession() if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!checkPermission(session.role, 'api-keys:write')) { @@ -14,9 +15,20 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ } const { id } = await params - const existing = db.prepare('SELECT id FROM api_keys WHERE id = ?').get(id) + const existing = db.prepare('SELECT * FROM api_keys WHERE id = ?').get(id) as Record | undefined if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 }) db.prepare('DELETE FROM api_keys WHERE id = ?').run(id) + + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'delete', + entityType: 'api_key', + entityId: Number(id), + details: { deleted: { id: existing.id, name: existing.name } }, + 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 7690b5c..eaf6027 100644 --- a/src/app/api/api-keys/route.ts +++ b/src/app/api/api-keys/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import db from '@/lib/db' import { getSession, generateApiKey, hashApiKey } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' @@ -39,6 +40,16 @@ export async function POST(request: Request) { const apiKey = db.prepare('SELECT id, name, permissions, expires_at, is_active, created_at FROM api_keys WHERE id = ?').get(result.lastInsertRowid) + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'create', + entityType: 'api_key', + entityId: result.lastInsertRowid as number, + details: { created: { name, permissions: permissions || ['assets:read'] } }, + ipAddress: getClientIP(request) + }) + return NextResponse.json({ key, apiKey }, { status: 201 }) } catch (e) { const msg = e instanceof Error ? e.message : '创建 API Key 失败' diff --git a/src/app/api/assets/[id]/route.ts b/src/app/api/assets/[id]/route.ts index e2376e0..21a72a2 100644 --- a/src/app/api/assets/[id]/route.ts +++ b/src/app/api/assets/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import db from '@/lib/db' import { getSession, verifyApiKey } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' @@ -75,8 +76,16 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values) - db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'update', 'asset', ?, ?)`) - .run(session.userId, id, null) + const newAsset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'update', + entityType: 'asset', + entityId: parseInt(id), + details: { changes: diffObjects(existing as Record, newAsset as Record) }, + ipAddress: getClientIP(request) + }) const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) return NextResponse.json({ asset }) @@ -94,12 +103,20 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ } const { id } = await params - const existing = db.prepare('SELECT id FROM assets WHERE id = ?').get(id) + const existing = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) as Record | undefined if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 }) db.prepare('DELETE FROM assets WHERE id = ?').run(id) - db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'delete', 'asset', ?, ?)`) - .run(session.userId, id, null) + + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'delete', + entityType: 'asset', + entityId: parseInt(id), + details: { deleted: { id: existing.id, serial_number: existing.serial_number, node_name: existing.node_name } }, + ipAddress: getClientIP(_request) + }) return NextResponse.json({ success: true }) } diff --git a/src/app/api/assets/batch/route.ts b/src/app/api/assets/batch/route.ts index fd215bd..d005fc5 100644 --- a/src/app/api/assets/batch/route.ts +++ b/src/app/api/assets/batch/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' @@ -47,8 +48,14 @@ export async function POST(request: Request) { const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`) const result = stmt.run(...values, ...ids) - db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'batch_update', 'asset', ?, ?)`) - .run(session.userId, JSON.stringify({ ids, fields }), null) + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'batch_update', + entityType: 'asset', + details: { batch_update: { ids, fields, count: result.changes } }, + ipAddress: getClientIP(request) + }) return NextResponse.json({ updated: result.changes }) } catch (e) { diff --git a/src/app/api/assets/export/route.ts b/src/app/api/assets/export/route.ts index 8144ec5..15d82c6 100644 --- a/src/app/api/assets/export/route.ts +++ b/src/app/api/assets/export/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' import { exportAssetsToBuffer } from '@/lib/excel' +import { writeAuditLog, getClientIP } from '@/lib/audit' const FILTERABLE_FIELDS = new Set([ 'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position', @@ -27,11 +27,8 @@ const FILTERABLE_FIELDS = new Set([ ]) export async function GET(request: Request) { - const cookieStore = await cookies() - const token = cookieStore.get('session_assets')?.value - if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) const payload = await getSession() - if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!payload) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 }) const { searchParams } = new URL(request.url) @@ -114,6 +111,15 @@ export async function GET(request: Request) { const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' const assets = db.prepare(`SELECT * FROM assets ${where} ORDER BY id DESC`).all(...params) as Record[] + writeAuditLog({ + userId: payload.userId, + apiKeyId: null, + action: 'export', + entityType: 'asset', + details: { export: { count: assets.length, ids: ids.length > 0 ? ids : undefined } }, + ipAddress: getClientIP(request) + }) + const buffer = exportAssetsToBuffer(assets) const today = new Date() const dateStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}` diff --git a/src/app/api/assets/field-values/route.ts b/src/app/api/assets/field-values/route.ts index 0548a11..71782af 100644 --- a/src/app/api/assets/field-values/route.ts +++ b/src/app/api/assets/field-values/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' import db from '@/lib/db' import { getSession } from '@/lib/auth' @@ -21,11 +20,8 @@ const ALLOWED_FIELDS = new Set([ ]) export async function GET(request: Request) { - const cookieStore = await cookies() - const token = cookieStore.get('session_assets')?.value - if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 }) const payload = await getSession() - if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + if (!payload) return NextResponse.json({ error: '未授权' }, { status: 401 }) const { searchParams } = new URL(request.url) const field = searchParams.get('field') || '' diff --git a/src/app/api/assets/import/route.ts b/src/app/api/assets/import/route.ts index 38b1516..38bd268 100644 --- a/src/app/api/assets/import/route.ts +++ b/src/app/api/assets/import/route.ts @@ -4,6 +4,7 @@ import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' import { parseImportBuffer } from '@/lib/excel' +import { writeAuditLog, getClientIP } from '@/lib/audit' @@ -85,8 +86,14 @@ export async function POST(request: Request) { insertTransaction() - db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'import', 'asset', ?, ?)`) - .run(session.userId, JSON.stringify({ created, updated }), null) + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'import', + entityType: 'asset', + details: { import: { created, updated } }, + ipAddress: getClientIP(request) + }) return NextResponse.json({ created, updated, errors }) } catch (e) { diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 76a4f10..e9f6a57 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import db from '@/lib/db' import { getSession, verifyApiKey } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' @@ -166,8 +167,15 @@ export async function POST(request: Request) { const result = db.prepare(`INSERT INTO assets (${present.join(', ')}) VALUES (${placeholders})`).run(...values) - db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'create', 'asset', ?, ?)`) - .run(session.userId, result.lastInsertRowid, null) + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'create', + entityType: 'asset', + entityId: result.lastInsertRowid as number, + details: { created: { device_type: body.device_type, node_name: body.node_name, serial_number: body.serial_number } }, + ipAddress: getClientIP(request) + }) const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(result.lastInsertRowid) return NextResponse.json({ asset }, { status: 201 }) 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..b4e08cc --- /dev/null +++ b/src/app/api/audit-logs/cleanup/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import db from '@/lib/db' +import { getSession } from '@/lib/auth' +import { initDatabase } from '@/lib/db-schema' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function DELETE(request: NextRequest) { + initDatabase() + const session = await getSession() + if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + // 检查权限 + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined + if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) + + if (user.role !== 'admin') { + const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined + if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 }) + + const perms = JSON.parse(permissions.permissions) + if (!perms.includes('*') && !perms.includes('audit-logs:export')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + // 清理 180 天前的日志 + const result = db.prepare( + "DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')" + ).run() + + // 自审计:记录清理操作 + writeAuditLog({ + userId: session.userId, + 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..95c367c --- /dev/null +++ b/src/app/api/audit-logs/export/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import db from '@/lib/db' +import { getSession } from '@/lib/auth' +import { initDatabase } from '@/lib/db-schema' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function GET(request: NextRequest) { + initDatabase() + const session = await getSession() + if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + // 检查权限 + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined + if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) + + if (user.role !== 'admin') { + const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined + if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 }) + + const perms = JSON.parse(permissions.permissions) + if (!perms.includes('*') && !perms.includes('audit-logs:export')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + 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: session.userId, + 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..d4ee37d --- /dev/null +++ b/src/app/api/audit-logs/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server' +import db from '@/lib/db' +import { getSession } from '@/lib/auth' +import { initDatabase } from '@/lib/db-schema' + +export async function GET(request: NextRequest) { + initDatabase() + const session = await getSession() + if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + // 检查权限 + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined + if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 }) + + // admin 角色有所有权限,其他角色需要 audit-logs:read + if (user.role !== 'admin') { + const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined + if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 }) + + const perms = JSON.parse(permissions.permissions) + if (!perms.includes('*') && !perms.includes('audit-logs:read')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + 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 35c630e..a870d46 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,6 +4,7 @@ import db from '@/lib/db' import { verifyPassword, signJwt, hashPassword } from '@/lib/auth' import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' import { ldapAuth } from '@/lib/ldap' +import { writeAuditLog, getClientIP } from '@/lib/audit' import type { User } from '@/types' export async function POST(request: Request) { @@ -88,6 +89,16 @@ export async function POST(request: Request) { }) cookieStore.set(sharedCfg.name, sharedToken, sharedCfg) + writeAuditLog({ + userId: userId, + apiKeyId: null, + action: 'login', + entityType: 'auth', + entityId: userId, + details: { username, role }, + ipAddress: getClientIP(request) + }) + return NextResponse.json({ user: { id: userId, username, display_name: displayName, role }, }) diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 609bd1f..f208145 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,10 +1,25 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' +import { getSession } from '@/lib/auth' +import { writeAuditLog, getClientIP } from '@/lib/audit' -export async function POST() { +export async function POST(request: Request) { + const session = await getSession() const cookieStore = await cookies() cookieStore.set('session_assets', '', { maxAge: 0, path: '/' }) cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' }) + if (session) { + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'logout', + entityType: 'auth', + entityId: session.userId, + details: { username: session.username }, + ipAddress: getClientIP(request) + }) + } + return NextResponse.json({ success: true }) } diff --git a/src/app/api/roles/[id]/route.ts b/src/app/api/roles/[id]/route.ts index 77f9e21..2d94315 100644 --- a/src/app/api/roles/[id]/route.ts +++ b/src/app/api/roles/[id]/route.ts @@ -3,6 +3,7 @@ import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' import { initDatabase } from '@/lib/db-schema' +import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' const BUILTIN_ROLES = ['admin', 'editor', 'viewer'] @@ -33,11 +34,27 @@ export async function PUT( } const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) + + // 记录审计日志 + const afterRole = role as Record + const changes = diffObjects(existing, afterRole) + if (Object.keys(changes).length > 0) { + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'update', + entityType: 'role', + entityId: Number(id), + details: { changes }, + ipAddress: getClientIP(request) + }) + } + return NextResponse.json({ role }) } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { initDatabase() @@ -53,5 +70,16 @@ export async function DELETE( } db.prepare('DELETE FROM roles WHERE id = ?').run(id) + + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'delete', + entityType: 'role', + entityId: Number(id), + details: { deleted: { id: existing.id, name: existing.name, display_name: existing.display_name } }, + ipAddress: getClientIP(request) + }) + return NextResponse.json({ success: true }) } diff --git a/src/app/api/roles/route.ts b/src/app/api/roles/route.ts index 4566703..432581b 100644 --- a/src/app/api/roles/route.ts +++ b/src/app/api/roles/route.ts @@ -3,6 +3,7 @@ import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' import { initDatabase } from '@/lib/db-schema' +import { writeAuditLog, getClientIP } from '@/lib/audit' export async function GET() { initDatabase() @@ -33,5 +34,16 @@ export async function POST(request: NextRequest) { name, display_name, JSON.stringify(permissions || []) ) const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid) + + writeAuditLog({ + userId: session.userId, + 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 }) } diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 4a8ac51..58447dd 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import db from '@/lib/db' import { getSession, hashPassword } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' @@ -56,6 +57,18 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: values.push(id) db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values) + + const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active FROM users WHERE id = ?').get(id) as Record + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'update', + entityType: 'user', + entityId: parseInt(id), + details: { changes: diffObjects(existing as Record, newUser) }, + ipAddress: getClientIP(request) + }) + const user = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at, 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 @@ -86,5 +99,16 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ } db.prepare('DELETE FROM users WHERE id = ?').run(id) + + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'delete', + entityType: 'user', + entityId: parseInt(id), + details: { deleted: { id: existing.id, username: existing.username } }, + ipAddress: getClientIP(_request) + }) + return NextResponse.json({ success: true }) } diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 5195117..677296e 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import db from '@/lib/db' import { getSession, hashPassword } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' +import { writeAuditLog, getClientIP } from '@/lib/audit' @@ -42,6 +43,16 @@ export async function POST(request: Request) { const result = db.prepare("INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))") .run(username, passwordHash, display_name, email || null, role || 'viewer') + writeAuditLog({ + userId: session.userId, + apiKeyId: null, + action: 'create', + entityType: 'user', + entityId: result.lastInsertRowid as number, + details: { created: { username, display_name, role: role || 'viewer' } }, + ipAddress: getClientIP(request) + }) + const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid) return NextResponse.json({ user }, { status: 201 }) } catch (e) { diff --git a/src/app/api/users/sync-emails/route.ts b/src/app/api/users/sync-emails/route.ts index 4179084..436eb8d 100644 --- a/src/app/api/users/sync-emails/route.ts +++ b/src/app/api/users/sync-emails/route.ts @@ -1,10 +1,11 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import db from '@/lib/db' import { getSession } from '@/lib/auth' import { checkPermission } from '@/lib/permissions' import { ldapGetUserInfo } from '@/lib/ldap' +import { writeAuditLog, getClientIP } from '@/lib/audit' -export async function POST() { +export async function POST(request: NextRequest) { const session = await getSession() if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!checkPermission(session.role, 'users:write')) { @@ -28,5 +29,14 @@ export async function POST() { } } + writeAuditLog({ + userId: session.userId, + 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 86ee2bf..d306a67 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,40 +1,62 @@ 'use client' +import { useState, useEffect } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key } from 'lucide-react' +import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key, FileText } from 'lucide-react' const navItems = [ - { href: '/dashboard', label: '仪表盘', icon: LayoutDashboard }, - { href: '/assets', label: '设备管理', icon: Server }, - { href: '/assets/advanced-search', label: '高级查询', icon: Search }, + { href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null }, + { href: '/assets', label: '设备管理', icon: Server, perm: 'assets:read' }, + { href: '/assets/advanced-search', label: '高级查询', icon: Search, perm: 'assets:read' }, ] const settingsItems = [ - { href: '/settings/users', label: '用户管理', icon: Users }, - { href: '/settings/roles', label: '角色权限', icon: Shield }, - { href: '/settings/api-keys', label: 'API Key', icon: Key }, + { 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' }, ] -export default function Sidebar({ role }: { role?: string }) { +function hasAnyAdminPerm(permissions: string[]): boolean { + return permissions.includes('*') || permissions.some(p => + ['users:', 'roles:', 'api-keys:', 'audit-logs:'].some(prefix => p.startsWith(prefix)) + ) +} + +export default function Sidebar() { const pathname = usePathname() - const isAdmin = role === 'admin' + const [permissions, setPermissions] = useState([]) + + useEffect(() => { + fetch('/api/auth/me') + .then(r => r.json()) + .then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) }) + .catch(() => {}) + }, []) + + const canSee = (perm: string | null) => { + if (perm === null) return true + if (permissions.includes('*')) return true + return permissions.includes(perm) + } + return (