feat: 添加审计日志功能
- 新增 audit.ts 核心函数(writeAuditLog、diffObjects、getClientIP) - 新增审计日志 API(分页查询、CSV 导出) - 新增审计日志前端页面 - 更新 Sidebar 添加审计日志菜单项 - 添加审计权限配置 - 在所有写操作 API 中集成审计日志 - 更新 CLAUDE.md 添加审计日志开发规范
This commit is contained in:
parent
5f0c312e9c
commit
1c1555e417
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -48,6 +48,7 @@ npm run import # 导入工单
|
||||||
| `src/lib/docx-export.ts` | Word 文档生成 |
|
| `src/lib/docx-export.ts` | Word 文档生成 |
|
||||||
| `src/lib/excel.ts` | Excel 解析与导出 |
|
| `src/lib/excel.ts` | Excel 解析与导出 |
|
||||||
| `src/lib/pdf.ts` | PDF 生成(puppeteer) |
|
| `src/lib/pdf.ts` | PDF 生成(puppeteer) |
|
||||||
|
| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) |
|
||||||
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
|
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
|
||||||
| `src/types/report.ts` | ReportType / ReportData |
|
| `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()` 校验
|
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验
|
||||||
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
||||||
- **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')`
|
- **权限格式**:`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(北京时间)。两处必须遵守:
|
- **日期处理(时区规范)**:整个系统统一使用 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')}`
|
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')`。
|
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||||
|
|
|
||||||
|
|
@ -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<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' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
|
ticket: '工单',
|
||||||
|
report: '报告',
|
||||||
|
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 '-' }
|
||||||
|
}
|
||||||
|
|
||||||
|
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" onClick={handleExport}>导出</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 过滤器 */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<Card>
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<Table headers={['时间', '用户', '操作', '对象', '详情', 'IP']}>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-sm">{log.created_at}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{log.username || '-'}</span>
|
||||||
|
{log.api_key_id && <Badge variant="warning">[API]</Badge>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={ACTION_LABELS[log.action]?.color === 'green' ? 'success' : ACTION_LABELS[log.action]?.color === 'blue' ? 'info' : ACTION_LABELS[log.action]?.color === 'red' ? 'danger' : 'default'}>
|
||||||
|
{ACTION_LABELS[log.action]?.label || log.action}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{ENTITY_LABELS[log.entity_type] || log.entity_type}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailLog(log)}
|
||||||
|
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(log)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono">{log.ip_address || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-12 text-center text-slate-500">暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{total > pageSize && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
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="secondary"
|
||||||
|
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 variant="warning" className="ml-1">[API]</Badge>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">操作:</span>
|
||||||
|
<Badge variant={ACTION_LABELS[detailLog.action]?.color === 'green' ? 'success' : ACTION_LABELS[detailLog.action]?.color === 'blue' ? 'info' : ACTION_LABELS[detailLog.action]?.color === 'red' ? 'danger' : 'default'}>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,8 @@ const allPermissions = [
|
||||||
{ key: 'roles:write', label: '编辑角色' },
|
{ key: 'roles:write', label: '编辑角色' },
|
||||||
{ key: 'api-keys:read', label: '查看 API Key' },
|
{ key: 'api-keys:read', label: '查看 API Key' },
|
||||||
{ key: 'api-keys:write', label: '编辑 API Key' },
|
{ key: 'api-keys:write', label: '编辑 API Key' },
|
||||||
|
{ key: 'audit-logs:read', label: '查看审计日志' },
|
||||||
|
{ key: 'audit-logs:export', label: '导出审计日志' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { verifyToken } from '@/lib/auth'
|
import { verifyToken } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
@ -49,9 +50,22 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
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 })
|
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)
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth'
|
import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
const cookieStore = await cookies()
|
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 (?, ?, ?, ?, ?)'
|
'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)'
|
||||||
).run(name, keyHash, perms, expires_at || null, session.id)
|
).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 })
|
return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '创建失败'
|
const msg = e instanceof Error ? e.message : '创建失败'
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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<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: 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"`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { ldapAuth } from '@/lib/ldap'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { getUserPermissions } from '@/lib/permissions'
|
import { getUserPermissions } from '@/lib/permissions'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -84,6 +85,17 @@ export async function POST(request: NextRequest) {
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
user: { id: userId, username, display_name: displayName, role, permissions: getUserPermissions(role) },
|
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, {
|
response.cookies.set('session_issue', localToken, {
|
||||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',
|
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',
|
||||||
maxAge: 7 * 24 * 60 * 60, path: '/',
|
maxAge: 7 * 24 * 60 * 60, path: '/',
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
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('session_issue', '', { maxAge: 0, path: '/' })
|
||||||
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||||
return r
|
return r
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { generateMonthlyReport } from '@/lib/monthly-report'
|
import { generateMonthlyReport } from '@/lib/monthly-report'
|
||||||
import { generateWeeklyReport } from '@/lib/weekly-report'
|
import { generateWeeklyReport } from '@/lib/weekly-report'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -47,6 +48,17 @@ export async function POST(
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
|
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 })
|
return NextResponse.json({ report: updated })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '生成失败'
|
const msg = e instanceof Error ? e.message : '生成失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -60,6 +61,17 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// 3. 返回报告(状态为 ready),前端自动跳转预览页
|
// 3. 返回报告(状态为 ready),前端自动跳转预览页
|
||||||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId)
|
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 })
|
return NextResponse.json({ report }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '创建失败'
|
const msg = e instanceof Error ? e.message : '创建失败'
|
||||||
|
|
@ -95,6 +107,16 @@ export async function DELETE(request: NextRequest) {
|
||||||
const placeholders = ids.map(() => '?').join(',')
|
const placeholders = ids.map(() => '?').join(',')
|
||||||
db.prepare(`DELETE FROM reports WHERE id IN (${placeholders})`).run(...ids)
|
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 })
|
return NextResponse.json({ success: true, deleted: ids.length })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '批量删除失败'
|
const msg = e instanceof Error ? e.message : '批量删除失败'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
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
|
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
|
||||||
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
||||||
|
|
||||||
|
// 获取更新前的完整记录用于审计日志
|
||||||
|
const beforeUpdate = { ...existing }
|
||||||
|
|
||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
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)
|
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<string, unknown>) },
|
||||||
|
ipAddress: getClientIP(request),
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ role })
|
return NextResponse.json({ role })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
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 })
|
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)
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '删除失败'
|
const msg = e instanceof Error ? e.message : '删除失败'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
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 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)
|
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 })
|
return NextResponse.json({ role }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '创建失败'
|
const msg = e instanceof Error ? e.message : '创建失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getAssetByIp } from '@/lib/assets-client'
|
import { getAssetByIp } from '@/lib/assets-client'
|
||||||
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
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 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 })
|
return NextResponse.json({ ticket })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
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
|
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 })
|
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<string, unknown> | undefined
|
||||||
|
|
||||||
// 非管理员不可删除已办工单,非创建人不可删除待办工单
|
// 非管理员不可删除已办工单,非创建人不可删除待办工单
|
||||||
const completedStatuses = ['resolved', 'closed']
|
const completedStatuses = ['resolved', 'closed']
|
||||||
if (user.role !== 'admin') {
|
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)
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '删除失败'
|
const msg = e instanceof Error ? e.message : '删除失败'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -44,6 +45,18 @@ export async function PUT(request: NextRequest) {
|
||||||
updated += result.changes
|
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 })
|
return NextResponse.json({ updated, total: updates.length })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '批量更新失败'
|
const msg = e instanceof Error ? e.message : '批量更新失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { exportTicketsToExcel } from '@/lib/excel'
|
import { exportTicketsToExcel } from '@/lib/excel'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,6 +48,16 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const buffer = exportTicketsToExcel(tickets)
|
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), {
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { verifyApiKey } from '@/lib/auth'
|
import { verifyApiKey } from '@/lib/auth'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
function verifyEnvApiKey(key: string): boolean {
|
function verifyEnvApiKey(key: string): boolean {
|
||||||
const allowed = process.env.ALLOWED_API_KEYS || ''
|
const allowed = process.env.ALLOWED_API_KEYS || ''
|
||||||
|
|
@ -27,6 +28,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const authHeader = request.headers.get('authorization')
|
const authHeader = request.headers.get('authorization')
|
||||||
let authenticated = false
|
let authenticated = false
|
||||||
|
let apiKeyInfo: { id: number; name: string; permissions: string[] } | null = null
|
||||||
|
|
||||||
if (authHeader?.startsWith('Bearer ak_')) {
|
if (authHeader?.startsWith('Bearer ak_')) {
|
||||||
const key = authHeader.slice(7)
|
const key = authHeader.slice(7)
|
||||||
|
|
@ -34,7 +36,10 @@ export async function POST(request: NextRequest) {
|
||||||
authenticated = true
|
authenticated = true
|
||||||
} else {
|
} else {
|
||||||
const keyInfo = verifyApiKey(key)
|
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)
|
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 })
|
return NextResponse.json({ ticket, created: true }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '创建失败'
|
const msg = e instanceof Error ? e.message : '创建失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { parseExcelTickets } from '@/lib/excel'
|
import { parseExcelTickets } from '@/lib/excel'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
function validateTicketNo(ticketNo: string): string | null {
|
function validateTicketNo(ticketNo: string): string | null {
|
||||||
if (!/^\d{14}$/.test(ticketNo)) {
|
if (!/^\d{14}$/.test(ticketNo)) {
|
||||||
|
|
@ -120,6 +121,20 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
transaction()
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
imported: imported.length,
|
imported: imported.length,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
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)
|
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid)
|
||||||
return NextResponse.json({ ticket }, { status: 201 })
|
return NextResponse.json({ ticket }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser, hashPassword } from '@/lib/auth'
|
import { getCurrentUser, hashPassword } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
initDatabase()
|
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
|
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 })
|
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<string, unknown>
|
||||||
|
|
||||||
// 禁止修改系统保留用户的角色
|
// 禁止修改系统保留用户的角色
|
||||||
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
|
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
|
||||||
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||||
|
|
@ -59,6 +63,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
last_login_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
|
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)
|
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<string, unknown>) },
|
||||||
|
ipAddress: getClientIP(request),
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ user: updated })
|
return NextResponse.json({ user: updated })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
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 })
|
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapshot = db.prepare('SELECT id, username, display_name, role FROM users WHERE id = ?').get(id) as Record<string, unknown>
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '删除失败'
|
const msg = e instanceof Error ? e.message : '删除失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { hashPassword } from '@/lib/auth'
|
import { hashPassword } from '@/lib/auth'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,6 +49,17 @@ export async function POST(request: NextRequest) {
|
||||||
).run(username, hash, display_name, email || null, role || 'viewer')
|
).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)
|
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 })
|
return NextResponse.json({ user: newUser }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '创建失败'
|
const msg = e instanceof Error ? e.message : '创建失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { ldapGetUserInfo } from '@/lib/ldap'
|
import { ldapGetUserInfo } from '@/lib/ldap'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
export async function POST(_request: NextRequest) {
|
export async function POST(_request: NextRequest) {
|
||||||
initDatabase()
|
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 })
|
return NextResponse.json({ synced, failed, total: users.length })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const settingsItems = [
|
||||||
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
|
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
|
||||||
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles: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/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 {
|
function hasAnyAdminPerm(permissions: string[]): boolean {
|
||||||
|
|
|
||||||
|
|
@ -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<string, unknown> | 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<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
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,19 @@ export function initDatabase(): void {
|
||||||
}
|
}
|
||||||
} catch { /* 迁移失败则保持原样 */ }
|
} 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')
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue