233 lines
6.9 KiB
TypeScript
233 lines
6.9 KiB
TypeScript
import { Router } from 'express'
|
||
import fs from 'fs'
|
||
import path from 'path'
|
||
import pool from '../db/connection'
|
||
import { requireAdmin } from '../middleware/requireAdmin'
|
||
|
||
const router = Router()
|
||
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
|
||
|
||
/** 获取今日日期字符串 YYYY-MM-DD(服务器本地时间) */
|
||
function getTodayStr(): string {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
/** 获取日志列表(最近7天) */
|
||
router.get('/files', requireAdmin, (_req, res) => {
|
||
try {
|
||
if (!fs.existsSync(LOG_DIR)) {
|
||
return res.json({ code: 0, data: [] })
|
||
}
|
||
|
||
const files = fs.readdirSync(LOG_DIR)
|
||
.filter(f => f.endsWith('.log'))
|
||
.sort()
|
||
.reverse()
|
||
.slice(0, 7) // 最多返回7天
|
||
|
||
const fileList = files.map(f => {
|
||
const filePath = path.join(LOG_DIR, f)
|
||
const stat = fs.statSync(filePath)
|
||
return {
|
||
name: f,
|
||
date: f.replace('.log', ''),
|
||
size: stat.size,
|
||
modified: stat.mtime.toISOString()
|
||
}
|
||
})
|
||
|
||
res.json({ code: 0, data: fileList })
|
||
} catch (err) {
|
||
console.error('[Logs] List error:', err)
|
||
res.status(500).json({ code: 50000, message: '获取日志列表失败' })
|
||
}
|
||
})
|
||
|
||
/** 获取日志统计 */
|
||
router.get('/stats', requireAdmin, (_req, res) => {
|
||
try {
|
||
if (!fs.existsSync(LOG_DIR)) {
|
||
return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } })
|
||
}
|
||
|
||
const dateStr = getTodayStr()
|
||
const todayFile = path.join(LOG_DIR, `${dateStr}.log`)
|
||
|
||
let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 }
|
||
|
||
if (fs.existsSync(todayFile)) {
|
||
const content = fs.readFileSync(todayFile, 'utf-8')
|
||
const lines = content.split('\n').filter(l => l.trim())
|
||
todayStats.total = lines.length
|
||
todayStats.errors = lines.filter(l => l.includes(' ERROR ')).length
|
||
todayStats.slow = lines.filter(l => l.includes('Slow request')).length
|
||
todayStats.warns = lines.filter(l => l.includes(' WARN ')).length
|
||
}
|
||
|
||
// 最近7天统计
|
||
const files = fs.readdirSync(LOG_DIR)
|
||
.filter(f => f.endsWith('.log'))
|
||
.sort()
|
||
.reverse()
|
||
.slice(0, 7)
|
||
|
||
const dailyStats = files.map(f => {
|
||
const filePath = path.join(LOG_DIR, f)
|
||
const content = fs.readFileSync(filePath, 'utf-8')
|
||
const lines = content.split('\n').filter(l => l.trim())
|
||
return {
|
||
date: f.replace('.log', ''),
|
||
total: lines.length,
|
||
errors: lines.filter(l => l.includes(' ERROR ')).length,
|
||
slow: lines.filter(l => l.includes('Slow request')).length
|
||
}
|
||
})
|
||
|
||
res.json({
|
||
code: 0,
|
||
data: {
|
||
today: todayStats,
|
||
daily: dailyStats
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error('[Logs] Stats error:', err)
|
||
res.status(500).json({ code: 50000, message: '获取日志统计失败' })
|
||
}
|
||
})
|
||
|
||
/** 获取埋点统计(必须在 /:date 之前注册) */
|
||
router.get('/track/stats', requireAdmin, async (_req, res) => {
|
||
try {
|
||
// 今日统计
|
||
const [todayRows] = await pool.query(
|
||
`SELECT event, COUNT(*) as count FROM track_events
|
||
WHERE DATE(created_at) = CURDATE()
|
||
GROUP BY event ORDER BY count DESC`
|
||
)
|
||
|
||
// 最近7天每日统计
|
||
const [dailyRows] = await pool.query(
|
||
`SELECT DATE(created_at) as date, COUNT(*) as count FROM track_events
|
||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||
GROUP BY DATE(created_at) ORDER BY date DESC`
|
||
)
|
||
|
||
// 最近7天事件分布
|
||
const [eventRows] = await pool.query(
|
||
`SELECT event, COUNT(*) as count FROM track_events
|
||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||
GROUP BY event ORDER BY count DESC LIMIT 10`
|
||
)
|
||
|
||
res.json({
|
||
code: 0,
|
||
data: {
|
||
today: todayRows,
|
||
daily: dailyRows,
|
||
events: eventRows
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error('[Track] Stats error:', err)
|
||
res.status(500).json({ code: 50000, message: '获取埋点统计失败' })
|
||
}
|
||
})
|
||
|
||
/** 查询埋点事件(必须在 /:date 之前注册) */
|
||
router.get('/track/list', requireAdmin, async (req, res) => {
|
||
try {
|
||
const { event, page = '1', pageSize = '50' } = req.query
|
||
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||
const pSize = Math.min(200, Math.max(1, parseInt(pageSize as string) || 50))
|
||
const offset = (pNum - 1) * pSize
|
||
|
||
let where = '1=1'
|
||
const params: any[] = []
|
||
|
||
if (event && typeof event === 'string') {
|
||
where += ' AND t.event = ?'
|
||
params.push(event)
|
||
}
|
||
|
||
const [countResult] = await pool.query(
|
||
`SELECT COUNT(*) as total FROM track_events t WHERE ${where}`,
|
||
params
|
||
)
|
||
const total = (countResult as any[])[0].total
|
||
|
||
const [rows] = await pool.query(
|
||
`SELECT t.*, u.nickname
|
||
FROM track_events t
|
||
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
|
||
WHERE ${where}
|
||
ORDER BY t.created_at DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[...params, pSize, offset]
|
||
)
|
||
|
||
res.json({
|
||
code: 0,
|
||
data: { list: rows, total, page: pNum, pageSize: pSize }
|
||
})
|
||
} catch (err) {
|
||
console.error('[Track] List error:', err)
|
||
res.status(500).json({ code: 50000, message: '查询埋点失败' })
|
||
}
|
||
})
|
||
|
||
/** 查询指定日期的日志(必须在 /track/* 之后注册) */
|
||
router.get('/:date', requireAdmin, (req, res) => {
|
||
try {
|
||
const { date } = req.params
|
||
const { level, keyword, page = '1', pageSize = '100' } = req.query
|
||
|
||
// 日期格式校验
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
return res.status(400).json({ code: 40001, message: '日期格式无效' })
|
||
}
|
||
|
||
const logFile = path.join(LOG_DIR, `${date}.log`)
|
||
if (!fs.existsSync(logFile)) {
|
||
return res.json({ code: 0, data: { lines: [], total: 0 } })
|
||
}
|
||
|
||
const content = fs.readFileSync(logFile, 'utf-8')
|
||
let lines = content.split('\n').filter(l => l.trim())
|
||
|
||
// 按级别过滤
|
||
const levelStr = typeof level === 'string' ? level : ''
|
||
if (levelStr && levelStr !== 'all') {
|
||
lines = lines.filter(l => l.includes(` ${levelStr.toUpperCase()} `))
|
||
}
|
||
|
||
// 按关键词过滤
|
||
if (keyword && typeof keyword === 'string') {
|
||
const kw = keyword.toLowerCase()
|
||
lines = lines.filter(l => l.toLowerCase().includes(kw))
|
||
}
|
||
|
||
const total = lines.length
|
||
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||
const pSize = Math.min(500, Math.max(1, parseInt(pageSize as string) || 100))
|
||
const offset = (pNum - 1) * pSize
|
||
const paged = lines.slice(offset, offset + pSize)
|
||
|
||
res.json({
|
||
code: 0,
|
||
data: {
|
||
lines: paged,
|
||
total,
|
||
page: pNum,
|
||
pageSize: pSize
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error('[Logs] Query error:', err)
|
||
res.status(500).json({ code: 50000, message: '查询日志失败' })
|
||
}
|
||
})
|
||
|
||
export default router
|