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))
}