diff --git a/migrations/1730000027000_system_logs.cjs b/migrations/1730000027000_system_logs.cjs new file mode 100644 index 0000000..5fd480b --- /dev/null +++ b/migrations/1730000027000_system_logs.cjs @@ -0,0 +1,34 @@ +/* eslint-disable */ +/** + * 系统操作审计日志:记录全系统的写操作(谁/何时/何角色/动作/目标/方法/路径/结果/IP/耗时)。 + * 供系统管理员审计。仅记录状态变更类请求(POST/PUT/DELETE)与登录。 + */ + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS system_logs ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + actor_id TEXT, + actor_name TEXT, + role TEXT, + action TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + target_id TEXT, + status INTEGER, + success BOOLEAN, + duration_ms INTEGER, + ip TEXT, + query TEXT, + detail JSONB + ); + CREATE INDEX IF NOT EXISTS idx_system_logs_ts ON system_logs(ts DESC); + CREATE INDEX IF NOT EXISTS idx_system_logs_actor ON system_logs(actor_id); + CREATE INDEX IF NOT EXISTS idx_system_logs_action ON system_logs(action); + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP TABLE IF EXISTS system_logs;`); +}; diff --git a/src/persistence/index.ts b/src/persistence/index.ts index 831fee6..806d720 100644 --- a/src/persistence/index.ts +++ b/src/persistence/index.ts @@ -22,6 +22,7 @@ export * from './drafts.js'; export * from './users.js'; export * from './approvalConfig.js'; export * from './assignments.js'; +export * from './systemLogs.js'; export * from './settings.js'; export * from './regionRates.js'; export * from './rejectReasons.js'; diff --git a/src/persistence/systemLogs.ts b/src/persistence/systemLogs.ts new file mode 100644 index 0000000..d791010 --- /dev/null +++ b/src/persistence/systemLogs.ts @@ -0,0 +1,105 @@ +/** + * 系统操作审计日志持久化。 + */ +import type pg from 'pg'; + +export interface SystemLogEntry { + actorId: string | null; + actorName: string | null; + role: string | null; + action: string; + method: string; + path: string; + targetId: string | null; + status: number | null; + success: boolean | null; + durationMs: number | null; + ip: string | null; + query: string | null; + detail?: unknown; +} + +export interface SystemLogRow extends SystemLogEntry { + id: number; + ts: string; +} + +/** 写入一条系统审计日志。 */ +export async function insertSystemLog(pool: pg.Pool, e: SystemLogEntry): Promise { + await pool.query( + `INSERT INTO system_logs(actor_id, actor_name, role, action, method, path, target_id, status, success, duration_ms, ip, query, detail) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + [ + e.actorId, e.actorName, e.role, e.action, e.method, e.path, e.targetId, + e.status, e.success, e.durationMs, e.ip, e.query, + e.detail !== undefined ? JSON.stringify(e.detail) : null, + ], + ); +} + +export interface SystemLogQuery { + limit: number; + offset: number; + actorId?: string; + action?: string; + q?: string; + from?: string; + to?: string; + success?: boolean; +} + +/** 分页查询系统审计日志(按时间倒序)。 */ +export async function querySystemLogs( + pool: pg.Pool, + opts: SystemLogQuery, +): Promise<{ items: SystemLogRow[]; total: number }> { + const where: string[] = []; + const params: unknown[] = []; + if (opts.actorId !== undefined && opts.actorId !== '') { params.push(opts.actorId); where.push(`actor_id = $${params.length}`); } + if (opts.action !== undefined && opts.action !== '') { params.push(opts.action); where.push(`action = $${params.length}`); } + if (opts.success !== undefined) { params.push(opts.success); where.push(`success = $${params.length}`); } + if (opts.from !== undefined && opts.from !== '') { params.push(opts.from); where.push(`ts >= $${params.length}`); } + if (opts.to !== undefined && opts.to !== '') { params.push(opts.to); where.push(`ts <= $${params.length}`); } + if (opts.q !== undefined && opts.q.trim() !== '') { + params.push(`%${opts.q.trim()}%`); + const i = params.length; + where.push(`(path ILIKE $${i} OR actor_name ILIKE $${i} OR action ILIKE $${i} OR target_id ILIKE $${i})`); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + const countRes = await pool.query(`SELECT count(*)::int AS n FROM system_logs ${whereSql}`, params); + const total = Number((countRes.rows[0] as { n: number }).n); + + const dataParams = [...params, opts.limit, opts.offset]; + const res = await pool.query( + `SELECT id, ts, actor_id, actor_name, role, action, method, path, target_id, status, success, duration_ms, ip, query, detail + FROM system_logs ${whereSql} + ORDER BY id DESC + LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`, + dataParams, + ); + const items = (res.rows as Array>).map((r) => ({ + id: Number(r.id), + ts: r.ts instanceof Date ? r.ts.toISOString() : String(r.ts), + actorId: r.actor_id != null ? String(r.actor_id) : null, + actorName: r.actor_name != null ? String(r.actor_name) : null, + role: r.role != null ? String(r.role) : null, + action: String(r.action), + method: String(r.method), + path: String(r.path), + targetId: r.target_id != null ? String(r.target_id) : null, + status: r.status != null ? Number(r.status) : null, + success: r.success != null ? Boolean(r.success) : null, + durationMs: r.duration_ms != null ? Number(r.duration_ms) : null, + ip: r.ip != null ? String(r.ip) : null, + query: r.query != null ? String(r.query) : null, + detail: r.detail ?? null, + })); + return { items, total }; +} + +/** 已出现过的动作类型(供筛选下拉)。 */ +export async function distinctActions(pool: pg.Pool): Promise { + const res = await pool.query('SELECT DISTINCT action FROM system_logs ORDER BY action'); + return (res.rows as Array<{ action: string }>).map((r) => String(r.action)); +} diff --git a/src/server/index.ts b/src/server/index.ts index 958ae9e..0792225 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -73,6 +73,9 @@ import { USER_ROLES, type UserRole, getUserById, + insertSystemLog, + querySystemLogs, + distinctActions, getApprovalConfig, saveApprovalConfig, ensureApprovalConfig, @@ -139,6 +142,90 @@ app.use(cors({ origin: '*' })); // 认证中间件:AUTH_SECRET 配置后启用 JWT 校验;未配置时为演示模式(不校验)。 app.use('/api/*', authMiddleware()); +/** 由 方法+路径 推导中文操作名称(用于审计日志可读性)。 */ +function deriveActionLabel(method: string, path: string): string { + const p = path.replace(/\/+$/, ''); + const rules: ReadonlyArray<[RegExp, string]> = [ + [/^\/api\/auth\/login$/, '登录'], + [/^\/api\/assessments\/run$/, '运行评估(创建/重评)'], + [/^\/api\/assessments\/[^/]+\/submit$/, '申报报送风控'], + [/^\/api\/assessments\/[^/]+\/resubmit$/, '驳回后重新提交'], + [/^\/api\/assessments\/[^/]+\/review$/, '风控审核'], + [/^\/api\/assessments\/[^/]+\/approve$/, '管理层审批'], + [/^\/api\/assessments\/[^/]+\/override$/, '管理层调整状态'], + [/^\/api\/assessments\/[^/]+\/archive$/, '归档/取消归档'], + [/^\/api\/assessments\/[^/]+\/redline-verdict$/, '红线人工裁定'], + [/^\/api\/assessments\/[^/]+\/reject-reason$/, '登记驳回原因'], + [/^\/api\/assessments\/[^/]+\/actuals$/, '回填运营实际值'], + [/^\/api\/assessments\/[^/]+\/scenarios/, '报价方案变更'], + [/^\/api\/assessments\/[^/]+\/synthesis$/, '生成综合研判'], + [/^\/api\/assessments\/[^/]+\/report/, '报告生成/导出'], + [/^\/api\/assessments\/[^/]+\/recommendation$/, '重算承接建议'], + [/^\/api\/assessments\/[^/]+\/attachments/, '附件变更'], + [/^\/api\/assessments\/[^/]+$/, method === 'PUT' ? '编辑评估资料' : '评估变更'], + [/^\/api\/users\/[^/]+\/password$/, '重置用户密码'], + [/^\/api\/users\/[^/]+$/, method === 'DELETE' ? '删除用户' : '修改用户'], + [/^\/api\/users$/, '新增用户'], + [/^\/api\/approval-config$/, '修改审批流程配置'], + [/^\/api\/customers\/[^/]+\/payments/, '客户回款记录变更'], + [/^\/api\/customers/, '客户档案变更'], + [/^\/api\/redline-rules/, '红线规则变更'], + [/^\/api\/rates/, '费率变更'], + [/^\/api\/region-rates/, '地域费率变更'], + [/^\/api\/min-wages/, '最低工资标准变更'], + [/^\/api\/calibration\/apply$/, '应用预测校准'], + [/^\/api\/drafts/, '草稿变更'], + [/^\/api\/embeddings\/rebuild$/, '重建向量索引'], + ]; + for (const [re, label] of rules) { + if (re.test(p)) return label; + } + return `${method} ${p}`; +} + +/** 从路径提取目标实体 ID(取 /api// 的 id 段)。 */ +function deriveTargetId(path: string): string | null { + const m = path.match(/^\/api\/[^/]+\/([^/?]+)/); + return m && m[1] !== undefined ? decodeURIComponent(m[1]) : null; +} + +// 系统操作审计:记录全部写操作(POST/PUT/DELETE)+ 登录,供系统管理员审计。 +app.use('/api/*', async (c, next) => { + const method = c.req.method; + const start = Date.now(); + await next(); + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return; + if (pool === null) return; + const path = c.req.path; + if (path === '/api/system-logs') return; // 不记录查询日志自身 + const payload = (c as import('hono').Context).get('user') as AuthPayload | undefined; + const actorId = payload?.uid ?? null; + let actorName = payload?.username ?? null; + const role = payload?.role ?? null; + // 登录成功:从响应体补充操作人(此时尚无 JWT)。 + if (path === '/api/auth/login' && actorName === null) { + actorName = (await c.req.json<{ username?: string }>().catch(() => ({} as { username?: string }))).username ?? null; + } + const status = c.res.status; + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? null; + const queryStr = (() => { const i = c.req.url.indexOf('?'); return i >= 0 ? c.req.url.slice(i + 1) : null; })(); + const entry = { + actorId, + actorName, + role, + action: deriveActionLabel(method, path), + method, + path, + targetId: deriveTargetId(path), + status, + success: status < 400, + durationMs: Date.now() - start, + ip, + query: queryStr, + }; + void insertSystemLog(pool, entry).catch(() => undefined); +}); + app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' })); /** @@ -1300,6 +1387,35 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => { return c.json({ deleted: true }); }); +/* ------------------------------------------------------------------ * + * 系统操作审计日志(系统管理员)。 + * ------------------------------------------------------------------ */ + +/** 分页查询系统操作日志(系统管理员)。 */ +app.get('/api/system-logs', requireRole('系统管理员'), async (c) => { + if (pool === null) return c.json({ items: [], total: 0, page: 1, pageSize: 20, actions: [] }); + const page = Math.max(1, Number(c.req.query('page') ?? 1) || 1); + const pageSize = Math.max(1, Math.min(Number(c.req.query('pageSize') ?? 20) || 20, 200)); + const successQ = c.req.query('success'); + const fActor = c.req.query('actorId'); + const fAction = c.req.query('action'); + const fQ = c.req.query('q'); + const fFrom = c.req.query('from'); + const fTo = c.req.query('to'); + const { items, total } = await querySystemLogs(pool, { + limit: pageSize, + offset: (page - 1) * pageSize, + ...(fActor ? { actorId: fActor } : {}), + ...(fAction ? { action: fAction } : {}), + ...(fQ ? { q: fQ } : {}), + ...(fFrom ? { from: fFrom } : {}), + ...(fTo ? { to: fTo } : {}), + ...(successQ === 'true' || successQ === 'false' ? { success: successQ === 'true' } : {}), + }); + const actions = await distinctActions(pool).catch(() => []); + return c.json({ items, total, page, pageSize, actions }); +}); + /* ------------------------------------------------------------------ * * 审批流程配置(系统管理员):规则 + SLA。 * ------------------------------------------------------------------ */ diff --git a/web/src/App.tsx b/web/src/App.tsx index de1ded0..163edde 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { RedlineManagement } from './pages/RedlineManagement.js'; import { CustomerManagement } from './pages/CustomerManagement.js'; import { UserManagement } from './pages/UserManagement.js'; import { WorkflowManagement } from './pages/WorkflowManagement.js'; +import { SystemLogs } from './pages/SystemLogs.js'; /** 路由守卫:未登录重定向到登录页。 */ function ProtectedRoute(): JSX.Element { @@ -62,6 +63,7 @@ export function App(): JSX.Element { }> } /> } /> + } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 23a997b..f9991a7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -982,6 +982,48 @@ export async function fetchDashboardStats(): Promise { return request('GET', '/api/dashboard/stats'); } +/** 系统操作审计日志项。 */ +export interface SystemLogItem { + id: number; + ts: string; + actorId: string | null; + actorName: string | null; + role: string | null; + action: string; + method: string; + path: string; + targetId: string | null; + status: number | null; + success: boolean | null; + durationMs: number | null; + ip: string | null; + query: string | null; + detail: unknown; +} + +export interface SystemLogPage { + items: SystemLogItem[]; + total: number; + page: number; + pageSize: number; + actions: string[]; +} + +/** 查询系统操作审计日志(系统管理员)。 */ +export async function fetchSystemLogs(params: { + page: number; pageSize: number; actorId?: string; action?: string; q?: string; from?: string; to?: string; success?: 'true' | 'false'; +}): Promise { + const sp = new URLSearchParams(); + sp.set('page', String(params.page)); + sp.set('pageSize', String(params.pageSize)); + if (params.action) sp.set('action', params.action); + if (params.q) sp.set('q', params.q); + if (params.from) sp.set('from', params.from); + if (params.to) sp.set('to', params.to); + if (params.success) sp.set('success', params.success); + return request('GET', `/api/system-logs?${sp.toString()}`); +} + /** 经验库。 */ export interface ExperienceItem { id: number; diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 161a035..058eed9 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -190,6 +190,9 @@ export function AppShell(): JSX.Element { navigate('/workflow')}> 审批流程 + navigate('/system-logs')}> + 日志管理 + )} diff --git a/web/src/pages/SystemLogs.tsx b/web/src/pages/SystemLogs.tsx new file mode 100644 index 0000000..d746c8e --- /dev/null +++ b/web/src/pages/SystemLogs.tsx @@ -0,0 +1,170 @@ +/** + * 日志管理 — 系统管理员查看全系统操作审计日志(谁/何时/何角色/动作/目标/方法/路径/结果/IP/耗时)。 + * 支持按动作、关键词、时间范围、成功/失败筛选与分页,并可展开查看明细。 + */ +import { useCallback, useEffect, useState, Fragment } from 'react'; +import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js'; +import { Card, Icon } from '../design-system/index.js'; +import { fetchSystemLogs, type SystemLogItem } from '../api/client.js'; + +const ROLE_FG: Record = { + '商务/销售': '#15803D', '风控': '#B45309', '管理层': '#4F46E5', '系统管理员': '#0891B2', +}; + +function fmt(ts: string): string { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + return d.toLocaleString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function SystemLogs(): JSX.Element { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [actions, setActions] = useState([]); + const [action, setAction] = useState(''); + const [success, setSuccess] = useState<'' | 'true' | 'false'>(''); + const [q, setQ] = useState(''); + const [qInput, setQInput] = useState(''); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + const t = setTimeout(() => { setQ(qInput); setPage(1); }, 350); + return () => clearTimeout(t); + }, [qInput]); + + const load = useCallback(() => { + setLoading(true); + fetchSystemLogs({ + page, pageSize, + ...(action ? { action } : {}), + ...(q ? { q } : {}), + ...(from ? { from: new Date(from).toISOString() } : {}), + ...(to ? { to: new Date(to).toISOString() } : {}), + ...(success ? { success } : {}), + }) + .then((res) => { setItems(res.items); setTotal(res.total); setActions(res.actions); setError(null); }) + .catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')) + .finally(() => setLoading(false)); + }, [page, pageSize, action, q, from, to, success]); + useEffect(() => { load(); }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const input: React.CSSProperties = { + padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`, + borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('caption'), + backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'), + }; + const th: React.CSSProperties = { textAlign: 'left', padding: `${space(2)}px ${space(2)}px`, borderBottom: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700, whiteSpace: 'nowrap' }; + const td: React.CSSProperties = { padding: `${space(2)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, ...typographyStyle('caption'), verticalAlign: 'top' }; + + return ( +
+
+

日志管理

+

+ 全系统操作审计:记录每一次写操作的操作人、角色、动作、目标、方法、路径、结果、IP 与耗时,供合规审计与追溯。 +

+
+ + + 操作日志({total}) +
+ setQInput(e.target.value)} /> + + + { setFrom(e.target.value); setPage(1); }} title="起始日期" /> + { setTo(e.target.value); setPage(1); }} title="结束日期" /> +
+
+ }> + {error !== null &&
{error}
} + {loading ?

加载中…

: ( +
+ + + + + + + + + + + + + + {items.map((it) => ( + + setExpanded(expanded === it.id ? null : it.id)}> + + + + + + + + + + + {expanded === it.id && ( + + + + )} + + ))} + {items.length === 0 && } + +
时间操作人角色动作方法目标/路径结果IP耗时
{fmt(it.ts)}{it.actorName ?? '匿名'}{it.role ?? '—'}{it.action}{it.method} + {it.targetId && {it.targetId}} +
{it.path}
+
+ + {it.status ?? '-'} + + {it.ip ?? '—'}{it.durationMs ?? '-'}ms
+
+ 日志 ID:{it.id} 操作人ID:{it.actorId ?? '—'} + {it.query && 查询参数:{it.query}} + {it.detail != null && 明细:{JSON.stringify(it.detail)}} +
+
暂无日志
+
+ )} + + {/* 分页 */} +
+ 共 {total} 条 · 第 {page}/{totalPages} 页 +
+ + + + + +
+
+ + + ); +} + +const pagerBtn: React.CSSProperties = { + padding: '4px 10px', borderRadius: '6px', border: '1px solid var(--color-border-default)', + background: 'transparent', cursor: 'pointer', fontFamily: FONT_FAMILY, fontSize: '12px', color: 'var(--color-text-primary)', +};