0.1.14 上线前更改
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
|
||||
*
|
||||
* 目标:
|
||||
* - 前端使用 Next production(读取 wolai-frontend/.env.production.local)
|
||||
* - 后端与内部服务使用本机/内网地址(读取各自 .env 或仓库根目录 .env.local)
|
||||
*
|
||||
* 可选环境变量:
|
||||
* - 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
|
||||
* - 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");
|
||||
|
||||
const pythonBin = process.env.PYTHON_BIN || "python";
|
||||
const celeryBin = process.env.CELERY_BIN || "celery";
|
||||
const skipCelery =
|
||||
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
|
||||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const frontendProdEnv = path.join(frontendDir, ".env.production.local");
|
||||
|
||||
// 说明:Next.js 在 production 也会加载 wolai-frontend/.env.local。
|
||||
// 为避免被本地开发环境(可能指向远端 Supabase)的配置污染,这里把“服务端专用”的关键 env 显式注入。
|
||||
// 规则:优先使用 wolai-frontend/.env.production.local,其次使用仓库根目录 .env.local/.env。
|
||||
const envRoot = {
|
||||
...loadEnvFile(path.join(rootDir, ".env.local")),
|
||||
...loadEnvFile(path.join(rootDir, ".env")),
|
||||
};
|
||||
|
||||
requireFile(
|
||||
frontendProdEnv,
|
||||
"请先创建 wolai-frontend/.env.production.local(可参考 wolai-frontend/.env.production.example)",
|
||||
);
|
||||
|
||||
// 生产模式:前端必须使用 production env,避免把 .env.local 的 127.0.0.1 泄露到公网用户
|
||||
const envFrontend = {
|
||||
...process.env,
|
||||
...loadEnvFile(frontendProdEnv),
|
||||
NODE_ENV: "production",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
|
||||
// Next 服务端(API Route)需要 service role 才能生成签名 URL、清理资源等。
|
||||
if (envRoot.SUPABASE_SERVICE_ROLE_KEY) {
|
||||
envFrontend.SUPABASE_SERVICE_ROLE_KEY = envRoot.SUPABASE_SERVICE_ROLE_KEY;
|
||||
}
|
||||
|
||||
// 兼容旧代码:部分模块读取 SUPABASE_URL(而非 SUPABASE_INTERNAL_URL)。
|
||||
// 强制覆盖:避免从系统环境变量或 wolai-frontend/.env.local 继承到“远端 Supabase”。
|
||||
envFrontend.SUPABASE_URL = envFrontend.SUPABASE_INTERNAL_URL || envRoot.SUPABASE_URL || envFrontend.SUPABASE_URL || "";
|
||||
|
||||
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
|
||||
// wolai-backend 使用自身目录下 .env
|
||||
const envBackend = {
|
||||
...process.env,
|
||||
...loadEnvFile(path.join(backendDir, ".env")),
|
||||
PYTHONUTF8: "1",
|
||||
};
|
||||
// ingest_service / rag_gateway 主要依赖仓库根目录 .env.local/.env
|
||||
const envInternalServices = {
|
||||
...process.env,
|
||||
...loadEnvFile(path.join(rootDir, ".env.local")),
|
||||
...loadEnvFile(path.join(rootDir, ".env")),
|
||||
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 portChecks = [
|
||||
{ name: "frontend", port: frontendPort },
|
||||
{ name: "backend", port: backendPort },
|
||||
{ name: "rag_gateway", port: ragGatewayPort },
|
||||
{ name: "ingest_service", port: ingestPort },
|
||||
{ name: "supabase(kong)", port: 18000 },
|
||||
];
|
||||
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)
|
||||
spawnTask({
|
||||
name: "frontend",
|
||||
// Windows 下 pnpm 对 `start -- ...` 的参数转发偶发不稳定;用 `pnpm exec next start` 更可靠。
|
||||
command: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
|
||||
cwd: rootDir,
|
||||
env: envFrontend,
|
||||
});
|
||||
|
||||
// 启动 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 (!skipCelery) {
|
||||
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
|
||||
if (ok) {
|
||||
spawnTask({
|
||||
name: "celery",
|
||||
command: `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
|
||||
cwd: backendDir,
|
||||
env: envBackend,
|
||||
});
|
||||
} else {
|
||||
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
|
||||
}
|
||||
} else {
|
||||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)");
|
||||
}
|
||||
|
||||
// 启动 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 正在运行)`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user