355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import os from 'os'
|
||
import { getAllSessions } from '@/lib/upload-sessions-redis'
|
||
import { transcodeQueue } from '@/lib/video-transcode-queue'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { getNetworkSpeed } from '@/lib/network-bandwidth-monitor'
|
||
import { authOptions } from '@/lib/auth'
|
||
import { hasSystemAdminPermission } from '@/lib/department-permission'
|
||
import {
|
||
classifyTranscodeMaterial,
|
||
classifyUploadSession,
|
||
readProcessMemory,
|
||
} from '@/lib/system-performance'
|
||
|
||
const RATE_LIMIT_WINDOW_MS = 60 * 1000
|
||
const RATE_LIMIT_MAX_REQUESTS = 40
|
||
const MAX_VISIBLE_UPLOAD_SESSIONS = 100
|
||
const requestCounts = new Map<string, { count: number; resetAt: number }>()
|
||
|
||
function consumeRateLimit(userId: string): number | null {
|
||
const now = Date.now()
|
||
const current = requestCounts.get(userId)
|
||
if (!current || current.resetAt <= now) {
|
||
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
|
||
return null
|
||
}
|
||
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
|
||
}
|
||
current.count += 1
|
||
return null
|
||
}
|
||
|
||
// 性能监控 - 快速统计接口(用于悬停卡片)
|
||
// GET /api/performance/quick
|
||
export const dynamic = 'force-dynamic'
|
||
export const revalidate = 0
|
||
|
||
// 获取CPU使用率(正确的方法:需要两次采样)
|
||
async function getCpuUsage(): Promise<number> {
|
||
try {
|
||
const snapshot = () => os.cpus().map((cpu) => cpu.times)
|
||
const before = snapshot()
|
||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||
const after = snapshot()
|
||
let totalDiff = 0
|
||
let idleDiff = 0
|
||
|
||
for (let index = 0; index < Math.min(before.length, after.length); index += 1) {
|
||
const previous = before[index]
|
||
const current = after[index]
|
||
if (!previous || !current) {
|
||
continue
|
||
}
|
||
const previousTotal =
|
||
previous.user + previous.nice + previous.sys + previous.idle + previous.irq
|
||
const currentTotal =
|
||
current.user + current.nice + current.sys + current.idle + current.irq
|
||
totalDiff += Math.max(0, currentTotal - previousTotal)
|
||
idleDiff += Math.max(0, current.idle - previous.idle)
|
||
}
|
||
|
||
if (totalDiff > 0) {
|
||
const usedDiff = Math.max(0, totalDiff - idleDiff)
|
||
const cpuUsage = (usedDiff / totalDiff) * 100
|
||
return Math.min(Math.max(Math.round(cpuUsage), 0), 100)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[PerformanceQuick] Failed to sample CPU usage:', error)
|
||
}
|
||
|
||
// 降级方案:使用负载平均值估算(不准确,但总比没有好)
|
||
const loadAvg = os.loadavg()[0]
|
||
const cpuCount = os.cpus().length
|
||
// 负载平均值反映系统负载,但不等同于CPU使用率
|
||
// 负载平均值 = CPU核心数时,表示CPU满载
|
||
// 这里做一个粗略估算:负载平均值 / CPU核心数 * 100,但限制在100%以内
|
||
const estimatedUsage = Math.min(Math.round((loadAvg / cpuCount) * 100), 100)
|
||
console.warn(`[PerformanceQuick] Using loadavg estimation: load=${loadAvg}, cores=${cpuCount}, estimated=${estimatedUsage}%`)
|
||
return estimatedUsage
|
||
}
|
||
|
||
export async function GET(request: NextRequest) {
|
||
try {
|
||
const session = await getServerSession(authOptions)
|
||
if (!session?.user) {
|
||
return NextResponse.json({ error: '请先登录' }, { status: 401 })
|
||
}
|
||
|
||
const user = session.user as any
|
||
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
|
||
return NextResponse.json({ error: '仅系统管理员可查看性能数据' }, { status: 403 })
|
||
}
|
||
|
||
const rateLimitKey = `${String(user.id || 'unknown')}:${request.headers.get('x-forwarded-for') || ''}`
|
||
const retryAfter = consumeRateLimit(rateLimitKey)
|
||
if (retryAfter) {
|
||
return NextResponse.json(
|
||
{ error: '请求过于频繁,请稍后再试' },
|
||
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
||
)
|
||
}
|
||
|
||
const loadAvg = os.loadavg()
|
||
const cpuCores = Math.max(1, os.cpus().length)
|
||
const [cpuUsage, memoryStats] = await Promise.all([getCpuUsage(), readProcessMemory()])
|
||
|
||
const cpu = {
|
||
usage: cpuUsage,
|
||
loadAverage: loadAvg,
|
||
cores: cpuCores,
|
||
}
|
||
|
||
const memory = {
|
||
used: Math.round(memoryStats.usedBytes / (1024 * 1024)),
|
||
total: Math.round(memoryStats.totalBytes / (1024 * 1024)),
|
||
percentage: memoryStats.percentage,
|
||
usedBytes: memoryStats.usedBytes,
|
||
totalBytes: memoryStats.totalBytes,
|
||
source: memoryStats.source,
|
||
hostUsedBytes: memoryStats.hostUsedBytes,
|
||
hostTotalBytes: memoryStats.hostTotalBytes,
|
||
hostPercentage: memoryStats.hostPercentage,
|
||
}
|
||
|
||
const networkSpeed = await getNetworkSpeed()
|
||
const network = {
|
||
uploadSpeed: networkSpeed?.uploadSpeed || 0,
|
||
downloadSpeed: networkSpeed?.downloadSpeed || 0,
|
||
interface: networkSpeed?.interface || 'unknown',
|
||
scope: 'app-container' as const,
|
||
}
|
||
|
||
// 2. 上传队列(从 Redis 获取,只显示真正活跃的上传)
|
||
const uploadProcessing: any[] = []
|
||
const uploadWaiting: any[] = []
|
||
const uploadUserIds = new Set<string>()
|
||
const uploadIds = new Set<string>()
|
||
|
||
// 从 Redis 获取所有上传会话
|
||
const uploadSessions = await getAllSessions()
|
||
|
||
// 只显示真正进行中的上传(进度 < 100% 且最近有活动)
|
||
const now = Date.now()
|
||
// 说明:
|
||
// - lastActivityAt:每个 chunk 上传完成后更新(更能代表“是否真的在上传”)
|
||
// - createdAt:只能代表会话创建时间,不能代表持续活跃
|
||
const ACTIVE_THRESHOLD = 5 * 60 * 1000 // 5分钟内有活动 → 认为“活跃”
|
||
const MAX_SHOW_THRESHOLD = 60 * 60 * 1000 // 超过1小时无活动 → 不再展示(避免长期误导)
|
||
|
||
Array.from(uploadSessions.entries())
|
||
.sort(([, left], [, right]) => {
|
||
const leftActivity = left.lastActivityAt || left.createdAt || 0
|
||
const rightActivity = right.lastActivityAt || right.createdAt || 0
|
||
return rightActivity - leftActivity
|
||
})
|
||
.slice(0, MAX_VISIBLE_UPLOAD_SESSIONS)
|
||
.forEach(([uploadId, session]) => {
|
||
const completedChunks = session.chunks.size
|
||
const totalChunks = session.totalChunks || 1
|
||
const progress = Math.min(
|
||
100,
|
||
Math.round((completedChunks / totalChunks) * 100)
|
||
)
|
||
|
||
const lastActivityAt = (session as any).lastActivityAt || session.createdAt || 0
|
||
const inactiveMs = lastActivityAt > 0 ? now - lastActivityAt : Number.POSITIVE_INFINITY
|
||
const isActive = inactiveMs >= 0 && inactiveMs < ACTIVE_THRESHOLD
|
||
|
||
const bucket = classifyUploadSession({
|
||
progress,
|
||
inactiveMs,
|
||
activeThresholdMs: ACTIVE_THRESHOLD,
|
||
maxShowThresholdMs: MAX_SHOW_THRESHOLD,
|
||
})
|
||
if (bucket === 'hidden') {
|
||
return
|
||
}
|
||
|
||
uploadUserIds.add(session.userId)
|
||
uploadIds.add(uploadId)
|
||
const item = {
|
||
uploadId,
|
||
fileName: session.fileName,
|
||
fileSize: session.fileSize || 0,
|
||
progress,
|
||
userId: session.userId,
|
||
currentSpeed: (session as any).currentSpeed || 0,
|
||
uploadedBytes: (session as any).uploadedBytes || 0,
|
||
lastActivityAt,
|
||
inactiveMs,
|
||
isActive,
|
||
}
|
||
if (bucket === 'processing') {
|
||
uploadProcessing.push(item)
|
||
} else {
|
||
uploadWaiting.push(item)
|
||
}
|
||
})
|
||
|
||
// 批量查询用户信息
|
||
const userInfoMap = new Map<string, { username: string; realName?: string }>()
|
||
if (uploadUserIds.size > 0) {
|
||
try {
|
||
const users = await prisma.user.findMany({
|
||
where: {
|
||
id: { in: Array.from(uploadUserIds) },
|
||
} as any,
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
} as any,
|
||
})
|
||
users.forEach((user: any) => {
|
||
userInfoMap.set(user.id, {
|
||
username: user.username || '未知',
|
||
realName: user.realName || undefined,
|
||
})
|
||
})
|
||
} catch (error) {
|
||
console.error('[PerformanceQuick] Failed to fetch user info:', error)
|
||
}
|
||
}
|
||
|
||
// 尝试通过uploadId查询已创建的material记录(获取materialType)
|
||
const uploadMaterialInfoMap = new Map<string, { materialType: string }>()
|
||
if (uploadIds.size > 0) {
|
||
try {
|
||
const materials = await prisma.material.findMany({
|
||
where: {
|
||
uploadId: { in: Array.from(uploadIds) },
|
||
deletedAt: null,
|
||
} as any,
|
||
select: {
|
||
uploadId: true,
|
||
materialType: true,
|
||
} as any,
|
||
})
|
||
materials.forEach((m: any) => {
|
||
if (m.uploadId) {
|
||
uploadMaterialInfoMap.set(m.uploadId, {
|
||
materialType: m.materialType || '未知类型',
|
||
})
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error('[PerformanceQuick] Failed to fetch upload material info:', error)
|
||
}
|
||
}
|
||
|
||
const decorateUploadTask = (task: any) => {
|
||
const userInfo = userInfoMap.get(task.userId) || { username: '未知用户' }
|
||
const materialInfo = uploadMaterialInfoMap.get(task.uploadId) || { materialType: '未知类型' }
|
||
return {
|
||
...task,
|
||
username: userInfo.realName || userInfo.username || '未知用户',
|
||
materialType: materialInfo.materialType,
|
||
}
|
||
}
|
||
const uploadProcessingWithUsers = uploadProcessing.map(decorateUploadTask)
|
||
const uploadWaitingWithUsers = uploadWaiting.map(decorateUploadTask)
|
||
|
||
const uploadQueue = {
|
||
processing: uploadProcessingWithUsers,
|
||
waiting: uploadWaitingWithUsers,
|
||
total: uploadProcessingWithUsers.length + uploadWaitingWithUsers.length,
|
||
}
|
||
|
||
const status = transcodeQueue.getStatus()
|
||
const progressObj: Record<string, number> = {}
|
||
const stageObj: Record<string, string> = {}
|
||
status.progress.forEach((value, key) => {
|
||
progressObj[key] = value
|
||
})
|
||
status.stages.forEach((value, key) => {
|
||
stageObj[key] = value
|
||
})
|
||
|
||
const transcodeMaterials = await prisma.material.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
videoProcessingStatus: { in: ['pending', 'processing'] },
|
||
},
|
||
select: {
|
||
id: true,
|
||
zipFileName: true,
|
||
materialType: true,
|
||
authorName: true,
|
||
videoProcessingStatus: true,
|
||
transcodeLeaseExpiresAt: true,
|
||
user: {
|
||
select: {
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: { updatedAt: 'asc' },
|
||
take: 80,
|
||
})
|
||
|
||
const transcodeNow = new Date()
|
||
const transcodeProcessing: any[] = []
|
||
const transcodeWaiting: any[] = []
|
||
transcodeMaterials.forEach((material) => {
|
||
const bucket = classifyTranscodeMaterial({
|
||
status: material.videoProcessingStatus,
|
||
leaseExpiresAt: material.transcodeLeaseExpiresAt,
|
||
now: transcodeNow,
|
||
})
|
||
if (bucket === 'hidden') return
|
||
const item = {
|
||
materialId: material.id,
|
||
fileName: material.zipFileName || '未知文件',
|
||
materialType: material.materialType || '未知类型',
|
||
authorName: material.authorName || material.user?.username || '未知用户',
|
||
progress: progressObj[material.id] ?? null,
|
||
stage: stageObj[material.id] ?? null,
|
||
}
|
||
if (bucket === 'processing') {
|
||
transcodeProcessing.push(item)
|
||
} else {
|
||
transcodeWaiting.push({
|
||
...item,
|
||
queuePosition: transcodeWaiting.length + 1,
|
||
})
|
||
}
|
||
})
|
||
|
||
const transcodeQueueData = {
|
||
processing: transcodeProcessing,
|
||
waiting: transcodeWaiting,
|
||
total: transcodeProcessing.length + transcodeWaiting.length,
|
||
maxConcurrent: status.maxConcurrent,
|
||
}
|
||
|
||
return NextResponse.json({
|
||
cpu,
|
||
memory,
|
||
network,
|
||
uploadQueue,
|
||
transcodeQueue: transcodeQueueData,
|
||
})
|
||
} catch (error) {
|
||
console.error('[PerformanceQuick] 获取性能数据失败:', error)
|
||
return NextResponse.json(
|
||
{ error: '获取性能数据失败' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
|