feat: add admin upgrade workflow
This commit is contained in:
198
src/app/api/admin/upgrade/route.ts
Normal file
198
src/app/api/admin/upgrade/route.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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.slice(0, 12),
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user