From 604857b25e5cfb1ca5c1e1229fa3b6d3b88a6350 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 16 Aug 2026 10:20:00 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E4=B8=80=E8=A8=80):=20=E6=8E=A5=E5=9B=9E?= =?UTF-8?q?=E7=9C=9F=E6=95=B0=E6=8D=AE=E9=9B=86=EF=BC=8C=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E7=BA=BF=E4=B8=8A=E8=BF=94=E5=9B=9E=E7=9A=84=E6=98=AF=E4=B8=89?= =?UTF-8?q?=E6=9D=A1=E7=A1=AC=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /quotes/random 在阶段 3 基线里只铺了个桩(三条写死的句子),读盘逻辑没移植, 所以 oj2.xuyue.cc 上一直是假数据——和 DATA_DIR 无关,哪儿部署都一样。 数据本来就在服务器上、位置也已经对上:旧栈挂 ./data/backend:/data,Django 的 HITOKOTO_DIR 就是它下面的 hitokoto/;OJ2 的 api 挂的是同一个目录。所以 Dockerfile 里跟着 TEST_CASE_DIRECTORY 写死 /data/hitokoto 即可,不用搬文件。 按分类懒加载并常驻,但只留前端用得上的 hitokoto/from 两个字段:原样缓存 7160 条要 2.5MB,裁完 1.4MB,而 api 容器只有 512m。读不到数据集时回落到 内置三条,不影响启动(本机 dev 默认就走这条)。 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 16 ++++++++++-- apps/api/src/config.ts | 3 +++ apps/api/src/routes/site.ts | 50 ++++++++++++++++++++++++++++++++++--- docker/Dockerfile | 1 + 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75d4196..9654982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,20 @@ # CLAUDE.md OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。 -上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**两者都已冻结, -是回滚路径,任何情况下都不要改。** +上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**两者都是回滚路径, +默认冻结。** + +冻结的目的是「回滚那天旧站能原样起来、和新站看到同一份数据」,不是「一个字节都不许动」。 +所以红线是**外部可观测的东西**: + +- **不许动**:接口路径与响应结构、数据库 schema、磁盘上的数据布局、密码哈希格式、 + 依赖版本。这些一动,回滚就不再是「把上游切回去」那么简单。 +- **可以动**:纯内部实现的小修(缓存放哪、日志、注释),前提是签名、异常、 + 返回结构逐一对齐不变,且 `uv run ruff check` 通过。改完在 commit message 里 + 写清楚为什么值得破例。 + +已发生的破例:`utils/cache.py` 的 `JsonDataLoader` 把一言数据集塞 Redis,每次请求 +都要把 323KB 的 pickle 拉过网络再反序列化,改成了进程内缓存。响应结构没动。 设计文档:`docs/specs/2026-08-06-bun-backend-rewrite-design.md` 切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走 diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 7130ae4..7898b2a 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -83,6 +83,9 @@ export const config = { // 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处 testCaseDirectory: repoPath(process.env.TEST_CASE_DIRECTORY ?? "data/test_case"), uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"), + // 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。 + // 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。 + hitokotoDirectory: repoPath(process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto"), 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", diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index bdafa54..1c0040f 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -1,7 +1,9 @@ import { quoteSchema, websiteConfigSchema } from "@oj2/contract" import { asc, desc, eq } from "drizzle-orm" import { Hono } from "hono" +import { resolve } from "node:path" +import { config } from "../config" import { db, schema } from "../db" import { failure, success } from "../http" import { getWebsiteOptions } from "../services/options" @@ -23,15 +25,55 @@ siteRoutes.get("/site", async (c) => { })) }) -const quotes = [ +// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里) +const fallbackQuotes = [ { hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" }, { hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" }, { hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" }, ] -siteRoutes.get("/quotes/random", (c) => { - const item = quotes[Math.floor(Math.random() * quotes.length)] ?? quotes[0] - return success(c, quoteSchema.parse(item)) +// categories.json 里的 path 形如 "./sentences/a.json",12 个分类合计 20MB。 +// 按分类懒加载并常驻缓存,但**只留前端用得上的两个字段** —— 原样缓存的话, +// 解析后的对象要占 60~100MB,而 api 容器只有 512m。裁完全量也就几 MB。 +let categoryPaths: string[] | null = null +const sentenceCache = new Map() + +interface Quote { + hitokoto: string + from: string +} + +async function loadSentences(path: string) { + const cached = sentenceCache.get(path) + if (cached) return cached + const raw = await Bun.file(resolve(config.hitokotoDirectory, path)).json() as { hitokoto?: unknown, from?: unknown }[] + const rows = (Array.isArray(raw) ? raw : []) + .filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0) + .map((it) => ({ hitokoto: it.hitokoto as string, from: typeof it.from === "string" ? it.from : "佚名" })) + if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`) + sentenceCache.set(path, rows) + return rows +} + +async function randomQuote() { + if (!categoryPaths) { + const categories = await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json() as { path?: string }[] + const paths = categories.map((it) => it.path).filter((it): it is string => typeof it === "string") + if (paths.length === 0) throw new Error("no hitokoto categories") + categoryPaths = paths + } + const path = categoryPaths[Math.floor(Math.random() * categoryPaths.length)]! + const sentences = await loadSentences(path) + return sentences[Math.floor(Math.random() * sentences.length)]! +} + +siteRoutes.get("/quotes/random", async (c) => { + try { + return success(c, quoteSchema.parse(await randomQuote())) + } catch { + const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]! + return success(c, quoteSchema.parse(item)) + } }) siteRoutes.get("/classes/:className/usernames", async (c) => { diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b27344..52c58e1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -91,6 +91,7 @@ COPY --from=builder /build/oj2-api /usr/local/bin/oj2-api # 少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。 WORKDIR /data ENV TEST_CASE_DIRECTORY=/data/test_case \ + HITOKOTO_DIRECTORY=/data/hitokoto \ UPLOAD_DIRECTORY=/data/public/upload \ AVATAR_DIRECTORY=/data/public/avatar \ PORT=3000