Files
happy-export/backend/routes/logs.js
T
Drsakura 1ffcc1ea07 feat: add admin web updates and verified username changes
Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming.

Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
2026-10-22 17:24:19 +08:00

214 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 系统日志(审计轨迹)—— 读取 audit_log 并提供筛选、统计与导出。
*
* 为什么要有这一页:Wayne 2026-09-15 参照 GoodJob 的系统日志页提出 —— 登录、改密、
* 用户/权限组/组织变更、客户归属流转、商机状态、AI 接入变更都会写 audit_log,
* 但这些记录此前只能在库里看见,界面上没有任何入口。
*
* ⚠️ 挂载点刻意**不放在 /api/system/ 下** —— 那个前缀在免登录白名单里
* (`/^\/api\/(health|auth\/|system\/)/`),放在那儿等于把全公司的操作日志公开。
*
* 权限:`audit.view`,按 Wayne 的口径**只授给系统管理员组**(见 lib/permissions.js)。
* 界面入口同理:没这个点的人,设置里根本看不到「系统日志」这一项。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const router = express.Router();
/* ---------------------------------------------------------------------------
* 分类:按 action 前缀归组。新增 action 时如果这里没覆盖,会落到「系统」,
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
* ------------------------------------------------------------------------- */
const CATEGORIES = [
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_|username_)/ },
{ key: 'user', label: '用户', match: /^user_/ },
{ key: 'permission', label: '权限', match: /^permission_group_/ },
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ },
{ key: 'customer', label: '客户', match: /^(customer_|pool_|prospect_)/ },
{ key: 'contract', label: '合同', match: /^contract_/ },
{ key: 'ai', label: '智能', match: /^ai_/ }
]
const CATEGORY_LABELS = { system: '系统', ...Object.fromEntries(CATEGORIES.map(c => [c.key, c.label])) }
/* 动作的中文名。查不到就显示原始 action —— 便于新动作上线时立刻可读,不用等改代码。 */
const ACTION_LABELS = {
login: '登录成功',
login_failed: '登录失败',
login_blocked: '登录被拦截',
logout: '退出登录',
profile_update: '更新个人资料',
password_change: '修改密码',
password_change_failed: '修改密码失败',
password_verify_failed: '身份密码验证失败',
username_change: '修改用户名',
system_update: '启动系统更新',
user_create: '新建用户',
user_update: '更新用户',
permission_group_create: '新建权限组',
permission_group_update: '更新权限组',
permission_group_delete: '删除权限组',
organization_create: '新建组织',
organization_update: '更新组织信息',
branding_update: '更新系统标识',
customer_create: '新建客户',
customer_to_pool: '客户丢入公海',
customer_claim: '公海领取客户',
prospect_deal_status: '商机状态变更',
ai_provider_create: '新增模型接入',
ai_provider_update: '更新模型接入',
ai_provider_delete: '移除模型接入',
ai_provider_test: '测试模型连通性',
ai_assignment_update: '调整模型用途分配',
contract_import: '导入合同',
contract_commit: '合同入库',
contract_ai_parse: '合同 AI 解析',
contract_rules_update: '更新合同解析规则'
}
function categoryOf(action = '') {
const hit = CATEGORIES.find(c => c.match.test(action))
return hit ? hit.key : 'system'
}
/* 等级:成功 / 信息 / 警告 / 错误。登录失败算「警告」——它通常是输错密码,
而不是系统故障,混进错误里会把真正的故障淹掉。 */
const WRITE_ACTION = /_(create|update|delete|change|claim)$/
function levelOf(row) {
if (row.ok) {
if (WRITE_ACTION.test(row.action) || row.action === 'customer_to_pool') return 'success'
return 'info'
}
return /^(login_failed|login_blocked)$/.test(row.action) ? 'warning' : 'error'
}
const LEVEL_LABELS = { info: '信息', success: '成功', warning: '警告', error: '错误' }
/* ---------------------------------------------------------------------------
* 过滤条件只在这里拼一次:列表、计数、统计、导出共用,避免四处走散。
* 全部走占位参数,不拼字符串。
* ------------------------------------------------------------------------- */
function buildFilter(query) {
const where = []
const params = []
const level = String(query.level || '').trim()
const category = String(query.category || '').trim()
const keyword = String(query.q || '').trim()
if (keyword) {
where.push('(username LIKE ? OR action LIKE ? OR detail LIKE ? OR ip LIKE ?)')
const like = `%${keyword}%`
params.push(like, like, like, like)
}
/* 分类与等级是「从 action / ok 算出来的」,SQL 里没有这两列,
所以先选出候选行再在内存里筛 —— 日志量级(万行内)完全够用。 */
const rows = db.prepare(
`SELECT id, at, username, action, detail, ip, user_agent, ok FROM audit_log
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY id DESC LIMIT 20000`
).all(...params)
const decorated = rows.map(row => ({
...row,
ok: row.ok ? 1 : 0,
category: categoryOf(row.action),
level: levelOf(row),
action_label: ACTION_LABELS[row.action] || row.action
}))
return decorated.filter(row =>
(!level || row.level === level) && (!category || row.category === category))
}
function dayStartIso() {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d.toISOString()
}
function statsOf(rows) {
/* 统计永远基于「当前筛选结果」,这样切了分类/等级之后四个数字才对得上列表。 */
const today = dayStartIso()
return {
total: rows.length,
today: rows.filter(r => r.at >= today).length,
warning: rows.filter(r => r.level === 'warning').length,
error: rows.filter(r => r.level === 'error').length
}
}
function csvCell(value) {
const text = value === null || value === undefined ? '' : String(value)
return `"${text.replace(/"/g, '""')}"`
}
/** GET /api/logs —— 分页列表(带统计与可选分类) */
router.get('/logs', auth.requirePermission('audit.view'), (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
const offset = Math.max(Number(req.query.offset) || 0, 0)
const all = buildFilter(req.query)
const categoryCounts = {}
for (const row of all) categoryCounts[row.category] = (categoryCounts[row.category] || 0) + 1
res.json({
items: all.slice(offset, offset + limit),
total: all.length,
limit,
offset,
stats: statsOf(all),
categories: Object.entries(CATEGORY_LABELS)
.map(([key, label]) => ({ key, label, count: categoryCounts[key] || 0 }))
.filter(item => item.count > 0),
levels: Object.entries(LEVEL_LABELS).map(([key, label]) => ({
key, label, count: all.filter(r => r.level === key).length
}))
})
} catch (error) {
res.status(500).json({ error: '读取日志失败:' + error.message })
}
});
/** GET /api/logs/export —— 导出当前筛选结果为 CSV(带 BOM,Excel 直接打开不乱码) */
router.get('/logs/export', auth.requirePermission('audit.view'), (req, res) => {
try {
const rows = buildFilter(req.query).slice(0, 5000)
const header = ['时间', '等级', '分类', '操作人', '动作', '说明', 'IP', '结果']
const lines = [header.map(csvCell).join(',')]
for (const row of rows) {
lines.push([
row.at,
LEVEL_LABELS[row.level] || row.level,
CATEGORY_LABELS[row.category] || row.category,
row.username || '—',
row.action_label,
row.detail || '',
row.ip || '',
row.ok ? '成功' : '失败'
].map(csvCell).join(','))
}
auth.audit({
username: req.user.username,
action: 'audit_export',
detail: `导出系统日志 ${rows.length} 条`,
req
})
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="system-logs-${stamp}.csv"`)
res.send('\uFEFF' + lines.join('\r\n'))
} catch (error) {
res.status(500).json({ error: '导出日志失败:' + error.message })
}
});
module.exports = router;