日志管理增强:登录补全角色、IP兜底、记录业务对象与决策
- 登录日志从用户表补全 actorId/actorName/role - IP 兜底:X-Forwarded-For/X-Real-IP → 否则用连接信息(getConnInfo) - 业务动作记录目标项目名(target_name)与决策(风控审核·通过/管理层审批·驳回/红线裁定·命中) - system_logs 增加 target_name 列;前端日志页显示项目名
This commit is contained in:
@@ -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;`);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export interface SystemLogEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
targetId: string | null;
|
||||
targetName?: string | null;
|
||||
status: number | null;
|
||||
success: boolean | null;
|
||||
durationMs: number | null;
|
||||
@@ -27,10 +28,10 @@ export interface SystemLogRow extends SystemLogEntry {
|
||||
/** 写入一条系统审计日志。 */
|
||||
export async function insertSystemLog(pool: pg.Pool, e: SystemLogEntry): Promise<void> {
|
||||
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)`,
|
||||
`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,$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.detail !== undefined ? JSON.stringify(e.detail) : null,
|
||||
],
|
||||
@@ -72,7 +73,7 @@ export async function querySystemLogs(
|
||||
|
||||
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
|
||||
`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}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
|
||||
@@ -88,6 +89,7 @@ export async function querySystemLogs(
|
||||
method: String(r.method),
|
||||
path: String(r.path),
|
||||
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,
|
||||
success: r.success != null ? Boolean(r.success) : null,
|
||||
durationMs: r.duration_ms != null ? Number(r.duration_ms) : null,
|
||||
|
||||
+44
-13
@@ -8,6 +8,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
import {
|
||||
classify,
|
||||
isBusinessType,
|
||||
@@ -199,31 +200,61 @@ app.use('/api/*', async (c, next) => {
|
||||
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 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;
|
||||
}
|
||||
let role = payload?.role ?? null;
|
||||
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 entry = {
|
||||
void insertSystemLog(pool, {
|
||||
actorId,
|
||||
actorName,
|
||||
role,
|
||||
action: deriveActionLabel(method, path),
|
||||
action,
|
||||
method,
|
||||
path,
|
||||
targetId: deriveTargetId(path),
|
||||
targetId,
|
||||
targetName,
|
||||
status,
|
||||
success: status < 400,
|
||||
durationMs: Date.now() - start,
|
||||
ip,
|
||||
ip: ip ?? null,
|
||||
query: queryStr,
|
||||
};
|
||||
void insertSystemLog(pool, entry).catch(() => undefined);
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||
|
||||
@@ -993,6 +993,7 @@ export interface SystemLogItem {
|
||||
method: string;
|
||||
path: string;
|
||||
targetId: string | null;
|
||||
targetName: string | null;
|
||||
status: number | null;
|
||||
success: boolean | null;
|
||||
durationMs: number | null;
|
||||
|
||||
@@ -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}><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') }}>
|
||||
{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>
|
||||
</td>
|
||||
<td style={{ ...td, whiteSpace: 'nowrap' }}>
|
||||
|
||||
Reference in New Issue
Block a user