diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bacf626..920c9dc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -49,25 +49,55 @@ app.onError((error, c) => { ) }) +/** 头像取不到时的占位图,避免每个没设头像的学生都打一次 404 */ +const DEFAULT_AVATAR_SVG = + '' + +/** + * 伺服 /public 下的用户上传文件。 + * + * 只取路径最后一段并要求它和原样一致 —— `..`、子目录、编码过的斜杠都会在这里被拒, + * 拼接进 resolve() 的永远只是一个纯文件名。 + * + * 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘: + * 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。 + */ +async function serveUpload(pathname: string, prefix: string, directory: string) { + const decoded = decodeURIComponent(pathname) + const filename = basename(decoded) + if (!filename || filename !== decoded.slice(prefix.length + 1)) { + return new Response("Not found", { status: 404 }) + } + const file = Bun.file(resolve(directory, filename)) + if (await file.exists()) { + // 文件名由后端生成且内容不变,可以放心长缓存 + return new Response(file, { headers: { "cache-control": "public, max-age=86400" } }) + } + return null +} + const server = Bun.serve({ port: config.port, async fetch(request, bunServer) { const url = new URL(request.url) if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) { - const filename = basename(decodeURIComponent(url.pathname)) - if (filename !== decodeURIComponent(url.pathname).split("/").at(-1)) { - return new Response("Not found", { status: 404 }) - } - const file = Bun.file(resolve(config.avatarDirectory, filename)) - if (await file.exists()) return new Response(file) - if (filename === "default.png") { - return new Response( - '', - { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } }, - ) + const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory) + if (hit) return hit + if (basename(decodeURIComponent(url.pathname)) === "default.png") { + return new Response(DEFAULT_AVATAR_SVG, { + headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" }, + }) } return new Response("Not found", { status: 404 }) } + // 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx, + // 但没有任何路由伺服它,题面图片一律 404。 + if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) { + return ( + (await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ?? + new Response("Not found", { status: 404 }) + ) + } if (url.pathname === "/ws/submissions" || url.pathname === "/ws/config") { const user = await getRequestSessionUser(request) if (!user) return new Response("Unauthorized", { status: 401 }) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 7274c55..5c3cd78 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -258,7 +258,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => { criteriaAverages, personCount: roster.length, completedCount: submitted.size, - wordFrequencies: buildWordFrequencies(texts), + wordFrequencies: await buildWordFrequencies(texts), dataUnaccepted: roster .filter((row) => !submitted.has(row.username)) .map((row) => ({ diff --git a/apps/api/src/services/word-frequency.ts b/apps/api/src/services/word-frequency.ts index fc64ea7..8d2bd0c 100644 --- a/apps/api/src/services/word-frequency.ts +++ b/apps/api/src/services/word-frequency.ts @@ -36,23 +36,26 @@ const CUSTOM_WORDS = [ * 词典加载有一次性开销(约 100ms),放在模块级会拖慢 API 冷启动, * 而词云只有教师偶尔点一次。改成首次调用时才建。 */ -let instance: JiebaInstance | null = null +let instance: Promise | null = null function jieba() { + // 缓存 Promise 而不是结果:并发进来两个请求也只会建一次词典 if (instance) return instance - const built = withBuiltinDict() - // 对应旧后端的 jieba.add_word(w, freq=9999)。 - // @node-rs/jieba@2 没有导出 insertWord/addWord,改用用户词典缓冲区,格式为「词 词频」。 - built.loadDict( - Buffer.from(CUSTOM_WORDS.map((word) => `${word} 9999`).join("\n") + "\n"), - ) - instance = built - return built + instance = (async () => { + const built = await withBuiltinDict() + // 对应旧后端的 jieba.add_word(w, freq=9999)。 + // @node-rs/jieba@2 没有导出 insertWord/addWord,改用用户词典缓冲区,格式为「词 词频」。 + built.loadDict( + Buffer.from(CUSTOM_WORDS.map((word) => `${word} 9999`).join("\n") + "\n"), + ) + return built + })() + return instance } -export function buildWordFrequencies(texts: string[], topN = 80) { +export async function buildWordFrequencies(texts: string[], topN = 80) { const counter = new Map() - const cutter = jieba() + const cutter = await jieba() for (const raw of texts) { const text = raw.replaceAll("【重点】", "") for (const token of cutter.cut(text)) { diff --git a/apps/api/src/vendor/jieba.ts b/apps/api/src/vendor/jieba.ts index 1745abf..e2ff83c 100644 --- a/apps/api/src/vendor/jieba.ts +++ b/apps/api/src/vendor/jieba.ts @@ -1,55 +1,64 @@ /** - * 直接加载 @node-rs/jieba 的原生模块和词典,绕开这个包自己的加载逻辑。 + * 加载 jieba。开发和编译两种形态走不同的路,因为没有一条路两边都能用。 * - * 为什么不老老实实 `import { Jieba } from "@node-rs/jieba"`: + * ## 编译形态(生产) * - * - 它的 `index.js` 在**运行时**探测平台再 `require` 对应的子包; - * - 它的 `dict.js` 用 `__dirname` 去读同目录的 `dict.txt`。 - * - * 这两件事在 `bun build --compile` 之后都不成立 —— 编译产物里 `__dirname` 和模块 - * 解析根都是 `/$bunfs/root`,子包和 dict.txt 都不在那儿。实测编译后直接报 + * `@node-rs/jieba` 的 `index.js` 在运行时探测平台再 `require` 对应的子包, + * `dict.js` 用 `__dirname` 去读同目录的 `dict.txt`。这两件事在 + * `bun build --compile` 之后都不成立 —— 二进制里 `__dirname` 和模块解析根都是 + * `/$bunfs/root`,子包和 dict.txt 都不在那儿。实测编译后直接 * `Failed to load native binding`,而且**只在离开仓库目录后才报**(在仓库里跑时 - * 它顺着 cwd 找到了 node_modules,假装没事),是那种在服务器上才炸的坑。 + * 它顺着 cwd 摸到了 node_modules,假装没事),是那种到服务器上才炸的坑。 * - * 改成把 `.node` 和 `dict.txt` 用 `with { type: "file" }` 内嵌成资源:编译时它们被 - * 塞进二进制,运行时 Bun 把它们摊到 `/$bunfs/root/` 下,`require()` 和 `readFileSync` - * 都能正常拿到。开发模式(不编译)下这个写法拿到的就是 node_modules 里的真实路径, - * 两种模式同一份代码。 + * 所以编译形态下把 `.node` 和 `dict.txt` 用 `with { type: "file" }` 内嵌成资源, + * 运行时 Bun 把它们摊在 `/$bunfs/root/` 下,`require()` 和 `readFileSync` 都能正常拿到。 + * + * ## 开发形态 + * + * 但 `.node` 的资源导入**只有打包器认,运行时不认**:`bun run` 遇到 + * `import x from "….node" with { type: "file" }` 会报 + * “To load Node-API modules, use require() or process.dlopen instead of import.”, + * 整个服务起不来。所以开发时老老实实用包自己的入口,那条路在 `bun run` 下是好的。 + * + * 两个分支都必须是**动态** import:静态 import 在模块加载时就会求值, + * 用 `isCompiled` 判断也来不及,dev 一样会撞上上面那个报错。 * * 平台写死 linux-x64-gnu:部署目标是 debian 基底的容器,本机开发也是 x64 glibc。 - * 换平台(比如改用 alpine/musl 基底镜像)必须同步改这里的 import,否则编译能过、 - * 启动就崩 —— 所以下面加了显式的错误提示。 + * 换基底镜像或 CPU 架构必须同步改这里,否则编译能过、启动就崩。 */ -import addonPath from "@node-rs/jieba-linux-x64-gnu/jieba.linux-x64-gnu.node" with { type: "file" } -import dictPath from "@node-rs/jieba/dict.txt" with { type: "file" } -import { readFileSync } from "node:fs" +import { isCompiled } from "../runtime" export interface JiebaInstance { cut(text: string, hmm?: boolean): string[] loadDict(dict: Buffer): void } -interface JiebaAddon { - Jieba: { withDict(dict: Buffer): JiebaInstance } -} +/** 内置词典建一个分词器。idf.txt 用不到,不内嵌,省二进制体积 */ +export async function withBuiltinDict(): Promise { + if (!isCompiled) { + const { Jieba } = await import("@node-rs/jieba") + const { dict } = await import("@node-rs/jieba/dict") + return Jieba.withDict(dict) as unknown as JiebaInstance + } -let addon: JiebaAddon | null = null + const { readFileSync } = await import("node:fs") + const addonPath = ( + await import("@node-rs/jieba-linux-x64-gnu/jieba.linux-x64-gnu.node", { + with: { type: "file" }, + }) + ).default as unknown as string + const dictPath = (await import("@node-rs/jieba/dict.txt", { with: { type: "file" } })) + .default as unknown as string -function loadAddon(): JiebaAddon { - if (addon) return addon + let addon: { Jieba: { withDict(dict: Buffer): JiebaInstance } } try { - addon = require(addonPath as unknown as string) as JiebaAddon + addon = require(addonPath) } catch (error) { throw new Error( `加载 jieba 原生模块失败(${addonPath})。若已更换容器基底或 CPU 架构,` + `需同步修改 src/vendor/jieba.ts 里写死的 linux-x64-gnu 导入。原始错误:${String(error)}`, ) } - return addon -} - -/** 内置词典(dict.txt,约 4.8MB)。idf.txt 用不到,不内嵌,省二进制体积 */ -export function withBuiltinDict(): JiebaInstance { - return loadAddon().Jieba.withDict(readFileSync(dictPath as unknown as string)) + return addon.Jieba.withDict(readFileSync(dictPath)) }