系统管理员:新增日志管理(全系统操作审计)

- system_logs 表 + 持久化(查询/筛选/分页/动作枚举)
- 全局审计中间件:记录全部写操作(POST/PUT/DELETE)+登录,含操作人(JWT)/角色/中文动作/方法/路径/目标/状态/成功/耗时/IP/查询参数
- 中文动作标签按路由推导,目标ID从路径提取
- GET /api/system-logs(仅系统管理员)支持按动作/关键词/时间/成功失败筛选
- 前端「日志管理」页:筛选+分页+明细展开;导航与路由接入
This commit is contained in:
freedakgmail
2026-06-14 08:49:26 +08:00
parent aff293d40e
commit 1a37daea68
8 changed files with 473 additions and 0 deletions
+1
View File
@@ -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';
+105
View File
@@ -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<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)`,
[
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<Record<string, unknown>>).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<string[]> {
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));
}
+116
View File
@@ -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/<res>/<id> 的 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。
* ------------------------------------------------------------------ */