fix(阶段5): jieba 资源导入不能用在 dev;补上 /public/upload 的伺服

## jieba:两种形态得走两条路

上一条 commit 把 jieba 的 .node 改成 `with { type: "file" }` 内嵌,只验证了
编译产物,没跑 `bun run` —— 结果 dev 直接起不来:

    TypeError: To load Node-API modules, use require() or process.dlopen
               instead of import.

`.node` 的资源导入只有打包器认,运行时不认。而且必须是**动态** import:
静态 import 在模块加载时就求值,拿 isCompiled 判断也来不及。

所以分两条路:dev 用包自己的入口(那条路 bun run 下是好的),编译形态才走
内嵌资源。两边都实测过了。.wasm 没这个毛病,两种形态都正常。

withBuiltinDict 因此变成 async,连带 buildWordFrequencies 也是。
jieba 实例改成缓存 Promise 而不是结果,并发进来两个请求只会建一次词典。

## /public/upload 之前根本没人伺服

后台上传图片存盘、返回 `/public/upload/<name>`,但没有任何路由处理这个前缀 ——
题面里插的图片一律 404。补上,并和头像共用一个 serveUpload:只取路径最后一段
且要求它和原样一致,`..`、子目录、编码斜杠都在这里被拒。

生产环境这些请求也走后端(Caddy 反代整段 /public),不让 Caddy 直接读盘,
这样开发和生产是同一条代码路径。

dev 模式 8 项实测:上传图片/头像/默认头像回退 200,不存在、`..`、编码穿越、
子目录、编码斜杠全 404。编译产物在干净目录里 7 项照旧全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 23:20:12 -06:00
parent ce21a2bb8f
commit e2c6ee69da
4 changed files with 95 additions and 53 deletions

View File

@@ -36,23 +36,26 @@ const CUSTOM_WORDS = [
* 词典加载有一次性开销(约 100ms放在模块级会拖慢 API 冷启动,
* 而词云只有教师偶尔点一次。改成首次调用时才建。
*/
let instance: JiebaInstance | null = null
let instance: Promise<JiebaInstance> | 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<string, number>()
const cutter = jieba()
const cutter = await jieba()
for (const raw of texts) {
const text = raw.replaceAll("【重点】", "")
for (const token of cutter.cut(text)) {