From b38ad8ccee05b67db8dc8fc366215bf8f06dc6b7 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 14:53:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=85=8D=E7=BD=AE):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E7=94=A8=20zod=20=E4=B8=80=E6=AC=A1=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=EF=BC=8C=E6=9C=89=E9=94=99?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来是 `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 --- apps/api/src/config.ts | 141 ++++++++++++++++++++++++++-------- apps/api/src/db/index.ts | 7 +- apps/api/src/flowchart/run.ts | 5 +- 3 files changed, 113 insertions(+), 40 deletions(-) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 6bc9c52..a5142ae 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto" import { readFileSync } from "node:fs" import { isAbsolute, resolve } from "node:path" +import { z } from "zod" + import { isCompiled, pathBase } from "./runtime" /** @@ -38,11 +40,79 @@ function 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。 * @@ -52,9 +122,14 @@ function repoPath(value: string) { 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() { - const fromEnv = process.env.JUDGE_SERVER_TOKEN - if (fromEnv) return fromEnv + if (env.JUDGE_SERVER_TOKEN) return env.JUDGE_SERVER_TOKEN console.warn( "[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。" + "判题机将无法通过鉴权,本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN," + @@ -63,49 +138,49 @@ function judgeServerToken() { 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 = { - port: Number(process.env.PORT ?? 3000), - redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380", + port: env.PORT, + databaseUrl: env.DATABASE_URL, + redisUrl: env.REDIS_URL, sessionCookie: "oj2_session", - sessionTtlSeconds: Number( - process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60, - ), - secureCookies: process.env.COOKIE_SECURE === "true", - judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081", + sessionTtlSeconds: env.SESSION_TTL_SECONDS, + secureCookies: env.COOKIE_SECURE === "true", + judgeServerUrl: env.JUDGE_SERVER_URL, judgeServerToken: judgeServerToken(), - judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2), - avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"), + judgeConcurrency: env.JUDGE_CONCURRENCY, + avatarDirectory: repoPath(env.AVATAR_DIRECTORY), // 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处 - testCaseDirectory: repoPath( - process.env.TEST_CASE_DIRECTORY ?? "data/test_case", - ), - uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"), + testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY), + uploadDirectory: repoPath(env.UPLOAD_DIRECTORY), // 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。 // 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。 - hitokotoDirectory: repoPath( - process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto", - ), + hitokotoDirectory: repoPath(env.HITOKOTO_DIRECTORY), /** * WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。 * 同源本来就放行,只有前后端分处不同域名时才需要配。 */ - allowedWebSocketOrigins: (process.env.ALLOWED_WS_ORIGINS ?? "") - .split(",") + allowedWebSocketOrigins: env.ALLOWED_WS_ORIGINS.split(",") .map((value) => value.trim()) .filter(Boolean), - uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload", - avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar", - aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com", + uploadUriPrefix: env.UPLOAD_URI_PREFIX, + avatarUriPrefix: env.AVATAR_URI_PREFIX, + aiBaseUrl: env.AI_BASE_URL, /** 只用来写 ai_analysis.provider 这一列,换 provider 时和 AI_BASE_URL 一起改 */ - aiProvider: process.env.AI_PROVIDER ?? "deepseek", - aiKey: process.env.AI_KEY ?? "", - aiModel: process.env.AI_MODEL ?? "deepseek-flash", + aiProvider: env.AI_PROVIDER, + aiKey: env.AI_KEY, + aiModel: env.AI_MODEL, /** * AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**: * 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。 * 设成 "1" 打开。 */ - aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1", - ruffPath: process.env.RUFF_PATH ?? "ruff", - clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format", + aiHintDiagnose: env.AI_HINT_DIAGNOSE === "1", + ruffPath: env.RUFF_PATH, + clangFormatPath: env.CLANG_FORMAT_PATH, } diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 5dd0ba9..95735c1 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -1,15 +1,12 @@ import { drizzle } from "drizzle-orm/postgres-js" import postgres from "postgres" +import { config } from "../config" import * as schema from "./schema" -const url = - process.env.DATABASE_URL ?? - "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge" - // 不设会话时区:日历语义的 SQL 一律显式 `at time zone`(`../time` 的 localTime), // 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。 -const client = postgres(url) +const client = postgres(config.databaseUrl) export const db = drizzle(client, { schema }) diff --git a/apps/api/src/flowchart/run.ts b/apps/api/src/flowchart/run.ts index a97f2bf..555f7a0 100644 --- a/apps/api/src/flowchart/run.ts +++ b/apps/api/src/flowchart/run.ts @@ -1,6 +1,7 @@ import type { FlowchartUpdate } from "@oj2/contract" import { eq } from "drizzle-orm" +import { config } from "../config" import { db, schema } from "../db" import { publishFlowchartUpdate } from "../events" import { completeChat } from "../services/ai" @@ -85,8 +86,8 @@ export async function evaluateFlowchart( aiFeedback: result.feedback, aiSuggestions: result.suggestions, aiCriteriaDetails: result.criteria, - aiProvider: "deepseek", - aiModel: process.env.AI_MODEL ?? "deepseek-flash", + aiProvider: config.aiProvider, + aiModel: config.aiModel, processingTime: (performance.now() - started) / 1000, evaluationTime: new Date().toISOString(), })