日志管理增强:登录补全角色、IP兜底、记录业务对象与决策

- 登录日志从用户表补全 actorId/actorName/role
- IP 兜底:X-Forwarded-For/X-Real-IP → 否则用连接信息(getConnInfo)
- 业务动作记录目标项目名(target_name)与决策(风控审核·通过/管理层审批·驳回/红线裁定·命中)
- system_logs 增加 target_name 列;前端日志页显示项目名
This commit is contained in:
freedakgmail
2026-06-14 09:02:05 +08:00
parent 1a37daea68
commit a9c41bba57
5 changed files with 65 additions and 18 deletions
@@ -0,0 +1,12 @@
/* eslint-disable */
/**
* 系统日志增加 target_name(业务对象名称,如项目名),让审计可读:"谁 对 哪个项目 做了什么"。
*/
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE system_logs ADD COLUMN IF NOT EXISTS target_name TEXT;`);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE system_logs DROP COLUMN IF EXISTS target_name;`);
};
+6 -4
View File
@@ -11,6 +11,7 @@ export interface SystemLogEntry {
method: string; method: string;
path: string; path: string;
targetId: string | null; targetId: string | null;
targetName?: string | null;
status: number | null; status: number | null;
success: boolean | null; success: boolean | null;
durationMs: number | null; durationMs: number | null;
@@ -27,10 +28,10 @@ export interface SystemLogRow extends SystemLogEntry {
/** 写入一条系统审计日志。 */ /** 写入一条系统审计日志。 */
export async function insertSystemLog(pool: pg.Pool, e: SystemLogEntry): Promise<void> { export async function insertSystemLog(pool: pg.Pool, e: SystemLogEntry): Promise<void> {
await pool.query( await pool.query(
`INSERT INTO system_logs(actor_id, actor_name, role, action, method, path, target_id, status, success, duration_ms, ip, query, detail) `INSERT INTO system_logs(actor_id, actor_name, role, action, method, path, target_id, target_name, status, success, duration_ms, ip, query, detail)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
[ [
e.actorId, e.actorName, e.role, e.action, e.method, e.path, e.targetId, e.actorId, e.actorName, e.role, e.action, e.method, e.path, e.targetId, e.targetName ?? null,
e.status, e.success, e.durationMs, e.ip, e.query, e.status, e.success, e.durationMs, e.ip, e.query,
e.detail !== undefined ? JSON.stringify(e.detail) : null, e.detail !== undefined ? JSON.stringify(e.detail) : null,
], ],
@@ -72,7 +73,7 @@ export async function querySystemLogs(
const dataParams = [...params, opts.limit, opts.offset]; const dataParams = [...params, opts.limit, opts.offset];
const res = await pool.query( 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 `SELECT id, ts, actor_id, actor_name, role, action, method, path, target_id, target_name, status, success, duration_ms, ip, query, detail
FROM system_logs ${whereSql} FROM system_logs ${whereSql}
ORDER BY id DESC ORDER BY id DESC
LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`, LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
@@ -88,6 +89,7 @@ export async function querySystemLogs(
method: String(r.method), method: String(r.method),
path: String(r.path), path: String(r.path),
targetId: r.target_id != null ? String(r.target_id) : null, targetId: r.target_id != null ? String(r.target_id) : null,
targetName: r.target_name != null ? String(r.target_name) : null,
status: r.status != null ? Number(r.status) : null, status: r.status != null ? Number(r.status) : null,
success: r.success != null ? Boolean(r.success) : null, success: r.success != null ? Boolean(r.success) : null,
durationMs: r.duration_ms != null ? Number(r.duration_ms) : null, durationMs: r.duration_ms != null ? Number(r.duration_ms) : null,
+44 -13
View File
@@ -8,6 +8,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { getConnInfo } from '@hono/node-server/conninfo';
import { import {
classify, classify,
isBusinessType, isBusinessType,
@@ -199,31 +200,61 @@ app.use('/api/*', async (c, next) => {
const path = c.req.path; const path = c.req.path;
if (path === '/api/system-logs') return; // 不记录查询日志自身 if (path === '/api/system-logs') return; // 不记录查询日志自身
const payload = (c as import('hono').Context).get('user') as AuthPayload | undefined; const payload = (c as import('hono').Context).get('user') as AuthPayload | undefined;
const actorId = payload?.uid ?? null; let actorId = payload?.uid ?? null;
let actorName = payload?.username ?? null; let actorName = payload?.username ?? null;
const role = payload?.role ?? null; let 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 status = c.res.status;
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? null;
// 登录:此时无 JWT,从请求体用户名补全操作人与角色(成功时)。
if (path === '/api/auth/login') {
const lb = await c.req.json<{ username?: string }>().catch(() => ({} as { username?: string }));
if (lb.username) {
actorName = lb.username;
const u = await getUserByUsername(pool, lb.username).catch(() => null);
if (u) { actorId = u.id; role = u.role; }
}
}
// 业务动作增强:解析目标项目名 + 决策(通过/驳回等)。
let action = deriveActionLabel(method, path);
let targetName: string | null = null;
const targetId = deriveTargetId(path);
const isAssessmentPath = /^\/api\/assessments\/[^/]+/.test(path) && targetId !== null && targetId !== 'run' && targetId !== 'profitability' && targetId !== 'summary' && targetId !== 'expiring' && targetId !== 'overdue';
if (isAssessmentPath && targetId) {
const rec = store.get(targetId);
const desc = rec ? String((rec.assessment as unknown as { projectDescription?: string }).projectDescription ?? '') : '';
const m = desc.match(/【项目】([^|\n]+)/);
targetName = m?.[1]?.trim() ?? null;
}
// 含决策的动作:读已缓存的请求体补充"通过/驳回/放弃"。
if (/\/(review|approve)$/.test(path)) {
const b = await c.req.json<{ action?: string }>().catch(() => ({} as { action?: string }));
const map: Record<string, string> = { approve: '通过', reject: '驳回', abandon: '放弃' };
if (b.action && map[b.action]) action = `${action}·${map[b.action]}`;
} else if (/\/redline-verdict$/.test(path)) {
const b = await c.req.json<{ status?: string }>().catch(() => ({} as { status?: string }));
if (b.status) action = `${action}·${b.status}`;
}
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
?? c.req.header('x-real-ip')
?? (() => { try { return getConnInfo(c).remote.address ?? null; } catch { return null; } })();
const queryStr = (() => { const i = c.req.url.indexOf('?'); return i >= 0 ? c.req.url.slice(i + 1) : null; })(); const queryStr = (() => { const i = c.req.url.indexOf('?'); return i >= 0 ? c.req.url.slice(i + 1) : null; })();
const entry = { void insertSystemLog(pool, {
actorId, actorId,
actorName, actorName,
role, role,
action: deriveActionLabel(method, path), action,
method, method,
path, path,
targetId: deriveTargetId(path), targetId,
targetName,
status, status,
success: status < 400, success: status < 400,
durationMs: Date.now() - start, durationMs: Date.now() - start,
ip, ip: ip ?? null,
query: queryStr, query: queryStr,
}; }).catch(() => undefined);
void insertSystemLog(pool, entry).catch(() => undefined);
}); });
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' })); app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
+1
View File
@@ -993,6 +993,7 @@ export interface SystemLogItem {
method: string; method: string;
path: string; path: string;
targetId: string | null; targetId: string | null;
targetName: string | null;
status: number | null; status: number | null;
success: boolean | null; success: boolean | null;
durationMs: number | null; durationMs: number | null;
+2 -1
View File
@@ -116,7 +116,8 @@ export function SystemLogs(): JSX.Element {
<td style={{ ...td, whiteSpace: 'nowrap', color: colorVar('color.text.primary') }}>{it.action}</td> <td style={{ ...td, whiteSpace: 'nowrap', color: colorVar('color.text.primary') }}>{it.action}</td>
<td style={td}><span style={{ fontFamily: 'monospace', fontWeight: 700, color: it.method === 'DELETE' ? '#BE123C' : it.method === 'PUT' ? '#B45309' : '#2563EB' }}>{it.method}</span></td> <td style={td}><span style={{ fontFamily: 'monospace', fontWeight: 700, color: it.method === 'DELETE' ? '#BE123C' : it.method === 'PUT' ? '#B45309' : '#2563EB' }}>{it.method}</span></td>
<td style={{ ...td, maxWidth: 320, wordBreak: 'break-all', color: colorVar('color.text.secondary') }}> <td style={{ ...td, maxWidth: 320, wordBreak: 'break-all', color: colorVar('color.text.secondary') }}>
{it.targetId && <span style={{ color: colorVar('color.text.primary'), fontWeight: 600 }}>{it.targetId}</span>} {it.targetName && <div style={{ color: colorVar('color.text.primary'), fontWeight: 600 }}>{it.targetName}</div>}
{it.targetId && !it.targetName && <span style={{ color: colorVar('color.text.primary'), fontWeight: 600 }}>{it.targetId}</span>}
<div style={{ fontFamily: 'monospace', fontSize: '11px' }}>{it.path}</div> <div style={{ fontFamily: 'monospace', fontSize: '11px' }}>{it.path}</div>
</td> </td>
<td style={{ ...td, whiteSpace: 'nowrap' }}> <td style={{ ...td, whiteSpace: 'nowrap' }}>