feat: 新增客户端错误收集与admin监控接口

- POST /api/client-errors: 批量存储客户端错误(10分钟去重合并)
- GET /api/client-errors: admin分页查看错误列表
- DELETE /api/client-errors: admin清空错误记录
- 72小时自动清理旧数据

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
stringadmin
2026-06-03 10:52:26 +08:00
parent 3c574eeff6
commit 1a5992845a
2 changed files with 96 additions and 1 deletions
+94
View File
@@ -0,0 +1,94 @@
const crypto = require('node:crypto');
const { pool, requireAuth, requireAdmin } = require('./context');
const MAX_ERRORS_PER_PAGE = 50;
const ERROR_RETENTION_HOURS = 72;
async function initTable(client) {
await client.query(`
CREATE TABLE IF NOT EXISTS client_errors (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
stack TEXT,
source VARCHAR(32),
url TEXT,
user_agent TEXT,
session_id TEXT,
user_id INTEGER,
count INTEGER DEFAULT 1,
first_seen TIMESTAMP DEFAULT NOW(),
last_seen TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_client_errors_last_seen ON client_errors(last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_client_errors_user ON client_errors(user_id);
`);
}
async function storeError(client, error) {
const normalizedMessage = error.message.slice(0, 1000);
const { rows } = await client.query(
`SELECT id, count FROM client_errors WHERE message = $1 AND url = $2 AND source = $3 AND last_seen > NOW() - INTERVAL '10 minutes'`,
[normalizedMessage, error.url || '', error.source || 'manual']
);
if (rows.length > 0) {
await client.query(
`UPDATE client_errors SET count = count + 1, last_seen = NOW(), stack = COALESCE($1, stack) WHERE id = $2`,
[error.stack, rows[0].id]
);
} else {
await client.query(
`INSERT INTO client_errors (message, stack, source, url, user_agent, session_id, user_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[normalizedMessage, error.stack, error.source, error.url, error.userAgent, error.sessionId, error.userId || null]
);
}
}
module.exports = function mountClientErrorRoutes(router) {
router.post('/client-errors', requireAuth, async (req, res) => {
try {
const { errors } = req.body;
if (!Array.isArray(errors) || !errors.length) return res.json({ ok: true });
const { pool: db } = require('./context');
const client = await db.connect();
try {
await initTable(client);
for (const err of errors.slice(0, 10)) {
await storeError(client, { ...err, userId: req.user?.id });
}
} finally { client.release(); }
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/client-errors', requireAuth, requireAdmin, async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const { pool: db } = require('./context');
const client = await db.connect();
try {
await initTable(client);
await client.query(`DELETE FROM client_errors WHERE last_seen < NOW() - INTERVAL '$1 hours'`, [ERROR_RETENTION_HOURS]);
const { rows } = await client.query(
`SELECT * FROM client_errors ORDER BY last_seen DESC LIMIT $1 OFFSET $2`,
[MAX_ERRORS_PER_PAGE, (page - 1) * MAX_ERRORS_PER_PAGE]
);
const { rows: countRows } = await client.query('SELECT COUNT(*) as total FROM client_errors');
res.json({ items: rows, total: parseInt(countRows[0]?.total) || 0, page, pageSize: MAX_ERRORS_PER_PAGE });
} finally { client.release(); }
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.delete('/client-errors', requireAuth, requireAdmin, async (req, res) => {
try {
const { pool: db } = require('./context');
await db.query('DELETE FROM client_errors');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
};
+2 -1
View File
@@ -17,7 +17,8 @@ const { registerConversationRoutes } = require('./conversations')
const { registerReportRoutes } = require('./reports')
const { registerAssetRoutes } = require('./assets')
const { registerNotificationRoutes } = require('./notifications')
const { registerDraftRoutes } = require('./drafts')
const { registerDraftRoutes } = require('./drafts');
const mountClientErrorRoutes = require('./clientErrors')
const router = express.Router()