feat(阶段3): 补齐用户侧依赖的三个 admin 端点

重判、提交统计、流程图统计这三条挂在旧后端的 admin 路由下,权限也确实是
teacher/super admin,但入口在用户侧页面里(提交列表页的重判按钮、两个统计面板)。
不做完,阶段 3 的出口标准「用户侧全部功能跑在新后端上」就不成立 —— 按 URL
前缀切阶段会漏掉它们。

- POST submissions/:id/rejudge     ← GET admin/submission/rejudge
- GET  submissions/statistics      ← GET admin/submission/statistics
- GET  flowcharts/statistics       ← GET admin/flowchart/statistics

几处对齐旧后端的细节:
- AST_CHECK_FAILED(10) 与 ACCEPTED(0) 同算通过
- 完成度先用原始花名册人数算、再修正 person_count,顺序照搬,兜住「学生已删号
  但提交记录还在」
- 有提交但零通过的学生,两个名单里都不出现(旧后端同样口径)
- 词云用 @node-rs/jieba,STOPWORDS 与 38 个自定义词逐词照搬;jieba@2 没有
  insertWord,改用 loadDict 加载用户词典
- avgScore 分母是有分数的条数,对齐 Django Avg() 跳过 NULL

rejudge 的 jobId 带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId
做 jobId 的话 BullMQ 会认为任务已存在,重判会静默变成空操作。

correctRate 改成数值不带 %,展示格式化交给前端。stripClassPrefix 用
startsWith+slice 而不是 replace,前缀对不上时不会从中间截出乱码;site.ts
原有的 replace 写法一并改掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 06:48:05 -06:00
parent cb6d42363b
commit 836d97847e
12 changed files with 787 additions and 233 deletions

View File

@@ -0,0 +1,67 @@
import { Jieba } from "@node-rs/jieba"
import { dict } from "@node-rs/jieba/dict"
/**
* 流程图评语词云的分词。对齐旧后端 `flowchart/views/admin.py` 的
* STOPWORDS / CUSTOM_WORDS / _build_word_frequencies 三段。
*
* 停用词表逐词照搬,改一个词就会让词云和旧后端对不上 —— 教师是拿它横向比不同班级的,
* 词表变了历史截图就没法比。
*/
const STOPWORDS = new Set(
(
"的 了 是 在 和 有 就 不 也 都 要 会 这 那 到 说 上 为 与 及 等 " +
"把 被 从 而 所 但 如 又 或 很 更 还 让 对 已 向 只 能 以 中 可以 " +
"可能 需要 没有 使用 进行 注意 建议 应该 考虑 整体 基本 部分 " +
"一个 一些 一下 一定 一种 这个 所有 其他 比较 存在 明确 " +
"正确 良好 清晰 合理 较好 不错 符合 标准 "
)
.split(" ")
.filter(Boolean),
)
const CUSTOM_WORDS = [
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件",
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束",
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误",
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断",
"缺少循环", "死循环", "无限循环", "循环出口", "循环体",
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用",
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作",
"终止条件", "退出条件", "返回值",
]
/**
* 词典加载有一次性开销(约 100ms放在模块级会拖慢 API 冷启动,
* 而词云只有教师偶尔点一次。改成首次调用时才建。
*/
let instance: Jieba | null = null
function jieba() {
if (instance) return instance
const built = Jieba.withDict(dict)
// 对应旧后端的 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
}
export function buildWordFrequencies(texts: string[], topN = 80) {
const counter = new Map<string, number>()
const cutter = jieba()
for (const raw of texts) {
const text = raw.replaceAll("【重点】", "")
for (const token of cutter.cut(text)) {
const word = token.trim()
if (word.length < 2 || STOPWORDS.has(word)) continue
counter.set(word, (counter.get(word) ?? 0) + 1)
}
}
return [...counter]
.sort((a, b) => b[1] - a[1])
.slice(0, topN)
.map(([word, count]) => ({ word, count }))
}