diff --git a/package.json b/package.json index 38a1f9b..64c29ac 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "backup:create": "bash ./scripts/backup-create.sh", "backup:list": "bash ./scripts/backup-list.sh", "backup:restore": "bash ./scripts/backup-restore.sh", + "upgrade:run": "node ./scripts/admin-upgrade-runner.mjs", "db:patch": "bash ./scripts/apply-database-patch.sh", "dev": "bash ./scripts/dev.sh", "preinstall": "npx only-allow pnpm", diff --git a/scripts/admin-upgrade-runner.mjs b/scripts/admin-upgrade-runner.mjs new file mode 100755 index 0000000..5f296e4 --- /dev/null +++ b/scripts/admin-upgrade-runner.mjs @@ -0,0 +1,490 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const args = parseArgs(process.argv.slice(2)); +const projectRoot = path.resolve(args.project || process.cwd()); +loadEnvFile(path.join(projectRoot, '.env.local')); + +const stateRoot = path.resolve( + process.env.UPGRADE_STATE_DIR || + (process.env.LOCAL_STORAGE_DIR ? path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade') : path.join(projectRoot, 'upgrade-state')), +); + +const jobId = requireArg(args, 'job-id'); +const mode = requireArg(args, 'mode'); +const packagePath = path.resolve(requireArg(args, 'package')); +const packageName = args['package-name'] || path.basename(packagePath); +const jobDir = path.join(stateRoot, 'jobs', jobId); +const stateFile = path.join(jobDir, 'state.json'); +const extractDir = path.join(jobDir, 'extract'); +const sourceBackupFile = path.join(jobDir, `source-before-${jobId}.tar.gz`); + +const HOT_ALLOWED_PREFIXES = ['public/']; +const HOT_ALLOWED_FILES = new Set([ + 'manifest.json', + 'robots.txt', + 'sitemap.xml', + 'favicon.ico', + 'icon.png', + 'apple-icon.png', +]); + +const COLD_ALLOWED_PREFIXES = ['src/', 'public/', 'scripts/', 'database/', 'docs/']; +const COLD_ALLOWED_FILES = new Set([ + 'manifest.json', + 'package.json', + 'pnpm-lock.yaml', + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'tsconfig.json', + 'postcss.config.mjs', + 'components.json', + 'ecosystem.config.cjs', +]); + +const BLOCKED_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']); + +let state = readState() || { + id: jobId, + mode, + status: 'queued', + step: 'queued', + message: '升级任务已创建', + progress: 0, + packageName, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + logs: [], +}; + +main().catch(error => { + log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`); + rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => { + updateState({ + status: 'rollback_failed', + step: 'rollback_failed', + progress: 100, + message: '升级失败,自动回滚也失败,请立即人工检查', + error: `${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + finishedAt: new Date().toISOString(), + }); + }); +}); + +async function main() { + ensureDir(jobDir); + updateState({ + status: 'running', + step: 'preflight', + progress: 5, + message: '正在检查升级包与运行环境', + startedAt: state.startedAt || new Date().toISOString(), + }); + + if (mode !== 'hot' && mode !== 'cold') { + throw new Error('升级方式无效'); + } + if (!fs.existsSync(packagePath)) { + throw new Error(`升级包不存在: ${packagePath}`); + } + if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) { + throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包'); + } + + run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' }); + + resetDir(extractDir); + run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' }); + + const payloadRoot = resolvePayloadRoot(extractDir); + const files = listFiles(payloadRoot); + if (files.length === 0) { + throw new Error('升级包为空'); + } + + const validation = validateFiles(files, mode); + updateState({ + step: 'validated', + progress: 14, + message: `升级包校验通过,共 ${files.length} 个文件`, + restartRequired: mode === 'cold' || validation.requiresRestart, + packageHash: sha256(packagePath), + changedFiles: files, + }); + + updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' }); + const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], { + cwd: projectRoot, + label: '创建数据备份', + env: { BACKUP_DIR: path.join(stateRoot, 'data-backups'), COZE_WORKSPACE_PATH: projectRoot }, + }).trim().split('\n').pop(); + if (!backupFile || !fs.existsSync(backupFile)) { + throw new Error('数据备份创建失败'); + } + updateState({ backupFile }); + + updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' }); + createSourceBackup(sourceBackupFile); + updateState({ sourceBackupFile }); + + updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' }); + updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) }); + applyFiles(payloadRoot, files); + + if (mode === 'hot') { + updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' }); + run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' }); + updateState({ + status: 'succeeded', + step: 'completed', + progress: 100, + message: '热更新成功,平台未重启', + finishedAt: new Date().toISOString(), + restartRequired: false, + }); + return; + } + + const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml'); + if (dependencyChanged) { + updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' }); + run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' }); + } + + updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' }); + run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' }); + + updateState({ step: 'build', progress: 75, message: '正在构建平台' }); + run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' }); + + updateState({ step: 'restart', progress: 86, message: '正在重启平台进程' }); + restartPlatform(); + + updateState({ step: 'health_check', progress: 94, message: '正在检查平台健康状态' }); + waitForHealth(); + + updateState({ + status: 'succeeded', + step: 'completed', + progress: 100, + message: '冷更新成功,平台已重启并通过健康检查', + finishedAt: new Date().toISOString(), + restartRequired: true, + }); +} + +async function rollbackAfterFailure(message) { + const originalError = message; + updateState({ + status: 'rolling_back', + step: 'rolling_back', + progress: 96, + message: '升级失败,正在自动回滚到升级前状态', + error: originalError, + }); + + if (fs.existsSync(sourceBackupFile)) { + restoreSourceBackup(sourceBackupFile); + } + + if (state.backupFile && fs.existsSync(state.backupFile)) { + run('bash', ['./scripts/backup-restore.sh', state.backupFile], { + cwd: projectRoot, + label: '恢复数据备份', + env: { COZE_WORKSPACE_PATH: projectRoot }, + }); + } + + if (mode === 'cold') { + try { + run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' }); + restartPlatform(); + waitForHealth(); + } catch (error) { + throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`); + } + } + + updateState({ + status: 'rolled_back', + step: 'rolled_back', + progress: 100, + message: '升级失败,已自动回滚到升级开始前状态', + error: originalError, + finishedAt: new Date().toISOString(), + }); +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith('--')) continue; + const key = item.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith('--')) { + parsed[key] = 'true'; + } else { + parsed[key] = next; + index += 1; + } + } + return parsed; +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) throw new Error(`missing --${key}`); + return value; +} + +function loadEnvFile(file) { + if (!fs.existsSync(file)) return; + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue; + const index = trimmed.indexOf('='); + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!process.env[key]) process.env[key] = value; + } +} + +function readState() { + try { + return JSON.parse(fs.readFileSync(stateFile, 'utf8')); + } catch { + return null; + } +} + +function updateState(patch) { + state = { + ...state, + ...patch, + updatedAt: new Date().toISOString(), + logs: state.logs || [], + }; + ensureDir(path.dirname(stateFile)); + const tempFile = `${stateFile}.tmp`; + fs.writeFileSync(tempFile, `${JSON.stringify(state, null, 2)}\n`); + fs.renameSync(tempFile, stateFile); +} + +function log(line) { + const timestamped = `[${new Date().toISOString()}] ${line}`; + const logs = [...(state.logs || []), timestamped].slice(-300); + updateState({ logs }); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); +} + +function resetDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }); + ensureDir(dir); +} + +function run(command, commandArgs, options = {}) { + runCapture(command, commandArgs, options); +} + +function runCapture(command, commandArgs, options = {}) { + const label = options.label || command; + log(`${label}: ${command} ${commandArgs.join(' ')}`); + const result = spawnSync(command, commandArgs, { + cwd: options.cwd || projectRoot, + env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) }, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }); + const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); + if (output) { + for (const line of output.split(/\r?\n/).slice(-80)) log(`${label}: ${line}`); + } + if (result.status !== 0) { + throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`); + } + return result.stdout || ''; +} + +function isAllowedArchive(file) { + return file.endsWith('.tar') || file.endsWith('.tar.gz') || file.endsWith('.tgz'); +} + +function resolvePayloadRoot(root) { + const entries = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.name !== '__MACOSX'); + if (entries.length === 1 && entries[0].isDirectory()) { + return path.join(root, entries[0].name); + } + return root; +} + +function listFiles(root) { + const files = []; + walk(root, ''); + return files.sort(); + + function walk(currentRoot, relativeRoot) { + for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) { + if (entry.name === '.DS_Store') continue; + const relative = toPosix(path.join(relativeRoot, entry.name)); + const absolute = path.join(currentRoot, entry.name); + if (entry.isDirectory()) { + walk(absolute, relative); + } else if (entry.isFile()) { + files.push(relative); + } else { + throw new Error(`升级包包含不支持的文件类型: ${relative}`); + } + } + } +} + +function validateFiles(files, updateMode) { + for (const file of files) { + assertSafeRelativePath(file); + const parts = file.split('/'); + if (parts.some(part => BLOCKED_NAMES.has(part) || part.startsWith('.env'))) { + throw new Error(`升级包包含禁止覆盖的路径: ${file}`); + } + if (updateMode === 'hot' && !isHotAllowed(file)) { + throw new Error(`热更新只能包含 public 等无需重启的静态资源;${file} 需要使用冷更新`); + } + if (updateMode === 'cold' && !isColdAllowed(file)) { + throw new Error(`冷更新包包含未授权路径: ${file}`); + } + } + return { requiresRestart: files.some(file => !isHotAllowed(file)) }; +} + +function assertSafeRelativePath(file) { + if (!file || file.startsWith('/') || file.startsWith('\\') || file.includes('\\')) { + throw new Error(`升级包包含非法路径: ${file}`); + } + const normalized = path.posix.normalize(file); + if (normalized !== file || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) { + throw new Error(`升级包包含目录穿越路径: ${file}`); + } +} + +function isHotAllowed(file) { + return HOT_ALLOWED_FILES.has(file) || HOT_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix)); +} + +function isColdAllowed(file) { + return COLD_ALLOWED_FILES.has(file) || COLD_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix)); +} + +function applyFiles(root, files) { + for (const file of files) { + if (file === 'manifest.json') continue; + const source = path.join(root, file); + const target = path.join(projectRoot, file); + ensureDir(path.dirname(target)); + fs.copyFileSync(source, target); + } +} + +function createSourceBackup(target) { + ensureDir(path.dirname(target)); + run('tar', [ + '-czf', + target, + '--exclude=.git', + '--exclude=node_modules', + '--exclude=.next', + '--exclude=dist', + '--exclude=backups', + '--exclude=local-storage', + '--exclude=upgrade-state', + '--exclude=tsconfig.tsbuildinfo', + '-C', + projectRoot, + '.', + ], { cwd: projectRoot, label: '创建源码快照' }); +} + +function restoreSourceBackup(source) { + log(`恢复源码快照: ${source}`); + const preExistingFiles = new Set(Array.isArray(state.preExistingFiles) ? state.preExistingFiles : []); + const changedFiles = Array.isArray(state.changedFiles) ? state.changedFiles : []; + for (const file of changedFiles) { + if (file === 'manifest.json' || preExistingFiles.has(file)) continue; + const target = path.join(projectRoot, file); + if (target.startsWith(projectRoot)) { + fs.rmSync(target, { force: true }); + } + } + run('tar', [ + '-xzf', + source, + '--exclude=.git', + '--exclude=node_modules', + '--exclude=.next', + '--exclude=dist', + '-C', + projectRoot, + ], { cwd: projectRoot, label: '恢复源码快照' }); +} + +function restartPlatform() { + const restartCommand = process.env.UPGRADE_RESTART_COMMAND || detectRestartCommand(); + run('bash', ['-lc', restartCommand], { cwd: projectRoot, label: '重启平台' }); +} + +function detectRestartCommand() { + const pm2Names = runCapture('bash', ['-lc', 'command -v pm2 >/dev/null 2>&1 && pm2 jlist || true'], { + cwd: projectRoot, + label: '检测 PM2 进程', + }); + if (pm2Names.includes('"name":"miaojing-dev"')) return 'pm2 restart miaojing-dev --update-env'; + if (fs.existsSync(path.join(projectRoot, 'ecosystem.config.cjs'))) return 'pm2 startOrReload ecosystem.config.cjs --update-env'; + return 'pm2 restart miaojing-dev --update-env'; +} + +function tarReadArgs(action, archivePath) { + const flag = action === 'list' ? '-tf' : '-xf'; + const gzipFlag = action === 'list' ? '-tzf' : '-xzf'; + return archivePath.endsWith('.tar') ? [flag, archivePath] : [gzipFlag, archivePath]; +} + +function waitForHealth() { + const healthUrl = process.env.UPGRADE_HEALTH_URL || process.env.APP_HEALTH_URL || 'http://127.0.0.1:5100/api/health'; + const timeoutMs = Number(process.env.UPGRADE_HEALTH_TIMEOUT_MS || 90000); + const startedAt = Date.now(); + let lastError = ''; + while (Date.now() - startedAt < timeoutMs) { + const result = spawnSync('curl', ['-fsS', healthUrl], { encoding: 'utf8', timeout: 8000 }); + if (result.status === 0) { + log(`健康检查通过: ${healthUrl}`); + return; + } + lastError = `${result.stderr || result.stdout || `exit ${result.status}`}`.trim(); + sleep(3000); + } + throw new Error(`健康检查超时: ${healthUrl}; ${lastError}`); +} + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function sha256(file) { + const hash = createHash('sha256'); + hash.update(fs.readFileSync(file)); + return hash.digest('hex'); +} + +function toPosix(file) { + return file.split(path.sep).join('/'); +} diff --git a/src/app/api/admin/upgrade/route.ts b/src/app/api/admin/upgrade/route.ts new file mode 100644 index 0000000..76a3f10 --- /dev/null +++ b/src/app/api/admin/upgrade/route.ts @@ -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(['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 { + 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'; +} diff --git a/src/components/admin/system-upgrade-tab.tsx b/src/components/admin/system-upgrade-tab.tsx new file mode 100644 index 0000000..683bf10 --- /dev/null +++ b/src/components/admin/system-upgrade-tab.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + FileArchive, + Flame, + History, + Loader2, + RefreshCw, + RotateCcw, + ServerCog, + ShieldCheck, + UploadCloud, + XCircle, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; + +type UpgradeMode = 'hot' | 'cold'; +type UpgradeStatus = + | 'queued' + | 'running' + | 'rolling_back' + | 'succeeded' + | 'failed' + | 'rolled_back' + | 'rollback_failed'; + +type UpgradeJob = { + 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[]; +}; + +type UpgradeResponse = { + latest: UpgradeJob | null; + history: UpgradeJob[]; + running: boolean; + stateDir: string; +}; + +const RUNNING_STATUSES = new Set(['queued', 'running', 'rolling_back']); +const FINAL_STATUSES = new Set(['succeeded', 'failed', 'rolled_back', 'rollback_failed']); + +function getAdminAuthHeaders(): HeadersInit { + try { + const raw = window.localStorage.getItem('miaojing_auth'); + if (!raw) return {}; + const auth = JSON.parse(raw) as { accessToken?: string; session?: { access_token?: string } }; + const token = auth.accessToken || auth.session?.access_token; + return token ? { Authorization: `Bearer ${token}` } : {}; + } catch { + return {}; + } +} + +export default function SystemUpgradeTab() { + const [mode, setMode] = useState('hot'); + const [selectedFile, setSelectedFile] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + const [upgradeData, setUpgradeData] = useState({ latest: null, history: [], running: false, stateDir: '' }); + const fileInputRef = useRef(null); + + const latest = upgradeData.latest; + const latestIsRunning = latest ? RUNNING_STATUSES.has(latest.status) : false; + + const loadStatus = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => { + if (!silent) setLoading(true); + try { + const res = await fetch('/api/admin/upgrade', { + headers: getAdminAuthHeaders(), + cache: 'no-store', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '读取升级状态失败'); + setUpgradeData({ + latest: data.latest || null, + history: Array.isArray(data.history) ? data.history : [], + running: data.running === true, + stateDir: data.stateDir || '', + }); + } catch (error) { + if (!silent) toast.error(error instanceof Error ? error.message : '读取升级状态失败'); + } finally { + if (!silent) setLoading(false); + } + }, []); + + useEffect(() => { + loadStatus(); + }, [loadStatus]); + + useEffect(() => { + if (!latestIsRunning) return; + const timer = window.setInterval(() => loadStatus({ silent: true }), 2500); + return () => window.clearInterval(timer); + }, [latestIsRunning, loadStatus]); + + useEffect(() => { + if (!latest || !FINAL_STATUSES.has(latest.status)) return; + const timer = window.setTimeout(() => loadStatus({ silent: true }), 1200); + return () => window.clearTimeout(timer); + }, [latest?.id, latest?.status, latest, loadStatus]); + + const canSubmit = useMemo(() => Boolean(selectedFile) && !submitting && !upgradeData.running, [selectedFile, submitting, upgradeData.running]); + + async function handleSubmit() { + if (!selectedFile) { + toast.error('请选择升级包'); + return; + } + if (!/\.(tar|tgz|tar\.gz)$/i.test(selectedFile.name)) { + toast.error('仅支持 .tar、.tar.gz、.tgz 升级包'); + return; + } + + setSubmitting(true); + try { + const form = new FormData(); + form.set('mode', mode); + form.set('package', selectedFile); + + const res = await fetch('/api/admin/upgrade', { + method: 'POST', + headers: getAdminAuthHeaders(), + body: form, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || '创建升级任务失败'); + + toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动'); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + await loadStatus({ silent: true }); + } catch (error) { + toast.error(error instanceof Error ? error.message : '创建升级任务失败'); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + 升级保护策略 + + 每次升级都会先创建数据库、存储、环境配置备份和源码快照;任务失败会自动回滚到升级开始前的源码与数据状态。 + + + +
+ + + + + 上传升级包 + + 支持 tar、tar.gz、tgz 格式;热更新不重启平台,冷更新会构建并重启平台进程。 + + +
+ } + title="热更新" + description="只允许 public 静态资源等不影响运行时代码的补丁,应用后不重启。" + onClick={() => setMode('hot')} + /> + } + title="冷更新" + description="适合代码、依赖、脚本等较大变更,会校验、构建、重启并健康检查。" + onClick={() => setMode('cold')} + /> +
+ +
+ + setSelectedFile(event.target.files?.[0] || null)} + /> +

