feat: 审计日志功能 + 修复 initDatabase 调用遗漏
This commit is contained in:
parent
0f56c60313
commit
67cba23ce3
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,5 +1,17 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-06-24
|
||||
|
||||
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 6 个表 8 个列
|
||||
|
||||
## 2026-06-23
|
||||
|
||||
- [新增] 审计日志详情可视化:字段变更用表格对比(旧值/新值),新建/删除用卡片,登录信息用键值对
|
||||
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
|
||||
- [优化] 审计日志列表详情列:显示操作摘要而非"-",支持 message/keys/username 等格式
|
||||
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行
|
||||
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
|
||||
|
||||
## 2026-05-18
|
||||
|
||||
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
|
||||
|
|
|
|||
|
|
@ -240,9 +240,10 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
|||
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')}`
|
||||
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ export default function AuditLogsPage() {
|
|||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
// 保留天数设置
|
||||
const [retentionSaved, setRetentionSaved] = useState(180)
|
||||
const [retentionInput, setRetentionInput] = useState('180')
|
||||
const [retentionSaving, setRetentionSaving] = useState(false)
|
||||
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
|
||||
const [retentionError, setRetentionError] = useState(false)
|
||||
const retentionDirty = retentionInput !== String(retentionSaved)
|
||||
|
||||
// 详情 Modal
|
||||
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
|
||||
|
||||
|
|
@ -79,6 +87,49 @@ export default function AuditLogsPage() {
|
|||
|
||||
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
|
||||
|
||||
async function fetchRetentionDays() {
|
||||
try {
|
||||
const res = await fetch('/api/audit-logs/cleanup')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRetentionSaved(data.days)
|
||||
setRetentionInput(String(data.days))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveRetentionDays() {
|
||||
const days = parseInt(retentionInput, 10)
|
||||
if (isNaN(days) || days < 30 || days > 365) {
|
||||
setRetentionError(true)
|
||||
setTimeout(() => setRetentionError(false), 2000)
|
||||
return
|
||||
}
|
||||
setRetentionSaving(true)
|
||||
setRetentionError(false)
|
||||
try {
|
||||
const res = await fetch('/api/audit-logs/cleanup', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days })
|
||||
})
|
||||
if (res.ok) {
|
||||
setRetentionSaved(days)
|
||||
setRetentionSavedFeedback(true)
|
||||
setTimeout(() => setRetentionSavedFeedback(false), 2000)
|
||||
} else {
|
||||
setRetentionError(true)
|
||||
setTimeout(() => setRetentionError(false), 2000)
|
||||
}
|
||||
} catch {
|
||||
setRetentionError(true)
|
||||
setTimeout(() => setRetentionError(false), 2000)
|
||||
}
|
||||
setRetentionSaving(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRetentionDays() }, [])
|
||||
|
||||
function resetFilters() {
|
||||
setActionFilter('')
|
||||
setEntityFilter('')
|
||||
|
|
@ -110,7 +161,9 @@ export default function AuditLogsPage() {
|
|||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
const now = new Date()
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
a.download = `audit-logs-${dateStr}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
|
@ -129,23 +182,200 @@ export default function AuditLogsPage() {
|
|||
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} 更新`
|
||||
if (details.message) return details.message
|
||||
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
|
||||
if (details.username) return `用户 ${details.username} 登录`
|
||||
if (details.retention_days !== undefined) return `保留 ${details.retention_days} 天`
|
||||
if (details.cleanup) return `清理 ${details.cleanup.deleted} 条`
|
||||
return '-'
|
||||
} catch { return '-' }
|
||||
}
|
||||
|
||||
// 字段名汉化映射
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
name: '名称', status: '状态', type: '类型', category: '分类',
|
||||
model: '型号', sn: '序列号', location: '位置', department: '部门',
|
||||
ip: 'IP', mac: 'MAC', os: '操作系统', cpu: 'CPU', memory: '内存',
|
||||
disk: '硬盘', purchase_date: '采购日期', warranty_date: '质保到期',
|
||||
description: '描述', notes: '备注', email: '邮箱', role: '角色',
|
||||
username: '用户名', permissions: '权限', count: '数量',
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (typeof val === 'object') return JSON.stringify(val)
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function renderDetails(details: Record<string, unknown>) {
|
||||
// 字段变更
|
||||
if (details.changes && typeof details.changes === 'object') {
|
||||
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 修改了以下字段:</p>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-100 dark:bg-slate-700">
|
||||
<th className="px-3 py-2 text-left font-medium">字段</th>
|
||||
<th className="px-3 py-2 text-left font-medium">旧值</th>
|
||||
<th className="px-3 py-2 text-left font-medium">新值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(changes).map(([key, val]) => (
|
||||
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
|
||||
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
|
||||
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
|
||||
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 新建记录
|
||||
if (details.created && typeof details.created === 'object') {
|
||||
const data = details.created as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 新建记录:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(data).map(([key, val]) => (
|
||||
<div key={key} className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||
<span>{formatValue(val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
if (details.deleted && typeof details.deleted === 'object') {
|
||||
const data = details.deleted as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 删除记录:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(data).map(([key, val]) => (
|
||||
<div key={key} className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||
<span>{formatValue(val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 批量更新
|
||||
if (details.batch_update && typeof details.batch_update === 'object') {
|
||||
const data = details.batch_update as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 批量更新 {String(data.count)} 条记录</p>
|
||||
{data.ids ? <p className="text-sm text-slate-500">涉及 ID:{String(data.ids)}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 导入
|
||||
if (details.import && typeof details.import === 'object') {
|
||||
const data = details.import as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 导入操作</p>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span>新增:{String(data.created)} 条</span>
|
||||
<span>更新:{String(data.updated)} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出
|
||||
if (details.export && typeof details.export === 'object') {
|
||||
const data = details.export as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 导出 {String(data.count)} 条记录</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 登录信息
|
||||
if (details.username && typeof details.username === 'string') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 登录信息:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">用户名:</span>
|
||||
<span>{details.username}</span>
|
||||
</div>
|
||||
{details.role && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">角色:</span>
|
||||
<span>{String(details.role)}</span>
|
||||
</div>
|
||||
)}
|
||||
{details.displayName && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">显示名:</span>
|
||||
<span>{String(details.displayName)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 保留天数设置
|
||||
if (details.retention_days !== undefined) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">⚙️ 保留天数设置:</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">日志保留期限调整为 <span className="font-medium">{String(details.retention_days)}</span> 天</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 清理操作
|
||||
if (details.cleanup) {
|
||||
const data = details.cleanup as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 清理操作:</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">已清理 {String(data.deleted)} 条过期日志</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 兜底:格式化 JSON
|
||||
return (
|
||||
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(details, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: Column<AuditLog>[] = [
|
||||
{
|
||||
key: 'created_at',
|
||||
title: '时间',
|
||||
width: '160px',
|
||||
render: (r) => <span className="text-sm">{r.created_at}</span>
|
||||
width: '170px',
|
||||
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
title: '用户',
|
||||
width: '120px',
|
||||
width: '100px',
|
||||
render: (r) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<span>{r.username || '-'}</span>
|
||||
{r.api_key_id && <Badge color="yellow">[API]</Badge>}
|
||||
</div>
|
||||
|
|
@ -154,17 +384,17 @@ export default function AuditLogsPage() {
|
|||
{
|
||||
key: 'action',
|
||||
title: '操作',
|
||||
width: '80px',
|
||||
width: '70px',
|
||||
render: (r) => {
|
||||
const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const }
|
||||
return <Badge color={info.color}>{info.label}</Badge>
|
||||
return <span className="whitespace-nowrap"><Badge color={info.color}>{info.label}</Badge></span>
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'entity_type',
|
||||
title: '对象',
|
||||
width: '80px',
|
||||
render: (r) => ENTITY_LABELS[r.entity_type] || r.entity_type
|
||||
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
|
|
@ -191,13 +421,40 @@ export default function AuditLogsPage() {
|
|||
<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>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">系统操作与事件记录</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleExport}>导出</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保留天数设置 */}
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">保留天数:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={30}
|
||||
max={365}
|
||||
value={retentionInput}
|
||||
onChange={(e) => setRetentionInput(e.target.value)}
|
||||
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
|
||||
/>
|
||||
<span className="text-xs text-slate-400">(30-365 天,默认 180 天)</span>
|
||||
{retentionSavedFeedback ? (
|
||||
<span className="text-xs font-medium text-green-600 dark:text-green-400">✓ 已保存</span>
|
||||
) : retentionError ? (
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">✗ 保存失败</span>
|
||||
) : retentionDirty ? (
|
||||
<button
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
onClick={saveRetentionDays}
|
||||
disabled={retentionSaving}
|
||||
>
|
||||
{retentionSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||||
<select
|
||||
|
|
@ -293,7 +550,7 @@ export default function AuditLogsPage() {
|
|||
<div>
|
||||
<span className="text-slate-500">用户:</span>
|
||||
<span>{detailLog.username || '-'}</span>
|
||||
{detailLog.api_key_id && <Badge color="yellow" className="ml-1">[API]</Badge>}
|
||||
{detailLog.api_key_id && <span className="ml-1"><Badge color="yellow">[API]</Badge></span>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">操作:</span>
|
||||
|
|
@ -313,11 +570,8 @@ export default function AuditLogsPage() {
|
|||
</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 className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
{renderDetails(JSON.parse(detailLog.details))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,57 @@ import { getSession } from '@/lib/auth'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// 获取保留天数配置
|
||||
export async function GET() {
|
||||
initDatabase()
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const row = db.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||
).get() as { value: string } | undefined
|
||||
|
||||
return NextResponse.json({ days: parseInt(row?.value || '180', 10) })
|
||||
}
|
||||
|
||||
// 更新保留天数配置
|
||||
export async function PUT(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 body = await request.json()
|
||||
const days = Math.max(30, Math.min(365, parseInt(body.days, 10)))
|
||||
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)"
|
||||
).run(String(days))
|
||||
|
||||
writeAuditLog({
|
||||
userId: session.userId,
|
||||
action: 'update',
|
||||
entityType: 'audit_log',
|
||||
details: { retention_days: days },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
return NextResponse.json({ days })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
initDatabase()
|
||||
const session = await getSession()
|
||||
|
|
@ -23,23 +74,23 @@ export async function DELETE(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// 清理 180 天前的日志
|
||||
// 从 settings 读取保留天数
|
||||
const retentionRow = db.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||
).get() as { value: string } | undefined
|
||||
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
|
||||
|
||||
const result = db.prepare(
|
||||
"DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')"
|
||||
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||
).run()
|
||||
|
||||
// 自审计:记录清理操作
|
||||
writeAuditLog({
|
||||
userId: session.userId,
|
||||
action: 'cleanup',
|
||||
entityType: 'audit_log',
|
||||
details: {
|
||||
cleanup: {
|
||||
deleted: result.changes
|
||||
}
|
||||
},
|
||||
details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
return NextResponse.json({ deleted: result.changes })
|
||||
return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { verifyPassword, signJwt, hashPassword } from '@/lib/auth'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
||||
import { ldapAuth } from '@/lib/ldap'
|
||||
|
|
@ -9,6 +10,7 @@ import type { User } from '@/types'
|
|||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
initDatabase()
|
||||
const { username, password } = await request.json()
|
||||
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import db from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getSession, hashPassword } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||
|
|
@ -7,6 +8,7 @@ import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
|||
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
initDatabase()
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import db from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getSession, hashPassword } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
|
@ -7,6 +8,7 @@ import { writeAuditLog, getClientIP } from '@/lib/audit'
|
|||
|
||||
|
||||
export async function GET() {
|
||||
initDatabase()
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'users:read')) {
|
||||
|
|
|
|||
|
|
@ -24,18 +24,24 @@ export function writeAuditLog(opts: AuditLogOptions): void {
|
|||
).get() as { value: string } | undefined
|
||||
|
||||
if (!lastCleanup || lastCleanup.value !== today) {
|
||||
// 从 settings 读取保留天数,默认 180 天
|
||||
const retentionRow = db.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||
).get() as { value: string } | undefined
|
||||
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
|
||||
|
||||
db.prepare(
|
||||
"DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')"
|
||||
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||
).run()
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)"
|
||||
).run(today)
|
||||
}
|
||||
|
||||
// 写入审计日志
|
||||
// 写入审计日志(显式设置 created_at 为北京时间)
|
||||
db.prepare(`
|
||||
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours'))
|
||||
`).run(
|
||||
userId ?? null,
|
||||
apiKeyId ?? null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue