feat: 添加审计日志功能
- 新增 audit.ts 核心函数(writeAuditLog、diffObjects、getClientIP) - 新增审计日志 API(分页查询、CSV 导出) - 新增审计日志前端页面 - 更新 Sidebar 为权限驱动模式 - 添加审计权限配置 - 在所有写操作 API 中集成审计日志 - 更新 CLAUDE.md 添加审计日志开发规范
This commit is contained in:
parent
ba26ac97f5
commit
0f56c60313
16
CLAUDE.md
16
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')`。
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; color: 'green' | 'blue' | 'red' | 'gray' }> = {
|
||||
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<string, string> = {
|
||||
asset: '资产',
|
||||
user: '用户',
|
||||
role: '角色',
|
||||
api_key: 'API Key',
|
||||
audit_log: '审计日志',
|
||||
auth: '认证',
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
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<AuditLog | null>(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<AuditLog>[] = [
|
||||
{
|
||||
key: 'created_at',
|
||||
title: '时间',
|
||||
width: '160px',
|
||||
render: (r) => <span className="text-sm">{r.created_at}</span>
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
title: '用户',
|
||||
width: '120px',
|
||||
render: (r) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{r.username || '-'}</span>
|
||||
{r.api_key_id && <Badge color="yellow">[API]</Badge>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
title: '操作',
|
||||
width: '80px',
|
||||
render: (r) => {
|
||||
const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const }
|
||||
return <Badge color={info.color}>{info.label}</Badge>
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'entity_type',
|
||||
title: '对象',
|
||||
width: '80px',
|
||||
render: (r) => ENTITY_LABELS[r.entity_type] || r.entity_type
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
title: '详情',
|
||||
render: (r) => (
|
||||
<button
|
||||
onClick={() => setDetailLog(r)}
|
||||
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
|
||||
>
|
||||
{formatDetails(r)}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'ip_address',
|
||||
title: 'IP',
|
||||
width: '120px',
|
||||
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">审计日志</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">系统操作与事件记录,保留 180 天</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleExport}>导出</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(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"
|
||||
>
|
||||
<option value="">全部操作</option>
|
||||
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={entityFilter}
|
||||
onChange={(e) => { setEntityFilter(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"
|
||||
>
|
||||
<option value="">全部对象</option>
|
||||
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
<span className="text-slate-400">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7天</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30天</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180天</Button>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="ghost" onClick={resetFilters}>重置</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<Table columns={columns} data={logs} rowKey={(r) => String(r.id)} />
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-slate-500">
|
||||
第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情 Modal */}
|
||||
<Modal
|
||||
open={!!detailLog}
|
||||
onClose={() => setDetailLog(null)}
|
||||
title="审计日志详情"
|
||||
>
|
||||
{detailLog && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">时间:</span>
|
||||
<span>{detailLog.created_at}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">用户:</span>
|
||||
<span>{detailLog.username || '-'}</span>
|
||||
{detailLog.api_key_id && <Badge color="yellow" className="ml-1">[API]</Badge>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">操作:</span>
|
||||
<Badge color={ACTION_LABELS[detailLog.action]?.color || 'gray'}>
|
||||
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">对象:</span>
|
||||
<span>{ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type}</span>
|
||||
{detailLog.entity_id && <span className="ml-1">#{detailLog.entity_id}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">IP:</span>
|
||||
<span className="font-mono">{detailLog.ip_address || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLog.details && (
|
||||
<div>
|
||||
<span className="text-sm text-slate-500">详情:</span>
|
||||
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(JSON.parse(detailLog.details), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 失败'
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, newAsset as Record<string, unknown>) },
|
||||
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<string, unknown> | 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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[]
|
||||
|
||||
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')}`
|
||||
|
|
|
|||
|
|
@ -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') || ''
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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<string, unknown>) => [
|
||||
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"`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
writeAuditLog({
|
||||
userId: session.userId,
|
||||
apiKeyId: null,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: parseInt(id),
|
||||
details: { changes: diffObjects(existing as Record<string, unknown>, 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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string[]>([])
|
||||
|
||||
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 (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
||||
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">资产管理系统</span>
|
||||
</div>
|
||||
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
{navItems.filter(item => canSee(item.perm)).map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
const Icon = item.icon
|
||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
||||
})}
|
||||
{isAdmin && (
|
||||
{hasAnyAdminPerm(permissions) && (
|
||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
<Settings size={14} />系统设置
|
||||
</div>
|
||||
{settingsItems.map((item) => {
|
||||
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
const Icon = item.icon
|
||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import db from './db'
|
||||
|
||||
interface AuditLogOptions {
|
||||
userId?: number | null
|
||||
apiKeyId?: number | null
|
||||
action: string
|
||||
entityType: string
|
||||
entityId?: number | null
|
||||
details?: Record<string, unknown> | null
|
||||
ipAddress?: string | null
|
||||
}
|
||||
|
||||
export function writeAuditLog(opts: AuditLogOptions): void {
|
||||
const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts
|
||||
|
||||
try {
|
||||
// 每日清理(每天首次写入触发)
|
||||
// 注意:禁止使用 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<string, unknown>,
|
||||
after: Record<string, unknown>
|
||||
): Record<string, { from: unknown; to: unknown }> {
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {}
|
||||
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
|
||||
}
|
||||
|
|
@ -87,6 +87,11 @@ export function initDatabase() {
|
|||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_node_name ON assets(node_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_business_ip ON assets(business_ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_device_type ON assets(device_type);
|
||||
|
|
|
|||
Loading…
Reference in New Issue