import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { NextRequest, NextResponse } from 'next/server'; import { requireAdmin } from '@/lib/admin-auth'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type UpgradeMode = 'hot' | 'cold'; type UpgradeStatus = | 'queued' | 'running' | 'rolling_back' | 'succeeded' | 'failed' | 'rolled_back' | 'rollback_failed'; type UpgradeJobState = { id: string; mode: UpgradeMode; status: UpgradeStatus; step: string; message: string; progress: number; packageName: string; packageHash?: string; backupFile?: string; sourceBackupFile?: string; restartRequired?: boolean; changedFiles?: string[]; preExistingFiles?: string[]; error?: string; startedAt: string; updatedAt: string; finishedAt?: string; logs: string[]; }; const MAX_PACKAGE_BYTES = 300 * 1024 * 1024; const RUNNING_STATUSES = new Set(['queued', 'running', 'rolling_back']); export async function GET(request: NextRequest) { const authError = await requireAdmin(request); if (authError) return authError; try { const states = await readStates(); return NextResponse.json({ latest: states[0] || null, history: states, stateDir: getUpgradeStateRoot(), running: states.some(job => RUNNING_STATUSES.has(job.status)), }); } catch (error) { console.error('[admin/upgrade] failed to read state:', error); return NextResponse.json({ error: '读取升级状态失败' }, { status: 500 }); } } export async function POST(request: NextRequest) { const authError = await requireAdmin(request); if (authError) return authError; try { const states = await readStates(); const runningJob = states.find(job => RUNNING_STATUSES.has(job.status)); if (runningJob) { return NextResponse.json({ error: `已有升级任务正在执行:${runningJob.id}` }, { status: 409 }); } const form = await request.formData(); const modeValue = String(form.get('mode') || ''); const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null; if (!mode) { return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 }); } const file = form.get('package'); if (!(file instanceof File)) { return NextResponse.json({ error: '请上传升级包' }, { status: 400 }); } if (file.size <= 0) { return NextResponse.json({ error: '升级包为空' }, { status: 400 }); } if (file.size > MAX_PACKAGE_BYTES) { return NextResponse.json({ error: '升级包不能超过 300MB' }, { status: 400 }); } if (!isAllowedArchiveName(file.name)) { return NextResponse.json({ error: '仅支持 .tar、.tar.gz、.tgz 升级包' }, { status: 400 }); } const stateRoot = getUpgradeStateRoot(); const jobId = `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomUUID().slice(0, 8)}`; const jobDir = path.join(stateRoot, 'jobs', jobId); const uploadDir = path.join(jobDir, 'upload'); await fs.mkdir(uploadDir, { recursive: true, mode: 0o700 }); const safeName = sanitizeFileName(file.name); const packagePath = path.join(uploadDir, safeName); const bytes = Buffer.from(await file.arrayBuffer()); await fs.writeFile(packagePath, bytes, { mode: 0o600 }); const now = new Date().toISOString(); const initialState: UpgradeJobState = { id: jobId, mode, status: 'queued', step: 'queued', message: '升级包已上传,等待执行', progress: 0, packageName: file.name, packageHash: createHash('sha256').update(bytes).digest('hex'), startedAt: now, updatedAt: now, logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`], }; await writeState(jobDir, initialState); const child = spawn(process.execPath, [ path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'), '--job-id', jobId, '--mode', mode, '--package', packagePath, '--package-name', file.name, '--project', process.cwd(), ], { cwd: process.cwd(), detached: true, stdio: 'ignore', env: { ...process.env, UPGRADE_STATE_DIR: stateRoot, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', }, }); child.unref(); return NextResponse.json({ success: true, job: initialState }); } catch (error) { console.error('[admin/upgrade] failed to start upgrade:', error); return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 }); } } function getUpgradeStateRoot(): string { const configured = process.env.UPGRADE_STATE_DIR; if (configured) return path.resolve(configured); if (process.env.LOCAL_STORAGE_DIR) return path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade'); return path.join(process.cwd(), 'upgrade-state'); } async function readStates(): Promise { const jobsRoot = path.join(getUpgradeStateRoot(), 'jobs'); let jobNames: string[] = []; try { jobNames = await fs.readdir(jobsRoot); } catch { return []; } const states = await Promise.all( jobNames.map(async jobName => { try { const statePath = path.join(jobsRoot, jobName, 'state.json'); const raw = await fs.readFile(statePath, 'utf8'); return JSON.parse(raw) as UpgradeJobState; } catch { return null; } }), ); return states .filter((job): job is UpgradeJobState => Boolean(job)) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } async function writeState(jobDir: string, state: UpgradeJobState): Promise { await fs.mkdir(jobDir, { recursive: true, mode: 0o700 }); await fs.writeFile(path.join(jobDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); } function isAllowedArchiveName(name: string): boolean { return name.endsWith('.tar') || name.endsWith('.tar.gz') || name.endsWith('.tgz'); } function sanitizeFileName(name: string): string { const baseName = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_'); return baseName || 'upgrade-package.tar.gz'; }