日志管理增强:登录补全角色、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;
|
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
@@ -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' }));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user