فهرست منبع

fix(web): 读取运行期环境变量,修复容器内设置页为空

- next.config: 去掉 env: envVars(构建期内联,会把 .env bake 进 bundle 且忽略容器运行期 env),改为运行期把 .env 赋给 process.env(仅填补未设置键;容器内无 .env 则 no-op,注入 env 原样生效)
- settings GET 改读 process.env(容器内无 .env 文件,原先读文件导致设置页全空),敏感值脱敏;POST 写 .env 包 try/catch,容器内不可写时返回明确提示而非 500

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey 4 روز پیش
والد
کامیت
8564d3b3fe
2فایلهای تغییر یافته به همراه38 افزوده شده و 12 حذف شده
  1. 13 4
      apps/web/next.config.mjs
  2. 25 8
      apps/web/src/app/api/settings/route.ts

+ 13 - 4
apps/web/next.config.mjs

@@ -1,9 +1,18 @@
 import { existsSync, readFileSync } from "node:fs";
 import { resolve } from "node:path";
 
-// Load .env from monorepo root at build/start time
+// Expose the monorepo-root .env to server-side `process.env.*` reads.
+//
+// IMPORTANT: do NOT put these into `nextConfig.env`. The `env` config key is
+// inlined into the JS bundle at BUILD time, which (a) bakes build-time values
+// into the image and ignores the container's runtime environment
+// (docker-compose `environment:` / k8s envFrom), and (b) inlines secrets into
+// the bundle. Assigning to process.env here runs at config-load (build and
+// `next start`); in the container there is no .env (it isn't copied into the
+// runtime image — see .dockerignore), so this is a no-op there and the
+// container's injected env vars are used as-is. Real/container env always wins
+// — .env only fills keys that are still undefined.
 const envPath = resolve(import.meta.dirname, "../../.env");
-const envVars = {};
 if (existsSync(envPath)) {
   const content = readFileSync(envPath, "utf-8");
   for (const line of content.split("\n")) {
@@ -12,15 +21,15 @@ if (existsSync(envPath)) {
     const eq = trimmed.indexOf("=");
     if (eq < 0) continue;
     const key = trimmed.slice(0, eq).trim();
+    if (!key) continue;
     const val = trimmed.slice(eq + 1).trim();
-    envVars[key] = val;
+    if (process.env[key] === undefined) process.env[key] = val;
   }
 }
 
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   transpilePackages: ["@pipeline/shared", "@pipeline/core", "@pipeline/tts"],
-  env: envVars,
 };
 
 export default nextConfig;

+ 25 - 8
apps/web/src/app/api/settings/route.ts

@@ -58,15 +58,23 @@ function serializeEnv(vars: Record<string, string>, original: string): string {
   return updated.join("\n");
 }
 
+// Keys shown on the Settings page. GET reads these from the LIVE environment
+// (container-injected vars; or .env via next.config in dev) — NOT the .env
+// file, which does not exist in the container. Reading process.env is what
+// makes Settings reflect the actual runtime config instead of appearing empty.
+const SETTINGS_KEYS = [
+  "OPENAI_API_KEY", "OPENAI_BASE_URL",
+  "OPENAI_TTS_API_KEY", "OPENAI_TTS_BASE_URL", "OPENAI_TTS_MODEL",
+  "FISH_AUDIO_API_KEY", "MINIMAX_API_KEY", "MINIMAX_GROUP_ID", "ELEVENLABS_API_KEY",
+  "OSS_REGION", "OSS_BUCKET", "OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_PUBLIC_DOMAIN",
+  "FEISHU_WEBHOOK_URL", "FEISHU_WEBHOOK_SECRET",
+] as const;
+
 export async function GET() {
-  if (!existsSync(ENV_PATH)) {
-    return NextResponse.json({});
-  }
-  const content = readFileSync(ENV_PATH, "utf-8");
-  const parsed = parseEnv(content);
   const masked: Record<string, string> = {};
-  for (const [key, val] of Object.entries(parsed)) {
-    masked[key] = maskValue(key, val);
+  for (const key of SETTINGS_KEYS) {
+    const raw = process.env[key];
+    masked[key] = raw === undefined ? "" : maskValue(key, raw);
   }
   return NextResponse.json(masked);
 }
@@ -89,7 +97,16 @@ export async function POST(request: Request) {
   }
 
   const serialized = serializeEnv(filtered, original);
-  writeFileSync(ENV_PATH, serialized, "utf-8");
+  try {
+    writeFileSync(ENV_PATH, serialized, "utf-8");
+  } catch (e) {
+    // In a container the env is injected externally and .env is usually not
+    // writable (k8s runs as non-root) — surface that clearly instead of a 500.
+    return NextResponse.json(
+      { ok: false, error: `无法写入 .env(容器内环境变量通常由外部注入,不可在此修改): ${(e as Error).message}` },
+      { status: 403 },
+    );
+  }
 
   // Re-read to return masked values
   const reparsed = parseEnv(serialized);