+ 热更新包如包含 src、package.json、脚本或锁文件会被拒绝,请改用冷更新。 +

+
+ + {selectedFile && ( +
+
+ + {selectedFile.name} +
+ {formatBytes(selectedFile.size)} +
+ )} + +
+
+ + 升级前确认 +
+
    +
  • 升级包不能包含 .env、node_modules、.git、backups、local-storage 等敏感目录。
  • +
  • 失败回滚会恢复源码快照、数据库备份、存储目录和环境配置。
  • +
  • 冷更新完成后会自动重启平台进程,并以 /api/health 作为成功判定。
  • +
+
+ + +
+
+ + + + + + + 当前状态 + + + + 冷更新重启后也会从磁盘续上这里的任务状态。 + + + {loading ? ( +
+ + 正在加载升级状态 +
+ ) : latest ? ( + + ) : ( +
+ +
暂无升级记录
+
上传升级包后会在这里显示进度和回滚结果
+
+ )} +
+
+
+ + + + + + 升级历史 + + 显示最近 12 次升级任务,便于核对成功、失败与回滚记录。 + + + {upgradeData.history.length === 0 ? ( +
暂无历史记录
+ ) : ( +
+ {upgradeData.history.map(job => ( +
+
+ + {job.mode === 'hot' ? '热更新' : '冷更新'} +
+
+
{job.packageName}
+
{job.message}
+
+
{formatDate(job.updatedAt)}
+
+ ))} +
+ )} +
+
+
+ ); +} + +function ModeCard({ + active, + icon, + title, + description, + onClick, +}: { + active: boolean; + icon: React.ReactNode; + title: string; + description: string; + onClick: () => void; +}) { + return ( + + ); +} + +function UpgradeStatusPanel({ job }: { job: UpgradeJob }) { + return ( +
+
+
+ + {job.mode === 'hot' ? '热更新' : '冷更新'} + {statusLabel(job.status)} +
+
{formatDate(job.updatedAt)}
+
+ +
+
+ {job.message} + {Math.max(0, Math.min(100, job.progress || 0))}% +
+ +
+ +
+ + + + {job.backupFile && } + {job.sourceBackupFile && } +
+ + {job.error && ( + + + {job.status === 'rolled_back' ? '已自动回滚' : '升级错误'} + {job.error} + + )} + + {job.logs?.length > 0 && ( + <> + +
+
执行日志
+
+              {job.logs.slice(-120).join('\n')}
+            
+
+ + )} +
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function StatusIcon({ status }: { status: UpgradeStatus }) { + if (status === 'succeeded') return ; + if (status === 'rolled_back') return ; + if (status === 'failed' || status === 'rollback_failed') return ; + return ; +} + +function statusLabel(status: UpgradeStatus): string { + const labels: Record = { + queued: '排队中', + running: '执行中', + rolling_back: '回滚中', + succeeded: '成功', + failed: '失败', + rolled_back: '已回滚', + rollback_failed: '回滚失败', + }; + return labels[status] || status; +} + +function statusBadgeClass(status: UpgradeStatus): string { + if (status === 'succeeded') return 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/15'; + if (status === 'rolled_back') return 'bg-amber-500/15 text-amber-600 hover:bg-amber-500/15'; + if (status === 'failed' || status === 'rollback_failed') return 'bg-destructive/15 text-destructive hover:bg-destructive/15'; + return 'bg-primary/15 text-primary hover:bg-primary/15'; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { hour12: false }); +} diff --git a/src/modules/console/pages/console-dashboard-page.tsx b/src/modules/console/pages/console-dashboard-page.tsx index bc9390a..8f2c19f 100644 --- a/src/modules/console/pages/console-dashboard-page.tsx +++ b/src/modules/console/pages/console-dashboard-page.tsx @@ -20,6 +20,7 @@ import { Logs, Loader2, Menu, + Package, PlugZap, Receipt, RefreshCw, @@ -45,6 +46,7 @@ const OrderManagementTab = dynamic(() => import('@/components/admin/order-manage const PaymentTab = dynamic(() => import('@/components/admin/payment-tab'), { ssr: false }); const AnnouncementTab = dynamic(() => import('@/components/admin/announcement-tab'), { ssr: false }); const DataManagementTab = dynamic(() => import('@/components/admin/data-management-tab'), { ssr: false }); +const SystemUpgradeTab = dynamic(() => import('@/components/admin/system-upgrade-tab'), { ssr: false }); const TaskManagementTab = dynamic(() => import('@/components/admin/task-management-tab'), { ssr: false }); const LogManagementTab = dynamic(() => import('@/components/admin/log-management-tab'), { ssr: false }); const SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false }); @@ -58,6 +60,7 @@ type ConsoleView = | 'payment' | 'announcements' | 'data' + | 'upgrade' | 'tasks' | 'logs' | 'settings'; @@ -221,6 +224,7 @@ const VIEW_TITLES: Record = payment: { title: '支付配置', description: '配置可用支付方式' }, announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' }, data: { title: '数据管理', description: '导出、导入与恢复业务数据' }, + upgrade: { title: '系统升级', description: '上传升级包,执行热更新、冷更新与失败自动回滚' }, tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' }, logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' }, settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' }, @@ -344,6 +348,7 @@ export default function ConsoleDashboardPage() { label: '系统', items: [ { value: 'data', label: '数据管理', icon: Database }, + { value: 'upgrade', label: '系统升级', icon: Package }, { value: 'logs', label: '系统日志', icon: Logs }, { value: 'settings', label: '系统设置', icon: Settings }, ], @@ -572,6 +577,8 @@ function ConsoleContent({ return ; case 'data': return ; + case 'upgrade': + return ; case 'tasks': return ; case 'logs':