feat(配置): 启动时用 zod 一次解析环境变量,有错拒绝启动

原来是 `Number(env ?? 默认值)` 散写:JUDGE_CONCURRENCY=two 得到 NaN 照样起;
DATABASE_URL 漏配就静默连上开发库的默认账号;AI_KEY 缺了要等第一次 AI 调用才报错。

- config.ts 一张 envSchema 覆盖全部变量,失败时列出所有问题后 exit 1
- 编译形态(线上)下 DATABASE_URL / REDIS_URL / JUDGE_SERVER_TOKEN 必须显式给,
  dev 默认值指向本机 5433 / 6380,放进容器里只会连不上或连错
- 空字符串按未设置处理(compose 的 ${X:-} 展开就是空串)
- AI_KEY 为空启动时告警(AI 是可选功能,不退出)
- db/index.ts 改读 config.databaseUrl;flowchart/run.ts 的 provider / model 改读 config

实跑核过:非法值被列出并拒绝启动;编译产物在仓库外无 env 时报三项必填,
给齐后 /health 正常;dev 的 serve / worker 照常起。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 14:53:51 +08:00
1 parent ca0dbbf0c5
commit b38ad8ccee
3 files changed
+113 -40

No files matched your search

+108 -33
View File
@@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto"
import { readFileSync } from "node:fs" import { readFileSync } from "node:fs"
import { isAbsolute, resolve } from "node:path" import { isAbsolute, resolve } from "node:path"
import { z } from "zod"
import { isCompiled, pathBase } from "./runtime" import { isCompiled, pathBase } from "./runtime"
/** /**
@@ -38,11 +40,79 @@ function loadRepoRootEnv() {
loadRepoRootEnv() loadRepoRootEnv()
/** /**
* 判题机 token。对齐旧后端 `options/options.py:93`: * 环境变量在**启动时**一次解析完,有错就列出全部问题退出,不带病起来。
* token = os.environ.get("JUDGE_SERVER_TOKEN"); return token if token else rand_str() *
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警), * 原来是 `Number(env ?? 默认值)` 散写:`JUDGE_CONCURRENCY=two` 得到 NaN,BullMQ 拿着
* 也不要在仓库里写死一个人人都知道的弱默认值。 * NaN 当并发数照样起;`DATABASE_URL` 漏配就静默连上开发库的默认账号。这类错都不在
* 启动时报,而是到第一个请求、第一条提交才以莫名其妙的形式冒出来。
*
* **编译形态(线上)下,库、Redis、判题机 token 必须显式给**:dev 的默认值指向本机
* 5433 / 6380,放到容器里要么连不上、要么连错,都不如当场报错。
* 空字符串一律按「没设」处理 —— compose 里 `${X:-}` 展开出来就是空串。
*/ */
const blankAsUnset = (value: unknown) => (value === "" ? undefined : value)
const text = (fallback: string) =>
z.preprocess(blankAsUnset, z.string().default(fallback))
const positiveInt = (fallback: number) =>
z.preprocess(
blankAsUnset,
z.coerce.number().int().positive().default(fallback),
)
const requiredWhenCompiled = (devDefault: string) =>
isCompiled
? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" }))
: text(devDefault)
const envSchema = z.object({
PORT: positiveInt(3000),
DATABASE_URL: requiredWhenCompiled(
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
),
REDIS_URL: requiredWhenCompiled("redis://localhost:6380"),
SESSION_TTL_SECONDS: positiveInt(7 * 24 * 60 * 60),
COOKIE_SECURE: z.preprocess(
blankAsUnset,
z.enum(["true", "false"]).optional(),
),
JUDGE_SERVER_URL: text("http://localhost:8081"),
// dev 不设时生成一次性随机值,见 judgeServerToken()
JUDGE_SERVER_TOKEN: isCompiled
? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" }))
: z.preprocess(blankAsUnset, z.string().optional()),
JUDGE_CONCURRENCY: positiveInt(2),
AVATAR_DIRECTORY: text("data/avatar"),
TEST_CASE_DIRECTORY: text("data/test_case"),
UPLOAD_DIRECTORY: text("data/upload"),
HITOKOTO_DIRECTORY: text("data/hitokoto"),
ALLOWED_WS_ORIGINS: text(""),
UPLOAD_URI_PREFIX: text("/public/upload"),
AVATAR_URI_PREFIX: text("/public/avatar"),
AI_BASE_URL: z.preprocess(
blankAsUnset,
z.url().default("https://api.deepseek.com"),
),
AI_PROVIDER: text("deepseek"),
AI_KEY: text(""),
AI_MODEL: text("deepseek-flash"),
AI_HINT_DIAGNOSE: z.preprocess(blankAsUnset, z.enum(["1", "0"]).optional()),
RUFF_PATH: text("ruff"),
CLANG_FORMAT_PATH: text("clang-format"),
})
function parseEnv() {
const parsed = envSchema.safeParse(process.env)
if (parsed.success) return parsed.data
console.error(
"[config] 环境变量有误,拒绝启动:\n" +
parsed.error.issues
.map((issue) => ` ${issue.path.join(".")}: ${issue.message}`)
.join("\n"),
)
process.exit(1)
}
const env = parseEnv()
/** /**
* 相对路径按 `pathBase` 解析(开发时是仓库根,编译后是 cwd),基准的取舍见 runtime.ts。 * 相对路径按 `pathBase` 解析(开发时是仓库根,编译后是 cwd),基准的取舍见 runtime.ts。
* *
@@ -52,9 +122,14 @@ function repoPath(value: string) {
return isAbsolute(value) ? value : resolve(pathBase, value) return isAbsolute(value) ? value : resolve(pathBase, value)
} }
/**
* 判题机 token。对齐旧后端 `options/options.py:93`:
* token = os.environ.get("JUDGE_SERVER_TOKEN"); return token if token else rand_str()
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
* 也不要在仓库里写死一个人人都知道的弱默认值。编译形态下缺了直接拒绝启动(见上面)。
*/
function judgeServerToken() { function judgeServerToken() {
const fromEnv = process.env.JUDGE_SERVER_TOKEN if (env.JUDGE_SERVER_TOKEN) return env.JUDGE_SERVER_TOKEN
if (fromEnv) return fromEnv
console.warn( console.warn(
"[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。" + "[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。" +
"判题机将无法通过鉴权,本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN," + "判题机将无法通过鉴权,本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN," +
@@ -63,49 +138,49 @@ function judgeServerToken() {
return randomBytes(32).toString("hex") return randomBytes(32).toString("hex")
} }
// AI 是可选功能(.env.example 写明留空即关),所以只告警不退出 —— 但要在启动时说,
// 别等到学生点了「AI 提示」才从一条 401 里看出来
if (!env.AI_KEY && process.argv[2] !== "sql-child") {
console.warn("[config] AI_KEY 未设置,AI 分析 / 提示 / 流程图评分都不可用。")
}
export const config = { export const config = {
port: Number(process.env.PORT ?? 3000), port: env.PORT,
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380", databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
sessionCookie: "oj2_session", sessionCookie: "oj2_session",
sessionTtlSeconds: Number( sessionTtlSeconds: env.SESSION_TTL_SECONDS,
process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60, secureCookies: env.COOKIE_SECURE === "true",
), judgeServerUrl: env.JUDGE_SERVER_URL,
secureCookies: process.env.COOKIE_SECURE === "true",
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
judgeServerToken: judgeServerToken(), judgeServerToken: judgeServerToken(),
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2), judgeConcurrency: env.JUDGE_CONCURRENCY,
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"), avatarDirectory: repoPath(env.AVATAR_DIRECTORY),
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处 // 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
testCaseDirectory: repoPath( testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY),
process.env.TEST_CASE_DIRECTORY ?? "data/test_case", uploadDirectory: repoPath(env.UPLOAD_DIRECTORY),
),
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。 // 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。 // 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
hitokotoDirectory: repoPath( hitokotoDirectory: repoPath(env.HITOKOTO_DIRECTORY),
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
),
/** /**
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。 * WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
* 同源本来就放行,只有前后端分处不同域名时才需要配。 * 同源本来就放行,只有前后端分处不同域名时才需要配。
*/ */
allowedWebSocketOrigins: (process.env.ALLOWED_WS_ORIGINS ?? "") allowedWebSocketOrigins: env.ALLOWED_WS_ORIGINS.split(",")
.split(",")
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean), .filter(Boolean),
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload", uploadUriPrefix: env.UPLOAD_URI_PREFIX,
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar", avatarUriPrefix: env.AVATAR_URI_PREFIX,
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com", aiBaseUrl: env.AI_BASE_URL,
/** 只用来写 ai_analysis.provider 这一列,换 provider 时和 AI_BASE_URL 一起改 */ /** 只用来写 ai_analysis.provider 这一列,换 provider 时和 AI_BASE_URL 一起改 */
aiProvider: process.env.AI_PROVIDER ?? "deepseek", aiProvider: env.AI_PROVIDER,
aiKey: process.env.AI_KEY ?? "", aiKey: env.AI_KEY,
aiModel: process.env.AI_MODEL ?? "deepseek-flash", aiModel: env.AI_MODEL,
/** /**
* AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**: * AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**:
* 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。 * 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。
* 设成 "1" 打开。 * 设成 "1" 打开。
*/ */
aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1", aiHintDiagnose: env.AI_HINT_DIAGNOSE === "1",
ruffPath: process.env.RUFF_PATH ?? "ruff", ruffPath: env.RUFF_PATH,
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format", clangFormatPath: env.CLANG_FORMAT_PATH,
} }
+2 -5
View File
@@ -1,15 +1,12 @@
import { drizzle } from "drizzle-orm/postgres-js" import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres" import postgres from "postgres"
import { config } from "../config"
import * as schema from "./schema" import * as schema from "./schema"
const url =
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone`(`../time` 的 localTime), // 不设会话时区:日历语义的 SQL 一律显式 `at time zone`(`../time` 的 localTime),
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。 // 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。
const client = postgres(url) const client = postgres(config.databaseUrl)
export const db = drizzle(client, { schema }) export const db = drizzle(client, { schema })
+3 -2
View File
@@ -1,6 +1,7 @@
import type { FlowchartUpdate } from "@oj2/contract" import type { FlowchartUpdate } from "@oj2/contract"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishFlowchartUpdate } from "../events" import { publishFlowchartUpdate } from "../events"
import { completeChat } from "../services/ai" import { completeChat } from "../services/ai"
@@ -85,8 +86,8 @@ export async function evaluateFlowchart(
aiFeedback: result.feedback, aiFeedback: result.feedback,
aiSuggestions: result.suggestions, aiSuggestions: result.suggestions,
aiCriteriaDetails: result.criteria, aiCriteriaDetails: result.criteria,
aiProvider: "deepseek", aiProvider: config.aiProvider,
aiModel: process.env.AI_MODEL ?? "deepseek-flash", aiModel: config.aiModel,
processingTime: (performance.now() - started) / 1000, processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(), evaluationTime: new Date().toISOString(),
}) })