'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;