166 lines
5.2 KiB
JavaScript
166 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const { ACTIONS } = require('@profile/shared');
|
|
const audit = require('./audit');
|
|
const db = require('../infra/db');
|
|
const rbac = require('../rbac/rbac');
|
|
const { requireAuth } = require('../auth/auth');
|
|
const { ok, asyncHandler } = require('../common/http');
|
|
|
|
const router = express.Router();
|
|
|
|
function sanitizeMcpArgs(value) {
|
|
if (Array.isArray(value)) return value.map(sanitizeMcpArgs);
|
|
if (!value || typeof value !== 'object') return value;
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
// 只过滤凭证类字段,保留业务参数(employeeId/name/year/team 等)供审计追踪
|
|
if (/(password|secret|token|_enc|_hmac)/i.test(key)) continue;
|
|
out[key] = sanitizeMcpArgs(item);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 生成人类可读的 MCP 调用摘要(供审计日志"结果摘要"列显示)
|
|
*/
|
|
function formatMcpSummary(tool, args) {
|
|
if (!args || typeof args !== 'object') return '';
|
|
const parts = [];
|
|
if (args.keyword) parts.push(`关键词:${args.keyword}`);
|
|
if (args.employeeId) parts.push(`工号:${args.employeeId}`);
|
|
if (args.employeeName) parts.push(`姓名:${args.employeeName}`);
|
|
if (args.year) parts.push(`年份:${args.year}`);
|
|
if (args.month) parts.push(`月份:${args.month}`);
|
|
if (args.team) parts.push(`班组:${args.team}`);
|
|
if (args.modules && Array.isArray(args.modules)) parts.push(`模块:${args.modules.join(',')}`);
|
|
if (args.openUi === false) parts.push('不开窗');
|
|
return parts.join('、') || JSON.stringify(args).slice(0, 200);
|
|
}
|
|
|
|
router.post(
|
|
'/view',
|
|
requireAuth,
|
|
asyncHandler(async (req, res) => {
|
|
const { page, table, filters, rowCount } = req.body || {};
|
|
const safePage = typeof page === 'string' && /^[a-z0-9_-]{1,64}$/.test(page) ? page : null;
|
|
const safeTable = typeof table === 'string' && /^[a-z0-9_]{1,64}$/.test(table) ? table : null;
|
|
if (!safePage && !safeTable) {
|
|
const err = new Error('页面或数据表标识无效');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
await audit.log({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
roleCode: req.user.roles?.[0],
|
|
clientType: 'ui',
|
|
action: safeTable ? 'view_data' : 'view_page',
|
|
module: safePage || safeTable,
|
|
targetType: safeTable ? 'table' : 'page',
|
|
targetId: safeTable || safePage,
|
|
payload: safeTable
|
|
? { filters: sanitizeMcpArgs(filters || {}), rowCount: Math.max(0, Number(rowCount) || 0) }
|
|
: null,
|
|
ip: req.ip,
|
|
userAgent: req.headers['user-agent'],
|
|
});
|
|
ok(res, { recorded: true });
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/mcp',
|
|
requireAuth,
|
|
asyncHandler(async (req, res) => {
|
|
const { tool, args, success, durationMs, error } = req.body || {};
|
|
if (typeof tool !== 'string' || !/^profile_(query|get|list|health)_[a-z0-9_]+$/.test(tool)) {
|
|
const err = new Error('非法 MCP 工具名');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
await audit.log({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
roleCode: req.user.roles?.[0],
|
|
clientType: 'agent',
|
|
action: 'mcp_call',
|
|
module: 'mcp',
|
|
targetType: 'tool',
|
|
targetId: tool,
|
|
payload: {
|
|
summary: formatMcpSummary(tool, args),
|
|
args: sanitizeMcpArgs(args || {}),
|
|
success: Boolean(success),
|
|
durationMs: Math.max(0, Number(durationMs) || 0),
|
|
error: error ? String(error).slice(0, 500) : null,
|
|
},
|
|
ip: req.ip,
|
|
userAgent: req.headers['user-agent'],
|
|
});
|
|
ok(res, { recorded: true });
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/',
|
|
requireAuth,
|
|
rbac.requirePermission('audit', ACTIONS.READ),
|
|
asyncHandler(async (req, res) => {
|
|
const take = Math.min(Number(req.query.size) || 50, 200);
|
|
const page = Math.max(Number(req.query.page) || 1, 1);
|
|
const offset = (page - 1) * take;
|
|
|
|
const conds = [];
|
|
const params = [];
|
|
if (req.query.client_type) {
|
|
conds.push('a.client_type = ?');
|
|
params.push(req.query.client_type);
|
|
}
|
|
if (req.query.action) {
|
|
conds.push('a.action = ?');
|
|
params.push(req.query.action);
|
|
}
|
|
if (req.query.username) {
|
|
const keyword = `%${String(req.query.username).slice(0, 64)}%`;
|
|
conds.push('(a.username LIKE ? OR u.real_name LIKE ? OR u.display_name LIKE ?)');
|
|
params.push(keyword, keyword, keyword);
|
|
}
|
|
if (req.query.module) {
|
|
conds.push('a.module = ?');
|
|
params.push(String(req.query.module).slice(0, 64));
|
|
}
|
|
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
|
|
|
const rows = await db.query(
|
|
`SELECT a.*, u.display_name, u.real_name
|
|
FROM audit_logs a
|
|
LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL
|
|
${where}
|
|
ORDER BY a.id DESC
|
|
LIMIT ${take} OFFSET ${offset}`,
|
|
params,
|
|
);
|
|
const countRows = await db.query(
|
|
`SELECT COUNT(*) AS total
|
|
FROM audit_logs a
|
|
LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL
|
|
${where}`,
|
|
params,
|
|
);
|
|
ok(res, { rows, total: Number(countRows[0].total), page, size: take });
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/verify',
|
|
requireAuth,
|
|
rbac.requirePermission('audit', ACTIONS.READ),
|
|
asyncHandler(async (req, res) => {
|
|
ok(res, await audit.verifyChain());
|
|
}),
|
|
);
|
|
|
|
module.exports = router;
|