feat: 审计日志功能 + 修复 initDatabase 调用遗漏

This commit is contained in:
gitadmin 2026-06-24 15:43:01 +08:00
parent 0f56c60313
commit 67cba23ce3
8 changed files with 360 additions and 30 deletions

View File

@ -1,5 +1,17 @@
# 变更日志 # 变更日志
## 2026-06-24
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 6 个表 8 个列
## 2026-06-23
- [新增] 审计日志详情可视化:字段变更用表格对比(旧值/新值),新建/删除用卡片,登录信息用键值对
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
- [优化] 审计日志列表详情列:显示操作摘要而非"-",支持 message/keys/username 等格式
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
## 2026-05-18 ## 2026-05-18
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航 - [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航

View File

@ -240,9 +240,10 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
ipAddress: getClientIP(request) 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')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`
--- ---

View File

@ -53,6 +53,14 @@ export default function AuditLogsPage() {
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = 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 // 详情 Modal
const [detailLog, setDetailLog] = useState<AuditLog | null>(null) const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
@ -79,6 +87,49 @@ export default function AuditLogsPage() {
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo]) 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() { function resetFilters() {
setActionFilter('') setActionFilter('')
setEntityFilter('') setEntityFilter('')
@ -110,7 +161,9 @@ export default function AuditLogsPage() {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url 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() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
@ -129,23 +182,200 @@ export default function AuditLogsPage() {
if (details.export) return `导出 ${details.export.count}` if (details.export) return `导出 ${details.export.count}`
if (details.batch_update) return `批量更新 ${details.batch_update.count}` if (details.batch_update) return `批量更新 ${details.batch_update.count}`
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新` 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 '-' return '-'
} catch { 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>[] = [ const columns: Column<AuditLog>[] = [
{ {
key: 'created_at', key: 'created_at',
title: '时间', title: '时间',
width: '160px', width: '170px',
render: (r) => <span className="text-sm">{r.created_at}</span> render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
}, },
{ {
key: 'username', key: 'username',
title: '用户', title: '用户',
width: '120px', width: '100px',
render: (r) => ( render: (r) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 whitespace-nowrap">
<span>{r.username || '-'}</span> <span>{r.username || '-'}</span>
{r.api_key_id && <Badge color="yellow">[API]</Badge>} {r.api_key_id && <Badge color="yellow">[API]</Badge>}
</div> </div>
@ -154,17 +384,17 @@ export default function AuditLogsPage() {
{ {
key: 'action', key: 'action',
title: '操作', title: '操作',
width: '80px', width: '70px',
render: (r) => { render: (r) => {
const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const } 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', key: 'entity_type',
title: '对象', title: '对象',
width: '80px', 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', key: 'details',
@ -191,13 +421,40 @@ export default function AuditLogsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleExport}></Button> <Button size="sm" variant="secondary" onClick={handleExport}></Button>
</div> </div>
</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"> <div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<select <select
@ -293,7 +550,7 @@ export default function AuditLogsPage() {
<div> <div>
<span className="text-slate-500"></span> <span className="text-slate-500"></span>
<span>{detailLog.username || '-'}</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>
<div> <div>
<span className="text-slate-500"></span> <span className="text-slate-500"></span>
@ -313,11 +570,8 @@ export default function AuditLogsPage() {
</div> </div>
{detailLog.details && ( {detailLog.details && (
<div> <div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-sm text-slate-500"></span> {renderDetails(JSON.parse(detailLog.details))}
<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>
)} )}
</div> </div>

View File

@ -4,6 +4,57 @@ import { getSession } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { writeAuditLog, getClientIP } from '@/lib/audit' 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) { export async function DELETE(request: NextRequest) {
initDatabase() initDatabase()
const session = await getSession() 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( 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() ).run()
// 自审计:记录清理操作
writeAuditLog({ writeAuditLog({
userId: session.userId, userId: session.userId,
action: 'cleanup', action: 'cleanup',
entityType: 'audit_log', entityType: 'audit_log',
details: { details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
cleanup: {
deleted: result.changes
}
},
ipAddress: getClientIP(request) ipAddress: getClientIP(request)
}) })
return NextResponse.json({ deleted: result.changes }) return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
} }

View File

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import db from '@/lib/db' import db from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { verifyPassword, signJwt, hashPassword } from '@/lib/auth' import { verifyPassword, signJwt, hashPassword } from '@/lib/auth'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
import { ldapAuth } from '@/lib/ldap' import { ldapAuth } from '@/lib/ldap'
@ -9,6 +10,7 @@ import type { User } from '@/types'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
initDatabase()
const { username, password } = await request.json() const { username, password } = await request.json()
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import db from '@/lib/db' import db from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getSession, hashPassword } from '@/lib/auth' import { getSession, hashPassword } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' 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 }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
initDatabase()
const session = await getSession() const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import db from '@/lib/db' import db from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getSession, hashPassword } from '@/lib/auth' import { getSession, hashPassword } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit' import { writeAuditLog, getClientIP } from '@/lib/audit'
@ -7,6 +8,7 @@ import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() { export async function GET() {
initDatabase()
const session = await getSession() const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'users:read')) { if (!checkPermission(session.role, 'users:read')) {

View File

@ -24,18 +24,24 @@ export function writeAuditLog(opts: AuditLogOptions): void {
).get() as { value: string } | undefined ).get() as { value: string } | undefined
if (!lastCleanup || lastCleanup.value !== today) { 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( 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() ).run()
db.prepare( db.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)" "INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)"
).run(today) ).run(today)
} }
// 写入审计日志 // 写入审计日志(显式设置 created_at 为北京时间)
db.prepare(` db.prepare(`
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address) INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours'))
`).run( `).run(
userId ?? null, userId ?? null,
apiKeyId ?? null, apiKeyId ?? null,