#!/usr/bin/env node /** * 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。 * * 目标: * - 前后端与内部服务统一读取仓库根目录 `.env.all`(生产优先,全局唯一 env 文件) * * 可选环境变量: * - SKIP_FRONTEND_BUILD=1:跳过前端 build(仅 start) * - FRONTEND_PORT:默认 3000 * - BACKEND_PORT:默认 8000 * - RAG_GATEWAY_PORT:默认 8778 * - INGEST_PORT:默认 8779 * - PYTHON_BIN:默认 python * - CELERY_BIN:默认 celery * - ENABLE_CELERY=1:启用默认 Celery worker * - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery * - SKIP_CELERY=1:强制跳过 Celery worker * - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0 */ const { spawn } = require("child_process"); const fs = require("fs"); const net = require("net"); const path = require("path"); const { URL } = require("url"); const rootDir = path.resolve(__dirname, ".."); const frontendDir = path.join(rootDir, "wolai-frontend"); const backendDir = path.join(rootDir, "wolai-backend"); const ingestDir = path.join(rootDir, "services", "ingest_service"); const ragGatewayDir = path.join(rootDir, "services", "rag_gateway"); function resolveBackendExecutable(envName, fallbackName) { const fromEnv = (process.env[envName] || "").trim(); if (fromEnv) return fromEnv; const isWin = process.platform === "win32"; const candidates = isWin ? [ path.join(backendDir, ".venv", "Scripts", `${fallbackName}.exe`), path.join(backendDir, ".venv312", "Scripts", `${fallbackName}.exe`), path.join(backendDir, "venv", "Scripts", `${fallbackName}.exe`), ] : [ path.join(backendDir, ".venv-linux", "bin", fallbackName), path.join(backendDir, ".venv", "bin", fallbackName), path.join(backendDir, "venv", "bin", fallbackName), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return fallbackName; } const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python"); const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery"); const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim(); const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0"; const frontendPort = Number(process.env.FRONTEND_PORT || 3000); const backendPort = Number(process.env.BACKEND_PORT || 8000); const ragGatewayPort = Number(process.env.RAG_GATEWAY_PORT || 8778); const ingestPort = Number(process.env.INGEST_PORT || 8779); const children = []; let shuttingDown = false; function logPrefix(name, message) { console.log(`[${name}] ${message}`); } function isEnabledEnv(value) { const normalized = String(value || "").toLowerCase(); return normalized === "1" || normalized === "true"; } function shouldStartCelery(env = process.env) { if (isEnabledEnv(env.SKIP_CELERY)) return false; if (String(env.CELERY_CMD || "").trim()) return true; return isEnabledEnv(env.ENABLE_CELERY); } function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; const content = fs.readFileSync(filePath, "utf8"); return content .split(/\r?\n/) .filter((line) => line.trim() && !line.trim().startsWith("#")) .reduce((acc, line) => { const idx = line.indexOf("="); if (idx === -1) return acc; const key = line.slice(0, idx).trim(); const value = line.slice(idx + 1).trim(); acc[key] = value; return acc; }, {}); } async function waitForExit(child, name) { return await new Promise((resolve) => { child.on("exit", (code, signal) => { const status = signal ? `信号 ${signal}` : `退出码 ${code ?? "null"}`; logPrefix(name, `进程结束(${status})`); resolve(code ?? 0); }); child.on("error", () => resolve(1)); }); } function spawnTask({ name, command, cwd, env }) { logPrefix(name, `启动命令:${command}`); const child = spawn(command, { cwd, stdio: "inherit", shell: true, env, }); child.on("exit", (code, signal) => { if (shuttingDown) return; const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`; logPrefix(name, `进程结束(${status}),准备清理其它任务。`); shutdown(code ?? 0); }); child.on("error", (err) => { logPrefix(name, `启动失败:${err.message}`); shutdown(1); }); children.push(child); return child; } function shutdown(code) { if (shuttingDown) return; shuttingDown = true; logPrefix("system", "收到终止信号,正在关闭所有子进程…"); for (const child of children) { if (!child.killed) { child.kill("SIGINT"); setTimeout(() => { if (!child.killed) { child.kill("SIGTERM"); } }, 5000); } } setTimeout(() => process.exit(code), 200); } process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); async function checkPortOpen(host, port, timeoutMs = 1200) { return await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(true); }); socket.once("error", () => { clearTimeout(timer); resolve(false); }); }); } async function checkRedisReachable(urlString, timeoutMs = 2000) { try { const url = new URL(urlString); const host = url.hostname || "127.0.0.1"; const port = Number(url.port) || 6379; return await checkPortOpen(host, port, timeoutMs); } catch (error) { logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`); return true; } } function requireFile(filePath, hint) { if (!fs.existsSync(filePath)) { console.error(`[system] 缺少文件:${filePath}`); if (hint) console.error(`[system] 提示:${hint}`); process.exit(1); } } function ensureStandaloneStatic(frontendDir) { // 说明:当 next.config.ts 配置了 output: "standalone" 时,推荐使用 // `node .next/standalone/server.js` 启动。 // 但 Next 不会自动把 `.next/static` 放入 `.next/standalone/.next/static`, // 若缺失会导致 `/_next/static/*` 404(远程访问尤其明显)。 const standaloneServer = path.join(frontendDir, ".next", "standalone", "server.js"); if (!fs.existsSync(standaloneServer)) return false; const src = path.join(frontendDir, ".next", "static"); const dest = path.join(frontendDir, ".next", "standalone", ".next", "static"); if (!fs.existsSync(src)) return true; try { fs.mkdirSync(path.dirname(dest), { recursive: true }); // Node 16+:fs.cpSync fs.cpSync(src, dest, { recursive: true }); return true; } catch (err) { logPrefix( "frontend", `复制 .next/static 到 standalone 失败:${err instanceof Error ? err.message : String(err)}`, ); // 失败时不强制退出,仍尝试启动(方便你自行排查) return true; } } async function main() { // 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。 const envAllPath = path.join(rootDir, ".env.all"); requireFile(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)"); const envAll = loadEnvFile(envAllPath); // 生产模式:前端必须使用 production env,避免本地开发配置污染公网用户 const envFrontend = { ...process.env, ...envAll, NODE_ENV: "production", NEXT_TELEMETRY_DISABLED: "1", }; // 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器) const envBackend = { ...process.env, ...envAll, PYTHONUTF8: "1", }; const envInternalServices = { ...process.env, ...envAll, PYTHONUTF8: "1", }; const shouldSkipBuild = (process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "1" || (process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "true"; if (!shouldSkipBuild) { logPrefix("frontend", "开始 production build…"); const build = spawn("pnpm -C wolai-frontend build", { cwd: rootDir, stdio: "inherit", shell: true, env: envFrontend, }); const code = await waitForExit(build, "frontend"); if (code !== 0) { process.exit(code); } } else { logPrefix("frontend", "已跳过 build(SKIP_FRONTEND_BUILD=1)"); } const useStandalone = ensureStandaloneStatic(frontendDir); // 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程) const portChecks = [ { name: "frontend", port: frontendPort }, { name: "backend", port: backendPort }, { name: "rag_gateway", port: ragGatewayPort }, { name: "ingest_service", port: ingestPort }, ]; for (const item of portChecks) { // 仅检查本机 127.0.0.1 是否已有服务在监听,方便你快速判断“是否重复启动” // 监听着不代表冲突:可能是你已经手动启动了。 // eslint-disable-next-line no-await-in-loop const open = await checkPortOpen("127.0.0.1", item.port); if (open) { logPrefix("system", `检测到端口 ${item.port} 已有服务监听(${item.name}),若你准备全量启动,请确认不会端口冲突。`); } } // 启动前端(production) const envFrontendServer = { ...envFrontend, PORT: String(frontendPort), HOSTNAME: "0.0.0.0", }; spawnTask({ name: "frontend", // 说明:next.config.ts 配置了 output: "standalone" 时,应使用 standalone server 启动,避免 next start 行为不一致。 command: useStandalone ? "node .next/standalone/server.js" : `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`, cwd: useStandalone ? frontendDir : rootDir, env: envFrontendServer, }); // 启动 wolai-backend(FastAPI) spawnTask({ name: "backend", command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`, cwd: backendDir, env: envBackend, }); // Celery(可选) if (shouldStartCelery(process.env)) { const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl); if (ok) { spawnTask({ name: "celery", command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`, cwd: backendDir, env: envBackend, }); } else { logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`); } } else if (isEnabledEnv(process.env.SKIP_CELERY)) { logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)"); } else { logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。"); } // 启动 ingest_service(自动入库、OCR 触发、延迟删除清理) spawnTask({ name: "ingest", command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ingestPort}`, cwd: ingestDir, env: envInternalServices, }); // 启动 rag_gateway(/rag 查询网关) spawnTask({ name: "rag_gateway", command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ragGatewayPort}`, cwd: ragGatewayDir, env: envInternalServices, }); logPrefix( "system", `已启动:frontend:${frontendPort} backend:${backendPort} ingest:${ingestPort} rag_gateway:${ragGatewayPort}(如需公网访问,请确保 cloudflared tunnel 正在运行)`, ); } if (require.main === module) { main().catch((error) => { console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); } module.exports = { shouldStartCelery, };