Files
miaojingAI/src/app/api/admin/upgrade/route.ts
2026-05-09 08:07:24 +00:00

199 lines
6.3 KiB
TypeScript

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<UpgradeStatus>(['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<UpgradeJobState[]> {
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<void> {
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';
}