diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 0000000..163d307 --- /dev/null +++ b/app/api/tasks/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { taskScheduler } from '../../../lib/scheduler'; + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const action = url.searchParams.get('action'); + const taskName = url.searchParams.get('task'); + + switch (action) { + case 'status': + if (taskName) { + const status = taskScheduler.getTaskStatus(taskName); + return NextResponse.json({ + task: taskName, + running: status + }); + } else { + const tasks = taskScheduler.listTasks(); + const statuses = tasks.map(task => ({ + name: task, + running: taskScheduler.getTaskStatus(task) + })); + return NextResponse.json({ tasks: statuses }); + } + + case 'list': + const allTasks = taskScheduler.listTasks(); + return NextResponse.json({ tasks: allTasks }); + + default: + return NextResponse.json({ + message: 'Task scheduler is running', + availableActions: ['status', 'list'], + tasks: taskScheduler.listTasks() + }); + } + } catch (error) { + console.error('Task scheduler API error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { action, taskName } = await request.json(); + + switch (action) { + case 'stop': + if (taskName) { + taskScheduler.stopTask(taskName); + return NextResponse.json({ + message: `Task ${taskName} stopped` + }); + } else { + taskScheduler.stopAll(); + return NextResponse.json({ + message: 'All tasks stopped' + }); + } + + case 'start': + // 重新启动所有任务 + taskScheduler.startAll(); + return NextResponse.json({ + message: 'All tasks started' + }); + + default: + return NextResponse.json( + { error: 'Invalid action' }, + { status: 400 } + ); + } + } catch (error) { + console.error('Task scheduler API error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/hosts/[hostname]/screenshots/route.ts b/app/hosts/[hostname]/screenshots/route.ts index c618ab3..48e0c8c 100644 --- a/app/hosts/[hostname]/screenshots/route.ts +++ b/app/hosts/[hostname]/screenshots/route.ts @@ -3,6 +3,10 @@ import { prisma } from '@/lib/prisma' import { storeFile } from '@/lib/fileStorage' import { push } from '@/lib/push' import { withCors } from '@/lib/middleware' +import ffmpeg from 'fluent-ffmpeg' +import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' interface WindowInfo { title: string @@ -17,7 +21,7 @@ async function handleScreenshotUpload(req: NextRequest) { const pathSegments = req.nextUrl.pathname.split('/') const hostnameIndex = pathSegments.indexOf('hosts') + 1 const hostname = pathSegments[hostnameIndex] - + if (!hostname) { return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) } @@ -25,7 +29,7 @@ async function handleScreenshotUpload(req: NextRequest) { const formData = await req.formData() const files: File[] = [] const windowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]') - + // Extract files from formData for (const [key, value] of formData.entries()) { if (key.startsWith('screenshot_') && value instanceof File) { @@ -38,23 +42,78 @@ async function handleScreenshotUpload(req: NextRequest) { } // Process screenshots + const processingStartTime = Date.now() + console.log(`[${hostname}] 开始处理 ${files.length} 张截图`) + const screenshots = await Promise.all(files.map(async (file, index) => { - const ext = file.name ? file.name.split('.').pop() : 'webp' - const filename = `${hostname}-${Date.now()}-${Math.round(Math.random() * 1E9)}.${ext}` - + const imageStartTime = Date.now() + + // Convert image to AV1 format using FFmpeg const buffer = Buffer.from(await file.arrayBuffer()) - const storedFile = await storeFile(buffer, filename, file.type, 'screenshot', hostname) - - return { - fileId: storedFile.id, - objectName: storedFile.objectName, - filename: storedFile.filename, - contentType: storedFile.contentType, - fileSize: storedFile.size, - monitorName: formData.get(`monitor_name_${index}`) as string || `Monitor ${index + 1}` + const originalSize = buffer.length + + // Create temporary files + const tempDir = mkdtempSync(join(tmpdir(), 'ffmpeg-')) + const inputPath = join(tempDir, `input_${index}.png`) + const outputPath = join(tempDir, `output_${index}.avif`) + + try { + // Write input file + writeFileSync(inputPath, buffer) + + const conversionStartTime = Date.now() + + // Convert using FFmpeg with libsvtav1 + await new Promise((resolve, reject) => { + ffmpeg(inputPath) + .videoCodec('libsvtav1') + .outputOptions([ + '-crf 30', // 质量控制,值越小质量越好 + '-preset 6', // 编码速度预设,0-12,值越大速度越快但质量可能下降 + '-pix_fmt yuv420p' // 像素格式 + ]) + .output(outputPath) + .on('end', () => resolve()) + .on('error', (err) => reject(err)) + .run() + }) + + const conversionTime = Date.now() - conversionStartTime + + // Read the converted file + const avifBuffer = readFileSync(outputPath) + const filename = `${hostname}-${Date.now()}-${Math.round(Math.random() * 1E9)}.avif` + + const storageStartTime = Date.now() + const storedFile = await storeFile(avifBuffer, filename, 'image/avif', 'screenshot', hostname) + const storageTime = Date.now() - storageStartTime + + const totalImageTime = Date.now() - imageStartTime + const compressionRatio = ((originalSize - avifBuffer.length) / originalSize * 100).toFixed(2) + + console.log(`[${hostname}] 图片 ${index + 1}: FFmpeg转换 ${conversionTime}ms, 存储 ${storageTime}ms, 总计 ${totalImageTime}ms, 压缩率 ${compressionRatio}% (${originalSize} -> ${avifBuffer.length} bytes)`) + + return { + fileId: storedFile.id, + objectName: storedFile.objectName, + filename: storedFile.filename, + contentType: storedFile.contentType, + fileSize: storedFile.size, + monitorName: formData.get(`monitor_name_${index}`) as string || `Monitor ${index + 1}` + } + } finally { + // Clean up temporary files + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch (err) { + console.warn(`清理临时文件失败: ${err}`) + } } })) + const totalProcessingTime = Date.now() - processingStartTime + console.log(`[${hostname}] 完成处理 ${files.length} 张截图,总耗时 ${totalProcessingTime}ms,平均每张 ${(totalProcessingTime / files.length).toFixed(2)}ms`) + // Ensure host exists first await prisma.host.upsert({ where: { hostname }, @@ -124,7 +183,7 @@ async function handleGetScreenshots(req: NextRequest) { const pathSegments = req.nextUrl.pathname.split('/') const hostnameIndex = pathSegments.indexOf('hosts') + 1 const hostname = pathSegments[hostnameIndex] - + if (!hostname) { return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) } @@ -132,27 +191,27 @@ async function handleGetScreenshots(req: NextRequest) { const { searchParams } = req.nextUrl const startTimeParam = searchParams.get('startTime') const endTimeParam = searchParams.get('endTime') - + let startTime: Date | undefined let endTime: Date | undefined - + if (startTimeParam) { - const timestamp = isNaN(Number(startTimeParam)) ? - new Date(startTimeParam) : + const timestamp = isNaN(Number(startTimeParam)) ? + new Date(startTimeParam) : new Date(Number(startTimeParam) * 1000) startTime = timestamp } - + if (endTimeParam) { - const timestamp = isNaN(Number(endTimeParam)) ? - new Date(endTimeParam) : + const timestamp = isNaN(Number(endTimeParam)) ? + new Date(endTimeParam) : new Date(Number(endTimeParam) * 1000) endTime = timestamp } // Build query conditions const whereClause: any = { hostname } - + if (startTime || endTime) { whereClause.timestamp = {} if (startTime) whereClause.timestamp.gte = startTime @@ -179,7 +238,7 @@ async function handleGetScreenshots(req: NextRequest) { lastUpdate: true } }) - + if (!host) { return NextResponse.json({ error: '未找到主机记录' }, { status: 404 }) } diff --git a/app/page.tsx b/app/page.tsx index 11d0a3c..ff29ecd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -51,7 +51,23 @@ export default function Home() {
-

屏幕截图监控系统

+
+

屏幕截图监控系统

+
+ + +
+
{/* 主机列表卡片网格 */}
diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx new file mode 100644 index 0000000..2e3299b --- /dev/null +++ b/app/tasks/page.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface TaskStatus { + name: string; + running: boolean; +} + +export default function TasksPage() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(''); + + useEffect(() => { + fetchTasks(); + }, []); + + const fetchTasks = async () => { + try { + const response = await fetch('/api/tasks?action=status'); + const data = await response.json(); + setTasks(data.tasks || []); + } catch (error) { + console.error('Failed to fetch tasks:', error); + setMessage('获取任务状态失败'); + } finally { + setLoading(false); + } + }; + + const handleAction = async (action: string, taskName?: string) => { + try { + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action, taskName }), + }); + + const data = await response.json(); + setMessage(data.message || '操作完成'); + + // 刷新任务状态 + await fetchTasks(); + } catch (error) { + console.error('Failed to perform action:', error); + setMessage('操作失败'); + } + }; + + if (loading) { + return ( +
+
加载中...
+
+ ); + } + + return ( +
+

定时任务管理

+ + {message && ( +
+ {message} +
+ )} + +
+
+

任务列表

+
+ + + +
+
+ + {tasks.length === 0 ? ( +
+ 没有找到定时任务 +
+ ) : ( +
+ + + + + + + + + + + {tasks.map((task) => ( + + + + + + + ))} + +
任务名称状态描述操作
+ {task.name} + + + {task.running ? '运行中' : '已停止'} + + + {task.name === 'hourlyTask' && '每小时第30分钟执行'} + {task.name === 'dailyCleanup' && '每日凌晨2点清理任务'} + + +
+
+ )} +
+ +
+

Cron 表达式说明

+
+
30 * * * * - 每小时的第30分钟执行
+
0 2 * * * - 每天凌晨2点执行
+
0 */2 * * * - 每2小时执行一次
+
0 9 * * 1-5 - 周一到周五上午9点执行
+
0 0 1 * * - 每月1号凌晨执行
+
*/15 * * * * - 每15分钟执行一次
+
+
+
+ ); +} diff --git a/bun.lock b/bun.lock index 1bdad2c..f9bb997 100644 --- a/bun.lock +++ b/bun.lock @@ -7,16 +7,19 @@ "@prisma/client": "^6.10.1", "@types/bcryptjs": "^3.0.0", "@types/cors": "^2.8.19", + "@types/fluent-ffmpeg": "^2.1.27", "@types/minio": "^7.1.1", "@types/multer": "^1.4.13", "bcryptjs": "^3.0.2", "cors": "^2.8.5", "date-fns": "^4.1.0", "dotenv-cli": "^8.0.0", + "fluent-ffmpeg": "^2.1.3", "lucide-react": "^0.525.0", "minio": "^8.0.5", "multer": "^2.0.1", "next": "15.3.4", + "node-cron": "^4.1.1", "prisma": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -24,6 +27,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", @@ -170,6 +174,8 @@ "@types/express-serve-static-core": ["@types/express-serve-static-core@5.0.6", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA=="], + "@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.27", "https://registry.npmmirror.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.27.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A=="], + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], @@ -180,6 +186,8 @@ "@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], + "@types/node-cron": ["@types/node-cron@3.0.11", "", {}, "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -196,7 +204,7 @@ "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async": ["async@0.2.10", "https://registry.npmmirror.com/async/-/async-0.2.10.tgz", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], @@ -270,6 +278,8 @@ "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], + "fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "https://registry.npmmirror.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -362,6 +372,8 @@ "next": ["next@15.3.4", "", { "dependencies": { "@next/env": "15.3.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.4", "@next/swc-darwin-x64": "15.3.4", "@next/swc-linux-arm64-gnu": "15.3.4", "@next/swc-linux-arm64-musl": "15.3.4", "@next/swc-linux-x64-gnu": "15.3.4", "@next/swc-linux-x64-musl": "15.3.4", "@next/swc-win32-arm64-msvc": "15.3.4", "@next/swc-win32-x64-msvc": "15.3.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA=="], + "node-cron": ["node-cron@4.1.1", "", {}, "sha512-oJj9CYV7teeCVs+y2Efi5IQ4FGmAYbsXQOehc1AGLlwteec8pC7DjBCUzSyRQ0LYa+CRCgmD+vtlWQcnPpXowA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -446,7 +458,7 @@ "web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@1.3.1", "https://registry.npmmirror.com/which/-/which-1.3.1.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], @@ -470,6 +482,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "minio/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "tar/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], diff --git a/lib/init-scheduler.ts b/lib/init-scheduler.ts new file mode 100644 index 0000000..9eb34cb --- /dev/null +++ b/lib/init-scheduler.ts @@ -0,0 +1,16 @@ +import { taskScheduler } from './scheduler'; + +// 确保定时任务只初始化一次 +let isInitialized = false; + +export function initializeScheduler() { + if (!isInitialized && typeof window === 'undefined') { + // 只在服务端初始化 + taskScheduler.startAll(); + isInitialized = true; + console.log('Task scheduler initialized'); + } +} + +// 在模块加载时自动初始化 +initializeScheduler(); diff --git a/lib/scheduler.ts b/lib/scheduler.ts new file mode 100644 index 0000000..0509c06 --- /dev/null +++ b/lib/scheduler.ts @@ -0,0 +1,121 @@ +import * as cron from 'node-cron'; +import { prisma } from './prisma'; + +// 定时任务管理器 +class TaskScheduler { + private tasks: Map = new Map(); + + // 启动所有定时任务 + startAll() { + this.scheduleHourlyTask(); + this.scheduleDailyCleanup(); + console.log('All scheduled tasks started'); + } + + // 每小时第30分钟执行的任务 + private scheduleHourlyTask() { + const task = cron.schedule('30 * * * *', async () => { + console.log('Running hourly task at minute 30...'); + try { + await this.hourlyTaskHandler(); + } catch (error) { + console.error('Hourly task failed:', error); + } + }); + + this.tasks.set('hourlyTask', task); + console.log('Hourly task scheduled (every hour at minute 30)'); + } + + // 每日凌晨2点清理任务 + private scheduleDailyCleanup() { + const task = cron.schedule('0 2 * * *', async () => { + console.log('Running daily cleanup task...'); + try { + await this.dailyCleanupHandler(); + } catch (error) { + console.error('Daily cleanup task failed:', error); + } + }); + + this.tasks.set('dailyCleanup', task); + console.log('Daily cleanup task scheduled (2:00 AM every day)'); + } + + // 每小时执行的具体任务逻辑 + private async hourlyTaskHandler() { + // 在这里添加你的业务逻辑 + // 例如:清理过期数据、同步数据、发送通知等 + + // 示例:清理30天前的记录 + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // 根据你的数据库结构调整这个查询 + // const result = await prisma.someTable.deleteMany({ + // where: { + // createdAt: { + // lt: thirtyDaysAgo + // } + // } + // }); + + console.log(`Hourly task completed at ${new Date().toISOString()}`); + } + + // 每日清理任务的具体逻辑 + private async dailyCleanupHandler() { + // 在这里添加每日清理逻辑 + // 例如:清理临时文件、压缩日志、备份数据等 + + console.log(`Daily cleanup completed at ${new Date().toISOString()}`); + } + + // 停止指定任务 + stopTask(taskName: string) { + const task = this.tasks.get(taskName); + if (task) { + task.stop(); + console.log(`Task ${taskName} stopped`); + } + } + + // 停止所有任务 + stopAll() { + this.tasks.forEach((task, name) => { + task.stop(); + console.log(`Task ${name} stopped`); + }); + this.tasks.clear(); + } + + // 获取任务状态 + getTaskStatus(taskName: string): boolean { + const task = this.tasks.get(taskName); + return task ? true : false; // 如果任务存在则认为正在运行 + } + + // 列出所有任务 + listTasks(): string[] { + return Array.from(this.tasks.keys()); + } +} + +// 导出单例实例 +export const taskScheduler = new TaskScheduler(); + +// Cron 表达式说明: +// ┌───────────── 分钟 (0 - 59) +// │ ┌─────────── 小时 (0 - 23) +// │ │ ┌───────── 日期 (1 - 31) +// │ │ │ ┌─────── 月份 (1 - 12) +// │ │ │ │ ┌───── 星期 (0 - 7, 0和7都是周日) +// │ │ │ │ │ +// * * * * * + +// 常用示例: +// '30 * * * *' - 每小时的第30分钟 +// '0 */2 * * *' - 每2小时执行一次 +// '0 9 * * 1-5' - 周一到周五上午9点 +// '0 0 1 * *' - 每月1号凌晨执行 +// '*/15 * * * *' - 每15分钟执行一次 diff --git a/package.json b/package.json index f710894..32b776e 100644 --- a/package.json +++ b/package.json @@ -16,26 +16,30 @@ "@prisma/client": "^6.10.1", "@types/bcryptjs": "^3.0.0", "@types/cors": "^2.8.19", + "@types/fluent-ffmpeg": "^2.1.27", "@types/minio": "^7.1.1", "@types/multer": "^1.4.13", "bcryptjs": "^3.0.2", "cors": "^2.8.5", "date-fns": "^4.1.0", "dotenv-cli": "^8.0.0", + "fluent-ffmpeg": "^2.1.3", "lucide-react": "^0.525.0", "minio": "^8.0.5", "multer": "^2.0.1", "next": "15.3.4", + "node-cron": "^4.1.1", "prisma": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "typescript": "^5", + "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4" + "tailwindcss": "^4", + "typescript": "^5" } }