Commit Graph

226 Commits

Author SHA1 Message Date
20a6ddc79c feat(自学): 重做学生端页面与教师端学情概览
Some checks failed
Deploy / deploy (push) Has been cancelled
学生端:目录 + 居中限宽正文 + 可收起的示例代码栏;目录改成三态圆点,
顶部加总进度;上一课/下一课栏桌面端固定在底部。
教师端:汇总卡片、学生状态标签与筛选(未开始/7 天没学/只读不练等)、
最后学习补「N 天前」、按练习表加「没人一次对/多数人卡住」提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 05:07:37 -06:00
c228b164cf refactor(提交): 教师统计路由拆出 submission-statistics.ts,check:routes 支持嵌套挂载
Some checks failed
Deploy / deploy (push) Has been cancelled
- submission.ts 1416 行降到 751 行;统计三条路由拆成子路由,挂在原位置,
  仍排在 /submissions/:id 之前
- check-route-shadowing 原来只认 index.ts 直接挂载,拆分后漏检 3 条(178→175);
  现在按挂载位置展开嵌套的 .route(),恢复 178 条、无遮蔽

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00
b5ba56ccd0 refactor(契约): 判题状态码收进契约唯一一份,并收紧 18 处 as any
- 状态码常量与 judgeStatusSchema 移到 packages/contract/src/judge-status.ts,
  后端 judge/status.ts 只再导出;前端 SubmissionStatus 枚举加编译期断言对齐契约
  (实测改坏一个码会当场类型检查失败)
- 类型逃逸 22 处降到 4 处:collab/handler、pagination、configUpdate、
  ExerciseManager、ProblemSubmission、pk.vue tooltip;剩下的是词云插件无类型、
  生成的 .d.ts、skulpt 和 TextEditor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00
f90d01338e feat(自学情况): 按学生加姓名/学号搜索,后端补稳定排序
Some checks failed
Deploy / deploy (push) Has been cancelled
搜索是纯前端过滤(整表本来就一次拉完),和班级筛选可以叠加。

学生查询原来没有 orderBy,前端默认按「已读」升序排之后,同分的一大批
(尤其一堆 0)落回聚合的任意顺序,刷新一次换一个样;按班级、学号兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:23:01 -06:00
3cc2be77a9 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-17 02:12:04 -06:00
905ba2ee03 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-17 02:07:52 -06:00
a8de8f3932 feat(排名): 本周进步榜移到全服 Top100 右边,左 2/3 右 1/3
Some checks failed
Deploy / deploy (push) Has been cancelled
两张榜分开上下放的时候,谁也看不见谁:上面那张是历史全部 AC 的总榜,名次几乎不动;
下面那张的分母只有这一周。并排摆在同一屏里,「总榜追不上」和「这周还能进前十」
才是一眼对照出来的。桌面 cols=3、Top100 占 2 栏,移动端退回 cols=1 上下堆叠。

周榜宽度从整屏缩到 1/3,列跟着收:列头去掉「本周」前缀(卡片标题已经写着),
宽度压到 70/120min/100/90,用户名加 ellipsis tooltip —— 1280 那一档也不出横向滚动条。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:03:04 -06:00
24af385f33 feat(排名): 加本周进步榜,按本周首次 AC 数排,每周一清零
Some checks failed
Deploy / deploy (push) Has been cancelled
存量榜(/rankings/users、班级榜)排的都是 user_profile 的 AC 总数,名次几乎不动,
中位学生看一眼就知道追不上 —— 榜单在那批人身上是负反馈。这张榜的分母换成「这一周」,
每周一 0:00(东八区)清零,谁都可能进前十。

口径是**本周首次 AC 的题目数**,不是「本周 AC 过的去重题数」:后者把上周就做出来的题
重交一遍也算成绩,一分钟能刷满一屏。靠 NOT EXISTS 排掉本周之前已通过的
(user, problem) 对,四个条件正好是 submission_public_metrics_idx 的全部列。

- time.ts 加 weekStart():localWeekday 的 0 是周日,要先折成 7,否则周日单独成一周
- GET /rankings/weekly?scope=global|class,入榜人群与全服榜一致(教师/超管不参与)
- 前端默认落在本班 —— 全服周榜上中位学生仍然看不到自己,班内 30 人那张才有答案
- 本周一题没做出来时 me 是 null,footer 那句「做出 1 题就能上榜」照样出现:
  它是说给还没上榜的人听的,而那正是最需要被推一把的一批

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:53:37 -06:00
ad858ed864 feat(提交列表): 代码详情弹框支持方向键翻阅,到头自动翻页
Some checks failed
Deploy / deploy (push) Has been cancelled
老师看一个班的提交原来得「点开 → 看 → 关掉 → 点下一行」,现在弹框开着就能
用 ↑↓(←→ 等价)切上一条/下一条,Esc 关闭。

- 只在 showLink 为真的行之间走,跳到看不了的行只会得到空弹框
- 走到本页头尾自动翻页续上:向下落到下一页第一条,向上落到上一页最后一条。
  翻页是异步的,先记 pendingJump,等 listSubmissions() 回来再开;
  整页都看不了代码时保持原来那条不动,给一句提示
- 弹框没开不接管方向键,焦点在输入框里也不抢
- SubmissionDetail 加 :key="submissionID" —— 它只在 onMounted 拉一次代码,
  不换 key 切过去还是上一条的代码

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:26:51 -06:00
88b695d34f feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
Some checks failed
Deploy / deploy (push) Has been cancelled
点求助列表里的一条 = 跳到 /problem/:id,在页面自带的那个编辑器里和学生同步。
原来是弹一个 CollabModal(弹框里再挂一个 CodeMirror + 一份只读题面):

- 按一下 Esc 弹框就关、协作跟着结束(`show` 的 setter 里 leave 是唯一的关闭语义,
  Esc 和点遮罩都走它),上课太容易误触;
- 弹框里那份题面不能跑测试、不能提交,老师还是得另开标签页对照。

现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。教师端也少了
一个 CodeMirror 实例 —— setBinaryHandler 那个单例槽位只剩 SyncCodeEditor 一个使用者。

改造中撞出两个真问题,根因是同一个:`/problem/1002` → `/problem/1001` 是同一条路由
换 params,**组件被复用、不卸载**。

- 老师在别的题上接单跳过来,`@ready` 不会再触发,而 room 早在导航前就 open 了 ——
  老师停在一个空编辑器上干等。watch 的源从 room 一个变成 [room, roomIsHere] 两个。
- 反过来老师协作中切去别的题,onUnmounted 不触发 → 不发 leave,学生一直挂着
  「老师正在帮你」而老师的字一个也过不去。加 `bound` 标记区分「房间从我这儿挪走了」
  (补发 leave)和「房间在别处开起来了」(跟我无关,接单那一瞬间就是这种)。

`leave` 分两种语义(协议加 reason):

| 前端 | 求助记录 | 对面看到 |
|---|---|---|
| leave("done") 点「结束协作」 | 删除 | 老师已结束这次帮忙 |
| leave("left") 离开这道题的页面 | 教师走→退回排队;学生走→删除 | 老师暂时离开,已重新排队 |

"left" 复用的是老师掉线那条路(requeueAfterTeacherGone),两件事语义相同。原来两者
都按 done 处理 —— 老师点一下「提交信息」,学生就得重新举手,而他看到的是
「协作已结束」,会以为被处理完了。发起方收到的 reason 单独一份(self_left/done),
不然学生自己切走了却看到「老师已结束这次帮忙」。

其余为这套交互补的闸:

- 学生排队期间离开那道题 → 自动取消求助。那条求助说的是「我卡在这道题」,人走了
  就不成立;原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本
  不会绑,老师对着空编辑器敲字,两边都没提示。
- 协作中**教师的语言选择跟着 room.language 走**(写 codeStore,不只是改高亮),
  结束后还原。只改高亮的话老师会拿自己那档语言提交学生的代码(学生写 C、老师选的
  是 Python,当场 CE),工具栏还可能显示「提交流程图」。学生端一个字不动 ——
  服务端的 room_language 只发给教师,学生本地那份停在建房那一刻。
- 协作中不给「重置代码」:v-model 一写回就顺着 Yjs 同步过去,等于一键清空学生的作业。
- 协作中「提交信息」走新标签 —— 那是教师工具栏上唯一会跳路由的按钮。
- 接单时 resetScreenMode():分屏在「题目」「自测」两档时右侧编辑器根本没挂出来,
  停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 detail.vue 的 init()
  会重置,同页不会)。
- 老师已在一个房间里时前端也拦住接下一单(服务端本来就拦),不拦的话前端已经跳到
  新题目上,等于把手上那场协作断掉。
- 文案:协议层那四条英文(Invalid problemId 之类)会被原样弹成 toast,改成中文;
  「请先退出当前协作」统一成「结束」;room_closed 的提示按 reason × 角色展开。

验证:本机起 api + web,三个账号两两组合实跑 —— 跨题接单 / 同页接单、双向编辑、
连按 Esc、结束协作、老师跳走后再接、学生跳走、学生排队中切题、学生协作中切语言、
分屏在「题目」档接单、协作中重复接单,以及上面每一句提示的实际文案。
api typecheck / check:routes、前端 type-check / build、fmt 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:13:42 -06:00
ed56a209ea chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
Some checks failed
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00
e600fd24cf feat(提交列表): 今日提交数旁加「统计」按钮,弹框给今天的提交统计
筛到今天之后标签旁边出现「统计」,弹框里是全站今天的提交概况:总提交 / 正确 /
判题中 / 正确率 / 参与人数,外加按钟点的 24 格分布、按语言、按判题结果,以及
今天最热的 10 道题。

新接口 GET /submissions/today-statistics,公开、只出聚合数,口径和那颗标签一致
(东八区今天 + 非比赛提交):

- 钟点分桶走 time.ts 的 localTime()。`extract(hour from create_time)` 按会话时区
  算,容器是 UTC,整张分布图会左移 8 小时;
- 正确率的分母摘掉未判完的条数,正确数含 AST_CHECK_FAILED;
- 热门题只算 visible 的题目 —— 这个接口不需要登录,不能拿它探未发布题目的标题;
  「提交列表对学生全开」关掉时这张表整个不下发,跟提交列表同一个开关(数字照给,
  否则标签说 21、弹框说 0)。

前端组件异步加载,不进本路由的关键路径;小时分布是纯 CSS 柱状图,没有引 chart.js。
柱子和基线取 useThemeVars(),深浅色都跟着走,「现在」那一格是基线上一段主色刻度。
流程图那档不给这颗按钮 —— 流程图提交在另一张表、只有 AI 评级没有判题状态。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:23:45 -06:00
5eee13fe81 chore: 删掉两个跑完的一次性订正脚本,recount 留着
Some checks failed
Deploy / deploy (push) Has been cancelled
fix-achievement-hours 订正的是时区丢失那两周误发的「夜猫子」「早起的鸟儿」,
2026-09-14 已跑完(修正 148 行 · 撤回 60 条),根因修在代码里之后不会再产生;
backfill-problemsets 补的三笔题单历史欠账也已结清,进度记账现在在判题这一路。
两个子命令、两条 npm 脚本一并去掉,services/problemset.ts 的 badgeHolderDiff
只服务于补发脚本,删完 0 处引用,一起清掉。

recount 留着 —— 反范式计数列会被重判、删提交带偏,是会复发的漂移,
补一条 npm 脚本(原来只有子命令)。

docs/timezone.md 里那节改成「账已平、脚本已删」,但保住那条教训:
unlockAchievements 是纯阈值比较,只删 user_achievement 不修 metrics 的话,
学生下次提交就把同一个成就原样再发一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:03:57 -06:00
6fdc3c588e chore(开发环境): 本机库改成靠迁移自举,db:migrate 换成线上同一个执行器
compose.dev.yml 不再把 schema dump 挂成 initdb:0000 就是完整的建表迁移,
起完库 bun run db:migrate 从 0000 自举即可。实测两条路子跑出来的结构一致 ——
空库自举 16 条迁移 vs 灌 dump + 打基线 + 跑迁移,pg_dump --schema-only
逐行零差异(1981 行)。docs/specs/schema.sql 与 sample-data.sql 随之删掉,
前者的内容已经在 0000_crazy_gateway.sql 里。

db:migrate 原来是 drizzle-kit migrate,和线上 oj2-api migrate 不是同一个
执行器:drizzle 那个所有迁移共用一个事务、跑不了 CREATE INDEX CONCURRENTLY,
对已打基线的库还会从 0000 撞表回滚且 exit 1 不打印任何错误。现在两边都走
db/migrate.ts。DATABASE_URL 从根目录 .env 读(--env-file),仓库里不留连接串。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:03:57 -06:00
a8408c0bb5 docs: 文档整理,CLAUDE.md 瘦身一半,删掉重写期已完成的 22 份阶段产物
CLAUDE.md 从 490 行降到 228 行:只留日常要当场记住的约束,展开拆成五份专题
文档 —— docs/deploy.md(部署与备份恢复)、database.md(迁移执行器、基线、
drizzle-kit 的坑)、timezone.md(时区口径与那次成就订正)、contract.md
(出参不 parse 的四次故障)、ast-rules.md(AST 规则与 C++ 的调用形态)。

删掉的是阶段 0–5 那批一次性产物:4 份实施计划、10 份评审/核验/修复报告、
endpoint-inventory.md(110 端点是 2026-08 的快照,现在 363 条路由)、
docs/spikes/ 的 spike 与提取脚本(结论早已落进代码)。phase5 切换手册删之前
先把仍然有效的部分提炼进 docs/deploy.md:拓扑、deploy.sh、部署后验证清单、
NPM 那两个不能关的开关、pg_dumpall 恢复的两个坑、镜像体积;演练报告与回滚
两节随旧栈下线一并作废。

两份设计文档保留,补上状态行说明它们是「当初为什么这么定」而不是现状。

apps/web/CLAUDE.md 顺手订正过期内容:PUBLIC_OJ_URL / PUBLIC_WS_URL 两个变量
早已不存在(baseURL 写死 /api,dev 走 vite proxy、线上由 Caddy 同源伺服),
store 与 composable 清单补齐到与目录一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:03:26 -06:00
3559ae4d6f chore(前端): 浏览器基线从 Chrome < 94 提到 105,删掉 mermaid@9 那套 fallback
机房只有部分电脑还是 Chrome 105,其余更新,按最低那档定基线。

- legacy 插件留着:vite 8 的默认 build.target 是 chrome111,比机房高。
  modernTargets 不写,用插件自带的 chrome>=105 基线,正好是这一档。
- polyfill 清单按 105 重新探测,63 → 50 项,仍然写死:自动探测要对每个产物
  跑 Babel 扫描,构建 3s → 12s。写死后产出的 polyfills chunk 与自动探测同尺寸。
- 删 mermaid-legacy(mermaid@9)、useMermaid 里按 UA 分叉的 v9 回调式 render、
  为它存在的 cytoscape UMD→ESM 别名 —— 105 直接用 mermaid 11。
- View Transitions 要 111,darkTransition 的降级分支保留,注释改成 105。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:43:21 -06:00
688005c081 perf(流程图): 列表裁掉整行 select、count 去掉 join,统计面板的数值全下推 SQL
Some checks failed
Deploy / deploy (push) Has been cancelled
流程图提交量涨上去之后,先扛不住的是教师统计面板:它一条不带 limit 的 select
把整个时间窗的行拉进内存再用 JS 算,词云那个 3000 条上限是在 JS 里截的,行早就
全回来了。列表那边则是 `select({ flowchart: 整行, problem: 整行 })`,把
mermaid_code、flowchart_data、三个 AI 文本列和整张题目表一起拉回来,响应一个
都用不到(快照实测流程图行均 4.9KB、题目行均 2.2KB,10 行一页白拉 ~70KB,
limit=250 时 1.7MB)。

- 列表改成白名单列 flowchartListColumns,对齐 submission 那边的
  submissionListColumns(那边同样刻意不取 code / info)。
- 题号 / 用户名筛选先解析成 flowchart_submission 自己的列,count 因此一个 join
  都不用挂,回得到最小索引上的 index-only scan;筛条件落在驱动表上,规划器也走
  得上 flowchart_user_time_idx / flowchart_problem_time_idx。
- 统计面板拆成五条各自和行数脱钩的查询:数值聚合、等级分布、各项平均分
  (jsonb_each + group by)、词云原料(order by ... limit 3000)、谁没做。
  每项满分改从词云那批行里顺手取,省掉一次 21 万行的排序。
- matchedUsers() 从 submission.ts 挪进 helpers.ts,两条统计共用。

拿生产快照(2134 条)复制一份、另插三行脏数据(标量 jsonb、数组 jsonb、分数和
满分写成字符串),新旧两版各跑 24 个请求组合:23 个逐字节一致;剩下 1 个只是三行
create_time 完全相同的记录先后不同 —— 既有的不确定性(ORDER BY create_time 不是
全序),留给后面的 keyset 分页一并解决。

把表灌到 5.3 万 / 21.3 万行实测(HTTP 端到端,5 次取最好,旧 → 新):

  列表 limit=10       35 →  4 ms    |  56 →   8 ms
  列表 limit=250      36 →  5 ms    |  54 →   8 ms
  列表 offset=5万    108 → 39 ms    |  57 →  45 ms
  列表 按班级         33 →  4 ms    |  53 →   8 ms
  统计 全部时段      288 → 187 ms   | 1169 → 704 ms
  统计 一个班        143 →  43 ms   |  388 → 129 ms
  统计 一道题         94 → 194 ms   |  339 → 277 ms

「统计 一道题」在 5 万行量级是退步的:那个筛选命中全表 23% 的行,PG 侧的 jsonb
算子比「原样输出让 Bun 去 parse」更费 CPU,要到 21 万行才反超。它跟的是筛出来的
行数、不跟总量走,200ms 的教师面板可以接受,没有再调。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:19:21 -06:00
ed3b2cf6de feat(课堂求助): 教师协作弹框左侧直接显示题面
Some checks failed
Deploy / deploy (push) Has been cancelled
原来弹框里只有编辑器,要看题得点「打开题面」另开标签页来回切。
现在左右分栏(可拖动):左侧只读题面(描述、输入输出、例子、提示,
SQL 题显示数据表与期望结果),右侧协作编辑器。

不复用 ProblemContent:它读写全局 problemStore,老师接单时正开着
的题目页会被换掉题面,测试按钮跑的也是老师自己的代码。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:48:40 -06:00
4189c59162 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-14 08:39:58 -06:00
cc51e305cf refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
Some checks failed
Deploy / deploy (push) Has been cancelled
- TIME_ZONE / TIME_ZONE_OFFSET_MINUTES 移到 packages/contract/src/time.ts,
  前后端共用一份,不再各写一遍靠注释对齐。
- apps/api/src/time.ts 新增 localTime(列),替换散落 7 处的
  `at time zone ${TIME_ZONE_SQL}`;ac-trend 的 where 复用同一个 year 表达式。
- /problems/:displayId/yearly-ac 漏写了时区、按 UTC 切年,被会话时区兜底掩盖;
  改为按东八区切(只影响每年 12-31 北京 0–8 点的提交归年)。
- 删掉数据库连接的 TimeZone 和 Dockerfile 的 TZ:正确代码不依赖它们,
  它们只会在线上掩盖漏写处、让 dev 与线上答案不同。
- time.ts:calendarDayYearsAgo/pad 并入 shiftMonthsByCalendar,startOfCalendarDay
  并入 todayStart,localWeekday 改用 getUTCDay,删掉历史叙述注释。
- 前端 zonedParts 改为固定偏移 + getUTC*(与后端、日期选择器同一写法),
  去掉 Intl formatToParts;10 万次 299ms → 11ms。zonedYear 去掉按浏览器时区的兜底。
- 两份 CLAUDE.md 同步;n-date-picker 那条过时说明改成现用法。

验证:新旧「近两年起点」21359 个时刻 0 差异、localWeekday 0 差异、
前端固定偏移与 Intl 在 America/New_York 下 47821 个时刻 0 差异;
localTime 在 select/group by/where 复用可用;fix-achievement-hours 预演仍为 148 / 60。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 06:06:52 -06:00
3921c496cb fix(成就): 后台补发成就后重算已解锁数并接着判「奖杯收藏家」;recount 订正存量
rescanAchievement 只插 user_achievement、加 unlock_count,不重算
achievement_unlocked_count,也不做元成就的第二轮判定(旧 rescan_achievement
原样如此)。判题结算只在「这次有新解锁」时才重算,所以被补发的人计数会一直停在
旧值。2026-09-07 一次补发之后 269 人少算,其中 10 人实际够 15 个却没拿到
「奖杯收藏家」。

- 新增 refreshUnlockedCount:一条 SQL 按 user_achievement 重算,只 jsonb_set
  这一个键、只写值变了的行。rescanAchievement 补发非白金成就后调用它,再补发元成就。
- recount 同时核对已解锁数与元成就漏发,--apply 先改计数再补发,复核同一份口径。

用 09-14 生产备份实跑:recount 订正 269 人、补发 10 条,复核通过、重跑无差异,
其余指标 0 行被动;模拟调低阈值补发 1504 条,「奖杯收藏家」随之 65 → 95,
与预先算出的跨线人数一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:38 -06:00
f6995c841b ops(成就): 加 fix-achievement-hours,订正时区丢失期间误发的「夜猫子」「早起的鸟儿」
OJ2 上线到时区修复之间,成就的小时键按 UTC 判定:UTC 的 0–5 点 / 5–7 点是北京
的上午 9–13 点 / 下午 1–3 点,上课时间的提交被记成熬夜和早起。Django 时代的存量
本来就是东八区口径(已用生产备份做判别性核对),出问题的只有这两周的增量。

脚本按东八区重算两个小时指标(只合并这两个键,不整体覆盖 metrics)→ 撤回不达标
的 → 补发达标却没发的 → 同步 unlock_count → 校正已解锁数与「奖杯收藏家」连锁。
默认只读预演,--apply 落库后自动复核,幂等。

用 db_backup_2026_09_14_18_17_37.sql 实跑:修正 148 行、撤回 60 条(47 + 13,
50 人)、补发 0、连锁 0,其余指标 0 行被动。必须先部署时区修复再跑。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:27 -06:00
4c0c38445c fix(时区): 日历口径收回东八区,读出的时刻统一成 ISO 并保留微秒
旧栈 Django 按 Asia/Shanghai 算日历,OJ2 重写时这个锚点丢了:容器和数据库会话
都是 UTC,于是「今日提交」在北京时间 0–8 点是空的,「凌晨/早起提交次数」整体偏
8 小时,热力图、AC 趋势年份、近两年活跃人数也各按进程时区切。

- 新增 apps/api/src/time.ts 作为唯一锚点(固定 +8 偏移,不依赖进程 TZ / tzdata),
  todayStart、成就小时/日期键、热力图、月份平移、年份夹逼全部改走它;
  SQL 里按日历切的一律显式 at time zone。
- db/index.ts:连接会话时区设为东八区(兜底);给 timestamptz(1184) 挂 parser,
  读出统一成 ISO 8601 UTC,撤掉为拿 PG 文本形状写的 ::text。parser 保留微秒 ——
  生产库 12.3 万条提交几乎全带微秒,截成毫秒会让翻页分界行和班级 AC 排名的
  <= min(create_time) 把自己排除(翻页每页丢一条、排名少 1)。
- 前端 parseTime/zonedParts/zonedYear 按 Asia/Shanghai 渲染,n-date-picker 做
  toPickerValue/fromPickerValue 平移,站内不再按浏览器时区取时间部件。
- Dockerfile 设 TZ=Asia/Shanghai 作为第二道兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:17 -06:00
6e63866cc9 perf(提交列表): 按用户名、题号筛选走索引,不再扫全表
Some checks failed
Deploy / deploy (push) Has been cancelled
用户名筛选:user_id 先查成字面列表,不再把子查询夹在 OR 里(那样整条 OR 不可索引),
加 trigram 索引接住 ilike '%x%';翻页先圈出匹配行再排序取页,避开规划器顺着时间索引
倒扫、边扫边滤的计划。快照上查一个班 70~107ms → 5~14ms;匹配 9.5 万条的年级前缀
从 34ms 变成 70~90ms,实际不这么查。

题号筛选:先解析成 problem.id,加 (problem_id, create_time, id) 部分索引。老题和
不存在的题号不再倒扫大半张表(34~67ms → 4ms),题号筛选也能走游标深翻页,
count 不再 join problem。

迁移 0015 装 pg_trgm(官方镜像自带 contrib,trusted 扩展)。39 个筛选组合的响应
与改动前逐字节一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg91q3JsunDE7EYoi9G3i2
2026-09-13 23:47:24 -06:00
fe4fc46243 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-13 23:40:58 -06:00
271123179a fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-13 19:29:45 -06:00
9132901cc7 feat(提交结果): 没通过时显示「通过 x/y 个测试点」;编译失败不等三次就能用 AI 分析
Some checks failed
Deploy / deploy (push) Has been cancelled
学生拿不到 info(每个点带 output_md5,只给管理员),测试点表格从来只有管理员看得见,
学生这边只有一句「答案错误」—— 从 2/8 交到 6/8 的人,感受是连输五次。

- 提交详情新增 caseSummary,后端从 info 数出通过数下发,不放开原文。
  比赛提交、SQL 题(被杀的测试点会 break,total 偏小)、无逐点结果时为 null
- 结果面板标题缀上通过数并加进度条,提交详情页标题同步;一个都没过时不缀
- POST /ai/hint 对编译失败跳过失败次数门槛,throttleAi 照旧;前端显示条件同口径

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TecwAowmYcoNZRheSyTgH
2026-09-13 07:25:00 -06:00
a833819679 fix(统计面板): 班级选择不再记本地,别让上一个人的班悄悄留在框里
Some checks failed
Deploy / deploy (push) Has been cancelled
提交统计弹框里的「班级或用户」原来点过「统计」就永久写进 localStorage
(statisticsClass),下次打开自动带上。机房一台电脑对一个班,下课换班或者
换个人坐这台机器时,上一个人查的班还留在选择框里,老师没注意就按了「统计」,
看到的整个是别人的班。

改成不存也不读,每次打开都从空的开始 —— 宁可多选一次。登录框的 LOGIN_CLASS
不动,那记的是这台机器的身份,不是一次临时查询。

STORAGE_KEY.STATISTICS_CLASS 随之没有调用方,一并删掉。
2026-09-10 20:57:34 -06:00
2b07040aee refactor(时段): 两份手抄的选项列表与五份 "weeks:1" 解析收成一处
Some checks failed
Deploy / deploy (push) Has been cancelled
时段选项的 value 是 `<date-fns 单位>:<数量>`,把它解成 Duration 的那段 `split(":")`
在**五个组件里各写了一遍**(提交统计、流程图统计、AI 分析页、榜单页、班级对比页),
每份的兜底还都不一样;选项列表也抄了好几份:

- 两个统计面板逐字相同地拼「10/20/30 分钟 + DURATION_OPTIONS + 全部时段」;
- rank/list.vue 和 class/pk.vue 各手写了同样的五条长时段,pk.vue 里还留着一句
  「与 rank/list.vue 保持一致」的注释 —— 靠注释同步的东西迟早不同步。

现在:`PANEL_DURATION_OPTIONS`(面板用,含分钟级和 all)、`LONG_DURATION_OPTIONS`
(榜单/班级对比用,从 DURATION_OPTIONS 派生)、`durationFromValue()`(唯一解析)。
各站点自己的兜底保留在原处,那部分本来就该各不相同。

## 行为零变化,逐条比对过

把改动前各处手写的列表原样取出来和新的比:面板 12 条、榜单 5 条、班级对比 6 条,
标签与取值逐条一致;11 个时段值的解析结果与旧的内联写法逐个相同。

唯一的差异在 `all`:旧写法产出 `{all: NaN}`,新写法回 null。**两边都到不了** ——
三处用到 subOptions 的地方全在 `query.duration === "all" ? … : …` 的 else 分支里,
三元短路,all 时根本不求值。逐处确认过。

## 实跑

- 榜单页:下拉 5 条顺序正确;切一周内,请求从 start=2026-08-10(months:1)
  变成 start=2026-09-03(weeks:1),正好差 7 天;
- 班级对比页:下拉 6 条含「全部时间」;两个班 PK,全部时间和一个月内都正常出结果;
- 提交统计面板:默认 minutes:10,统计请求的 start 正好比 end 早 10 分钟。

另:ChartJS.register 那 15 处**没有动**。逐个列出来看,它们注册的是各自需要的那一套,
不是同一份重复(真正逐字相同的只有 4 个 Bar 图和 2 个 Line 图),而且组件各自声明依赖
正是 chart.js 该用的方式 —— 没用到的控制器不会进包,忘了注册会当场抛
`"bar" is not a registered controller`,是响的不是静默的。集中注册只会把懒加载的图表
代码推进首屏,为省 6 处重复不值得。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 20:28:33 -06:00
8eae4bea7b feat(统计面板): 展开一个学生后按题目分组,每道题画一条「状态轨迹」
Some checks failed
Deploy / deploy (push) Has been cancelled
老师展开一个学生,原来看到的是一排 12 位十六进制的提交编号按钮。编号本身没有信息量,
而一节课里学生常在好几道题之间来回跳,那一排看不出他到底卡在哪一道。

现在一道题一行:题号、标题、交了几次、过没过,后面跟一排按时间**从早到晚**的小方块,
颜色就是判题状态(绿=通过 红=答错 黄=编译失败/超时 灰=判题中)。
「八绿一红一绿」和「五黄到底」一眼分得开。

- **排序按老师的用法来**:没过的排前面,其中交得越多越靠前 —— 卡得最久的那道顶到眼前;
  已通过的沉底,它们只是「做完了」。
- 鼠标悬停出「状态 · 时间 · 提交号」,点击照旧打开提交详情。
- 「语法未过」(ast_check_failed) 算做出来了,和表格上「已解决」那一列口径一致。
- 方块用内联样式而不是 class:它们是 h() 出来、挂在 NDataTable 展开槽里渲染的,
  <style scoped> 能不能盖到并不确定。色值沿用 Naive 的语义色,和 ExerciseMatch.vue 一致。

为此 GET /submissions/statistics/items 多带三个字段(problem / problemTitle /
createTime)—— 原来只有 id 和 result,分组和悬停都无从谈起。innerJoin problem 不会漏行:
submission.problem_id 是 NOT NULL 且外键 NO ACTION,题目删不掉。

## 验证

起全栈在浏览器里真点过(提交列表 → 数据统计 → 提交记录 → 展开 student):

- 五道题各成一行,顺序是 1005(5次未过) → 1006(4次) → 1020(1次) → 1018(1次) →
  1004(10次已通过),符合「没过的在前、交得多的在前、已过的沉底」;
- 现造一条「先错后对」:轨迹读出来是 绿绿绿绿绿绿绿绿红绿,新交的 WA→AC 落在末尾,
  确认是从早到晚而不是倒序;
- 悬停取到「编译失败 · 09-02 22:03:14 · 9f02da4c5f01」。

tsc、check:routes、vue-tsc、vite build、单二进制编译均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 20:09:09 -06:00
f581029eb2 refactor(统计面板): 抽出 useHiddenStudents,两份逐字相同的「请假隐藏」合成一份
StatisticsPanel.vue 和 FlowchartStatisticsPanel.vue 各写了一遍隐藏学生的逻辑
(loadHidden / saveHidden / hideStudent / showAll / onMounted 里的过期清理),除了
存储键和一个参数名逐字相同;「这个人有没有被隐藏」的判断两边还各自内联了三处。

现在是 shared/composables/hiddenStudents.ts,存储键作参数传进去 —— 两个面板仍然各用
各的键,提交统计里隐掉的人不该连带在流程图统计里也消失,那是两件事。

composable 不导出 hiddenStudents 那张表本身,只给 isHidden / notHidden:两个面板要的
都是「这个人该不该显示」,把表递出去只会让判断逻辑又散回组件里。

## 验证

起全栈在浏览器里真点了一遍(提交列表 → 数据统计 → 未完成 tab):

- 打开「请假隐藏」开关后学生标签变成可关闭,关掉「单田芳」,未完成从 5 变 4、
  「还没交」也同步从 5 变 4;
- localStorage 落的是 {"ks251单田芳": <时间戳>},刷新页面后仍在,到期时间 120 分钟;
- 流程图那把键 oj_hidden_students_flowchart 全程为 null,没被串到。

vue-tsc、vite build 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:46:32 -06:00
eda1eb7eee chore(题单): 删掉客户端自报进度的 PUT /problem-set-progress
Some checks failed
Deploy / deploy (push) Has been cancelled
这个端点是「AC 之后前端回调一下,把进度写进题单」那套设计的残留,早就被服务端记账
取代了 —— SubmitCode.vue 里留着当时的说明:客户端那条路只认路由参数里的那一个题单
(从普通题库入口做出同一道题不计进度),网络一抖、页面提前关掉进度就静默丢失;
现在判完之后由 judge/run.ts → services/problemset.ts 的 recordSolvedProblem 记账,
而且记进所有已加入且包含这道题的题单。

上一个提交删掉前端最后一个调用方 updateProblemSetProgress 之后,它就彻底没人打了。
留着的代价不只是死代码:那是一条**学生可以自己写进度**的写入口。

删之前逐个核过:
- 前端零引用(唯一的 wrapper 已在上个提交删掉);
- recomputeProgress 还有 POST 那条在用,保留;computeProgress / eligibleForBadge /
  updateAchievementsForProblemSet / publishAchievementNotification 在别处都有调用方;
- 它往 problemset_submission 写的那一笔,services/problemset.ts:260 做的是一模一样的
  去重后插入,不会因此少写。

顺带清掉因此变成孤儿的四个 import 和契约里的 updateProblemSetProgressRequestSchema
与 UpdateProblemSetProgressRequest。

## 验证

起服务实跑:
- PUT /api/problem-set-progress 现在 404;
- 保留的 POST(加入题单)仍然 201,progress 行照常由 recomputeProgress 建出来;
- 服务端那条替代路径端到端跑通:新建题单 → 加入题目 1004 → 学生加入 → 交一发 AC,
  判完后 problemset_progress 自动变成 completed=1/total=1/100%/得分 10,
  problemset_submission 也落了一行 —— 全程没有任何客户端回调。

tsc、check:routes、vue-tsc、单二进制编译均通过;测试题单已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:42:17 -06:00
79bfd28b07 chore: 删掉 369 行没有任何调用方的代码
Some checks failed
Deploy / deploy (push) Has been cancelled
## utils/functions.ts 砍掉一半(622 → 291 行)

- trickOrTreat():317 行、八种页面恶搞效果(中文乱码、页面翻转、去掉鼠标……),
  **全仓零调用**,占这个共享工具文件的一半;
- 文件末尾注释掉的 getChromeVersion / isLowVersion / protocol 六行。

## 其余没有调用方的导出

- services/achievement-metrics.ts 的 RARITIES / OPERATORS —— 和契约的
  achievementRaritySchema / achievementOperatorSchema 取值逐字相同,是没人用的第二份;
- collab/state.ts 的 hasRequest;以及 resetCollabState,它的注释写着「仅供进程退出或
  测试用」,而本仓库不写测试(项目约定),进程退出那条路径也没调过;
- contract/language.ts 的 JUDGE_LANGUAGES,注释说「前端用它排语言 tab」——前端并没有,
  它排 tab 用的是 constants.ts 里以 ProblemLanguage 为键的 SOURCES / LANGUAGE_SHOW_VALUE
  (那组映射的完整性 tsc 已经在管);
- web 的 useSimplePagination(usePagination 的一层空壳包装)和 updateProblemSetProgress。

扫描口径:三个包全部 .ts / .vue 里逐个导出符号数出现次数,只在定义处出现的算无引用
(.vue 模板里的引用也计入)。剩下 56 个仍无引用的全部是契约里 `z.infer` 派生的一行
类型(46 个请求类型 + 10 个领域类型)—— 它们是在用的 schema 的类型另一半,成体系的
1:1 镜像,删一部分只会让那个文件变得随意,所以不动。

## 留给你定的一件事

`updateProblemSetProgress` 删掉之后,后端 `PUT /problem-set-progress`(routes/problemset.ts:249)
就没有任何调用方了。题单进度实际是判完之后由 services/problemset.ts 的 recordSolvedProblem
在服务端记账的(schema.ts 里那条注释写明了),这个端点是一条客户端自报进度的平行路径。
没顺手删:删端点是行为变化,而且不能排除有脚本在打它。

tsc、vue-tsc、vite build、单二进制编译、check:routes、check:ast 均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:38:16 -06:00
91712b482d fix(AST): f-string 规则从上线起就没生效过;加 check:ast 把这类静默错判变成机器检查
Some checks failed
Deploy / deploy (push) Has been cancelled
## check:ast

判题机拿 target 的 node 去比 tree-sitter 节点类型,**对不上不报错**:collectNodes 一个
都收不到,于是「必须使用 X」永远失败、「不能使用 X」永远通过,两头不报错,只有学生
受着。上一个提交把两张表合成一张,杜绝了「漏配」,但「配错」照样静默 —— 所以加一个
检查,逐个 target 去问语法:这个节点类型你到底有没有。

    bun run --filter '@oj2/api' check:ast

升级 tree-sitter-* 之后必须跑:语法改节点名是常事,后果全静默。它只验节点类型存在,
不验语义对不对(把 while_loop 配成 for_statement 这种两个都存在,机器看不出来)。

## 它抓出来的那个

56 个 target 里坏了一个:Python3 的 f_string 一直配的是 format_string,而这个版本的
tree-sitter-python **根本没有这种节点** —— f-string 是一个 string,靠 string_start 为
f" 和内部的 interpolation 子节点来认。也就是说「不能使用 f-string」这条规则从上线起
就一直判成通过,「必须使用 f-string」一直判成失败。

改成 interpolation。实测:带占位符的 f-string(单双引号都有)命中,而 % 格式化、
.format()、普通字符串、字符串拼接都不误伤。代价是 f"abc" 这种没有占位符的 f-string
认不出来 —— 它确实不含 interpolation,但没占位符的 f-string 本来也没意义,比起原来
「一个都认不出来」是严格的改善。这条写在表里的注释上了。

## 验证

给题目 1004 配「必须有 for 循环 + 不能用 f-string」两条规则实跑:

- 有 for、用了 f-string → 修复前 ACCEPTED(0),修复后 AST_CHECK_FAILED(10),
  ast_results 为「必须使用 for 循环/通过」「不能使用 f-string/不通过」;
- 有 for、不用 f-string → ACCEPTED(0);
- check:ast 修复前 exit 1 并指出这一条,修复后 56 个全过、exit 0。

tsc、check:routes、vue-tsc、vite build、单二进制编译均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:33:19 -06:00
a6ba5cdf07 refactor(AST): 两张 target 表合成一张,加节点类型漏配在结构上不再可能
契约的 AST_NODE_TARGETS_BY_LANGUAGE 是 target → 中文名,judge/ast.ts 的 mappings 是
target → tree-sitter 节点类型,同一批键分在两个包里,靠一句「两边必须同增同减」的注释
维持。只加一边是静默错判:老师给 C 题选到只有 Python 有的 list_comprehension,判题机
拿裸名去比节点类型,C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、
「不能使用 f-string」永远通过,两头都不报错,只有学生受着。

现在一个 target 一条 { label, node }:label 给后台下拉和题目页,node 给判题机。加 target
而漏配节点类型在结构上就不可能了。judge/ast.ts 的 mappings 整张删掉,解析统一走契约的
astTargetNodeType()(节点查 node,运算符查运算符表 —— 那张表的值本身就是要比的 token,
判题机原来抄的 and→&& 三条取值逐个相同,纯属重复)。

顺带把同一份数据的四份拷贝收成一份:C 的 14 条原来在契约和判题机里各抄了两遍
(C 一份、C++ 一份),现在 C++ 逐条引用 C_NODE_TARGETS;运算符表的 C++ 改成
{ ...C_OPERATOR_TARGETS, "<<", ">>" }。C++ 那几条仍逐条列出而不是 spread,是为了保住
下拉框的显示顺序(C++ 独有的几条插在中间)。

## 验证

行为零变化,是逐个 target 机械比对过的:把 HEAD 版的两张表原样取出来,对三种语言的
全部 target 比对「label / 运算符文案 / tree-sitter 解析结果 / 下拉框顺序」四项 ——
C 37 个、C++ 47 个、Python3 43 个,全部一致,键集与顺序也一致。

实跑:给题目 1004 配两条 Python3 规则(必须有 for 循环、不能用 f-string),交一发没有
for 循环的正确答案,判成 AST_CHECK_FAILED(10),statistic_info.ast_results 为
「必须使用 for 循环 / 不通过」「不能使用 f-string / 通过」—— label 与 node 两半都走到了。
再交一发带 for 循环的,判成 ACCEPTED(0)。

tsc、vue-tsc、vite build、单二进制编译均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:30:35 -06:00
8520192a98 refactor(前端): websocket 合并三层继承与三份重复 composable,803 → 660 行
Some checks failed
Deploy / deploy (push) Has been cancelled
## 删掉的

- **六个从没人传过的配置项。** WebSocketConfig 上的 maxReconnectAttempts /
  reconnectDelay / maxReconnectDelay / heartbeatTime / enableHeartbeat /
  enableAutoReconnect,六个默认值、六处 ??,而三条通道从建起来到现在没有一个调用方
  传过任何一个,也没在运行时改过。收成三个模块常量,连带三处恒真/恒不触发的分支
  (if (enableHeartbeat)、!enableAutoReconnect、reconnectAttempts >= Infinity)。
- **一个没人用的工厂。** createWebSocketComposable 顶着二十五行 @example,示例里那条
  通知通道并不存在,三个真实 composable 一个都没调它,各自手写一遍转发对象。
- **三个空壳子类。** SubmissionWebSocket / FlowchartWebSocket / ConfigWebSocket 的全部
  内容就是在构造函数里拼 URL;前两个连 URL 都是同一条 /ws/submissions,两个类加两份
  三十行 composable 的差异只有一个类型参数。改成 useChannel(path, handler) 一个工厂,
  三个 useXxxWebSocket 各自只剩一行。
- SubscribingWebSocket 这一层并进基类:三层继承变两层,只剩 CollabWebSocket 还在扩展
  (它确有自己的二进制缓冲与房间语义)。URL 拼接收进 channelUrl()。

行为一处没动:重连仍然不封顶(后端 deploy 重启一次就超过原来那 15 秒,这条注释保留)、
指数退避带抖动、30 秒上限、心跳 30 秒、force_logout、断线重连补订阅全部照旧。

## 验证

起全栈用浏览器实跑:

- 提交一发 Python3,/ws/submissions 连上并推回 judging → judging → finished,
  控制台没有出现「WebSocket未及时响应,启动轮询保底」,即结果确实走的 WS 而非轮询;
- kill 掉后端观察三条通道重连:682ms → 1.4s → 2.4s → 4.4s → …… → 15~26s 封顶,
  指数退避与 30 秒上限均符合;
- 后端起回来后**不刷新页面**自动恢复(/ws/config、/ws/collab 各自重连成功),
  再提交一发仍然全程走 WS —— 这正是「重连不封顶」要保的场景;
- vue-tsc --noEmit exit 0;vite build 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:25:47 -06:00
ab47e71d6f refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Some checks failed
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies

出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:

- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
  的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
  z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
  submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
  problem;列表为空时才碰巧不炸,所以一直没人报。

两个都是读出侧校验自己造出来的故障,不是它拦住的故障。

## 校验责任挪回写入侧

- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
  .language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
  .sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
  tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
  problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
  不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
  problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
  difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
  会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
  在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
  报文是真边界,失败返回 null 而不是 500。

顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。

## 文档

CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。

## 验证

- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
  数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
  前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
  建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
  (error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
  后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
  problemDisplayId 收到 undefined,改动后同样两个响应均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:14:05 -06:00
b9a80d62bc docs(CLAUDE.md): 合并顶部叠着的更正块,订正前端验证方式,补上契约收紧的边界
## 顶部

两层引用块套着一段「2026-09-10 更正」,说的是同一件事的两个版本(7 张表已删 →
其实还剩一张)。合成一段现状:旧栈不可逆下线、唯一退路是备份恢复、漏网那张
django_migrations 由 0014 补删。考古过程留在迁移文件的注释里,这里不重复。27 行 → 14 行。

## 常用检查

`cd apps/web && bun run build` 后面那句「vite 不做类型检查,构建即验证」是错的:
vite 确实不做类型检查,但**构建也不是验证**。补上 `bun run type-check`,并写明两条
会静默通过的假路子 —— `vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个
tsconfig 是 files: [] + references 的壳,0.2 秒跑完就是信号)、`vite build` 不看类型。
后端也改成 `bun run --filter '@oj2/api' typecheck`(脚本本来就有)。

## 新增「契约收紧要挑地方」

前一个 commit 的教训值得留在这儿:契约 schema 后端也在读路径上 parse,收紧字段
等于给历史数据加闸,对不上要么 500(exerciseSchema)、要么静默塌成 {}(info,
9163/124192 条)。JSONB 原文的形状真相在写入侧,闸就设在那里;要收紧先拿生产备份
跑全量,重点看空值不是键集合。AST 规则的 astRulesError() 本来就是同一个道理。

## apps/web/CLAUDE.md

Commands 段还是 ojnext 时代的 npm start / npm fmt,全部换成 bun 并补上 type-check
的坑。Module Pattern 写的 `views/` 这一层实际不存在(页面组件直接放模块根下),
api.ts 也不按模块分(学生端 oj/api.ts、后台 admin/api.ts、跨端 shared/api.ts)。

工作区根目录的 CLAUDE.md / AGENTS.md 同样折叠了那段更正、订正了类型检查命令
(那两份不在 git 里)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 04:57:34 -06:00
7d15e6aeaa refactor(契约): 判题产物退回不校验,练一练的形状闸挪到写入侧,运行时闸门收回三处
前四轮把契约当成运行时闸门铺开,复盘下来三块里只有一块是赚的:类型收拢成一份
(语言联合、Problem/Message/ContestRank 的重复派生)留着;另外两块退回来。

## 判题产物:读出侧不再校验

judgeCaseResultSchema 按采样键集收紧的结果,用根目录那份生产备份全量跑了一遍:
124192 条提交里 9163 条对不上,**RE 8480/8480、TLE 338/338+26、MLE 1/1 全中**,
另有 270 条 WA、47 条 AC。原因不是键集合,是空值和 SQL 链路:

- 沙箱在非正常退出的测试点上写 `output_md5: null`,契约写的是 z.string();
- SQL 判题(judge/sql/engine.ts 的 CaseResult)根本没有 `output` 键;
- SQL 通过的测试点 `error_message` 是 null,契约写的是 z.string().optional()。

更糟的是失败方式:`info` 是 `union([完整形状, z.object({})])`,对不上的一律落进
第二支被剥成 `{}` 且 parse 成功 —— 管理员详情页的测试点表格**静默消失**,无日志。

JSONB 的形状真相在写入侧(判题机),读出侧再校验一遍只会在两边分叉时丢数据。
所以 `info` 回到 z.unknown(),形状改用 JudgeInfo / JudgeCaseResult 两个 TS 类型
描述(按判题机实际写的形状,不是采样出来的),取值处由 submissionCaseResults()
做唯一需要的运行时判断:有没有 data 数组。statisticInfo 换成 looseObject ——
所有键可选、不剥未知键,对任何对象都不会失败,它的作用是给类型不是当闸门。

## 练一练:形状闸从读路径挪到写路径

exerciseSchema 的 superRefine 挂在读路径上,而这个 schema 后端也在 parse
(routes/content.ts),等于一行脏数据就能让整条学生练习列表 500。同时写入侧的
exerciseDataError **一次都没查过 question**,两边严紧度不一致,脏数据进得来出不去。

exerciseDataByType 保留,改由 exerciseDataError 在写入前查,错误信息按字段翻成
中文给老师看;读路径回到不校验。

## 运行时闸门收回三处

contract() 从 41 个端点收回到题目详情 / 提交详情 / 用户资料 —— 原本就写了
.parse() 的那三条。留着的理由是「别抛错」(原来 parse 抛 ZodError 会白屏、
后面的 as 又让校验白做),不是校验:前后端同仓、共享同一份 schema,字段漂移
tsc 已经抓了。闸门本身也瘦掉了没人读的 window.__OJ2_CONTRACT_DRIFT__ 那套簿记。

## 验证

- 生产备份全量:124192 条提交过 submissionDetailSchema / submissionListItemSchema
  零失败,其中 112144 条能拿到测试点明细(另外 12048 条本来就是 data:null);
  151 道练习读路径 151/151、写入闸 151/151(老师改旧题不会被新闸挡);
- 反向验证写入闸:缺题干的排序题被拒并给出「题干的格式不对」;
- 本地实跑:种一条生产形状的 RE 提交(output_md5: null),管理员详情接口原样
  返回 info.data(改之前是 {});库里塞一行没有 options 的 mcq,学生端练习列表
  照常返回两条而不是 500;
- vue-tsc / tsc -p apps/api 均 exit 0,vite build 通过,check:routes 无遮蔽。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 04:53:28 -06:00
684f2d29a5 feat(契约): 练一练的内容按题型收进契约,7 个题型组件不再裸读 data
Some checks failed
Deploy / deploy (push) Has been cancelled
`Exercise*.vue` 七个组件直接读 `data.question` / `data.options` / `data.lines`,
而契约里 `data` 是 `Record<string, unknown>`(后端不校验)、前端只做了类型收窄
—— 也就是说这条路径**从来没有在运行时被看过一眼**,结构对不上时渲染期才炸。

现在 exerciseSchema 用 superRefine 按外层 `type` 分派到对应的内容形状,
外层与 data 对不上也会被抓住(`type: "mcq"` 配 `{question, code}` 会在渲染
mcq 组件时炸在 `data.options`)。七个题型的键集按生产库实测确定。

**这个 schema 后端也在 parse**(routes/content.ts 的 `exerciseSchema.parse`),
所以收紧它同时是一道服务端闸门:坏数据会让练习列表 500。因此先用生产全量
数据核验:151/151 通过,且反向验证确认能抓住题型与 data 不匹配的情况。

`getExercises` 另加了一层逐题型校验,让前端也能把内层分歧记进
`__OJ2_CONTRACT_DRIFT__`;`z.infer` 拿不到判别联合(superRefine 无法把校验
结果反映到推断类型上),所以类型上仍需一次经过 unknown 的断言 —— 它不掩盖
未经检查的分歧,因为运行时那一步已经做过。

顺带补上 getFlowchartSubmission 的闸门;被守卫端点达到 41 个。

验证:vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;check:routes 无遮蔽。
2026-09-10 04:28:26 -06:00
5da661d7f0 refactor(契约): 补上 AI / 站内信 / 自学留痕等接口的闸门,并订正前端 CLAUDE.md 的过期内容
Some checks failed
Deploy / deploy (push) Has been cancelled
闸门再补 13 个端点:AI 系列(detail / solved / duration / heatmap /
login-summary / pinned)、站内信列表、教程列表与学习进度、题目逐年 AC、
流程图列表。学生端读接口至此基本覆盖(写操作与纯前端伪状态不接)。

文档订正(原文写着"后端是 ../OnlineJudge 的 Django 5"、"utils/http.ts"、
"utils/permissions.ts",这些都早已不存在):
- 项目概述改成 OJ2 前端,并点明要兼容机房老 Chrome;
- HTTP 客户端改成实际存在的 utils/api.ts;
- 新增「Contract guard」一节,把**失败策略**(记日志 + 放行原始数据、不抛错)
  和排查入口(window.__OJ2_CONTRACT_DRIFT__)写清楚,并提醒同一个 schema
  后端也在 parse、收紧前要用生产数据核验;
- Related Repository 指向同仓的 ../api 与 packages/contract。

验证:vue-tsc 与 vite build 通过;13 个新端点对真实接口全部通过
(11 通过 / 0 失败 / 0 跳过)。
2026-09-10 04:25:24 -06:00
de34a47996 refactor(契约): 契约闸门铺到其余学生端接口,并清掉与契约等价的重复类型
Some checks failed
Deploy / deploy (push) Has been cancelled
## 闸门覆盖

从 7 条扩到 20 条:竞赛列表/详情/口令/榜单、题单列表/详情/题目/徽章/进度、
全服榜与活跃榜、公告列表/详情、教程、用户度量、流程图历史/当前/统计、
提交统计与统计明细、相似题目、题目 AC 榜。

## 顺手把两处形状收进契约

- `contestRankItemSchema.submissionInfo` 原来只是 `Record<string, unknown>`,
  前端被迫再声明一份 `SubmissionInfo` 去覆盖它。现在 JSONB 的 snake_case 形状
  (is_ac / ac_time / is_first_ac / error_number / checked)进了契约,那层覆盖随之消失。
- 竞赛题目列表用 `problemListItemSchema.array()` 而不是契约里的
  `contestProblemsSchema` —— 后者是 `array(union([列表项, 详情]))`,联合类型会让
  `filterResult` 的类型收窄落到详情分支,而且学生侧这条接口只下发列表项。

## 清掉两处确证重复的派生

用类型探针(双向可赋值)逐对验证,`Problem` 与 `Message` 的本地派生与契约**完全等价**,
是没有内容的重复:`Problem` 的 languages/template 收窄、`Message` 的 submission 收窄
都已经在前两轮搬进契约。其余七处派生(Submission 的 result|9、Profile、AdminProblem、
Exercise、ContestRank、BlankProblem、TutorialEdit 等)探针确认**不等价**,都有真实理由,
一律保留。

`Problem` 原来还挂着 hasAstRules / visible / answers 三个可选字段,探针证明它们对
赋值没有影响(契约里本来就有 hasAstRules),去掉后没有任何调用点报错。

## 验证

- vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;check:routes 无遮蔽;
- 契约 schema 直跑真实接口:学生会话 13 通过 / 0 失败,管理员会话 15 通过 / 0 失败;
  跳过的三条都是预期的权限或空数据(/submissions/statistics 与 /flowcharts/statistics
  要教师权限、/users/:id/metrics 只统计公开提交)。
2026-09-10 04:23:41 -06:00
aab0404ed7 refactor(契约): 语言与判题产物的形状收进契约,学生端高频响应接上运行时校验
Some checks failed
Deploy / deploy (push) Has been cancelled
契约在前端一直只当类型包用:45 处引用里几乎全是 import type,三个 .parse() 后面
还都紧跟一个 as 把校验结果断言回去,等于没校验。这一轮把形状的来源收拢。

## 语言:三份真相并成一份

前端 utils/types.ts 手写了一份 9 值的语言联合,后端 judge/languages.ts 有自己的一套,
生产库又有一套。手抄那份**漏了 SQL**,而生产库 961 道题里有 9 道 SQL 题、
124191 条提交里有 91 条 SQL 提交 —— 这些提交的 language 在前端类型上是 undefined。
现在唯一来源是契约的 problemLanguageSchema,constants.ts 的显示映射以它为键,
契约里加语言而那边没补映射会当场编译不过。

## 判题产物:按生产数据实测收紧

judgeInfoSchema / statisticInfoSchema 的形状来自 124191 条提交的实测,不是手抄:

- info.data 有 12048 条是 null(编译失败等没有逐测试点结果),前端手抄的 Info
  却把 data 写成非空数组 —— 这 12048 条在类型上根本不成立;
- info 还允许**空对象**:非管理员看提交详情时后端下发 info: {}(权限投影)。
  收紧时必须把它算进去,否则每条非管理员看的提交详情直接 500 —— 本地实测复现过;
- statistic_info 的五个键按出现次数定成全部可选;另有 8916 条 JSONB 原文因内嵌
  带转义的 shell 输出不是合法 JSON,被后端 objectValue() 兜成 { value: ... },
  所以不能用严格对象,否则这 8916 条会被误判成分歧。

## 运行时闸门

新增 utils/contract.ts:safeParse 失败时记一条分歧(去重、控制台可见、
window.__OJ2_CONTRACT_DRIFT__ 可查)后**放行原始数据**,不白屏 —— 面向学生的
生产站点,字段空着比整页崩掉可接受。接在 7 条高频链路上:/site、/site/online、
/problems、/problems/:id、/submissions、/submissions/:id、/me。

提交详情的 info 是联合类型,调用方不再直接取 .data,统一走
utils/functions.ts 的 submissionCaseResults()。

## 验证

- vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;
- 契约 schema 直跑真实接口:7/7 通过(自包含脚本,从登录到详情全链路);
- 用生产备份复核收紧后的约束:961 题的 languages 无越界值,template 只出现
  C/Python3 两个键 —— 不会因为这次收紧在生产上抛错。
2026-09-10 04:19:41 -06:00
a475cac128 fix(数据库): 生产库里还留着一张空的 django_migrations,补一条迁移删掉
Some checks failed
Deploy / deploy (push) Has been cancelled
0002_drop_django_leftovers 声称删掉了旧 Django 的 7 张框架表,但生产库实测有 29 张
public 表 = schema.ts 的 28 张 + 一张 0 行的 django_migrations:另外 6 张
(auth_group* / auth_permission / django_content_type / django_dramatiq_task /
django_session)确实都不在了,只有它残留下来。原因已无法从库里复原——0002 的记账行
在,说明当年执行过,而 DROP TABLE IF EXISTS 不会静默跳过它后面的语句。

全仓(二进制、路由、compose、脚本)零处读写这张表,表里 0 行,所以直接删掉。
用 IF EXISTS 让两条既有路径收敛到同一结构:空库自举时 0002 已经删过它(空转),
老生产库还留着(真正动手)。实测两条路径出来的 pg_dump --schema-only 逐字节一致。

「旧栈起不来」这个结论不变——它缺的是 django_session 等表,不是这一张。

迁移会被破坏性迁移闸拦下,这是有意的,放行方式记进了 CLAUDE.md。
2026-09-10 04:02:33 -06:00
78a42a42e7 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-10 03:44:32 -06:00
26b23aa7c0 fix(统计): 「提交记录」列出所有交过的人,不再只有做完的
Some checks failed
Deploy / deploy (push) Has been cancelled
统计面板那张表原来只给 `isDone` 为真的人,于是一次没对的学生连同他的提交
在面板里根本不存在——tab 却叫「提交记录」,看起来就像统计只认成功的提交。

- `data` 改成给窗口里交过东西的全部人,每行带 `done`;「完成人数」和完成度
  跟着 `done` 数,不是 `data.length`,两个数字口径不变。
- 表格加「完成」列区分两种人;展开行拉的仍是那个人的全部提交(items 接口
  本来就不按结果过滤),对错都在里面。
- 「交了没全对」那一栏原来无条件按花名册取,不填班级/用户名时花名册为空、
  整栏跟着空掉,这批人两栏都不在。改成没有花名册时退回「有提交但没做完的
  全部普通学生」,教师和禁用账号仍然排除;全站视图里保留完整用户名不剥班级
  前缀。「还没交」那一栏没有花名册是真算不出来,仍然为空。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZbWEaPCnGvmdfNFpPFNGb
2026-09-08 23:52:34 -06:00
6bef55904f fix(题目分析): 「卡住的题」只看公共题,别把比赛题也算进来
Some checks failed
Deploy / deploy (push) Has been cancelled
这条查询原来一个 where 都没有,比赛题一起进榜。隔壁 ac-trend 在同一个文件、同一块
面板,isNull(contestId) 写得明明白白,所以判定是漏写而不是口径选择。

比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题都叫「1」、61 道叫
「2」),一旦挤进前 40,那一行显示的题号会指向一道根本不存在的公共题。眼下还没
发生:前 40 的门槛是 97 人卡住,比赛题最多的一道是 59 人 —— 但两个班一起考的场次
有 95 人,撞上一道难题就够得着。

对公共题的数字没有任何影响:比赛提交挂的是比赛自己的 problem 行(快照实测两个方向
的交叉都是 0 条),公共题那一行本来就只统计自己的提交。实跑接口逐行比对,改前改后
前 40 集合完全一致,只有三处并列名次的先后不同 —— 那是这条查询本来就没有确定性
tiebreaker,并列的题在两次刷新之间也会自己换位置。

顺带能用上 0013 的 submission_public_metrics_idx:183ms / 18646 buffers →
80ms / 788。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 18:30:05 -06:00
80f3b21e95 perf(索引): 0012 删 21 个冗余索引,0013 加 4 个筛选/聚合索引
0012 —— 删的都是 Django 建的,列是某个复合索引的最左前缀,规划器本来就走那一个,
多出来的只是每次写入多维护一棵树:17 个前缀被覆盖的、3 个 _like(text_pattern_ops)
副本、1 个同表同列的完全重复(problemset_submission.user_id 上有两个)。
索引 107 → 86 个,32MB → 29MB。

删之前逐条确认过复合索引的第一列就是被删索引的那一列,删之后 19 条代表性查询的
执行计划逐条对过,没有一条退化成 Seq Scan,只是换了覆盖它的那个索引;级联删除会
用到的外键检查路径也都还有索引可走。

0013 —— 拿 auto_explain 把 60 多个读接口打一遍抓出来的真实慢查询,候选索引一个个
建出来实测:

- submission (language, create_time) WHERE contest_id IS NULL — 3.2MB
  语言筛选原来一个索引都没有,count 固定 75~82ms / 18448 buffers,筛什么值都一样。
  改后 Python3(占 8 成)80 → 11ms、C 77 → 1.6ms、SQL 82 → 0.06ms。更要命的是冷门
  语言翻页:Python2 只有 3 条全是 2022 年的,分页索引得从最新倒扫到底,43ms 全表扫
  → 0.02ms
- submission (result, create_time) WHERE contest_id IS NULL — 3.2MB
  count result=-1 75 → 2.0ms、result=-2 36 → 1.0ms
- submission (user_id, problem_id, result, create_time) WHERE contest_id IS NULL — 4.2MB
  覆盖索引,给「在全部公开提交上做聚合」那几个接口。它们慢的不是聚合本身,是为了读
  这四个小列把 145MB 的堆翻一遍(code 和 info 占了这张表绝大部分体积,聚合一列都用
  不上)。走 Index Only Scan 只读 6MB:教师统计全站 186 → 49ms(消掉 4.2MB 落盘
  排序)、活跃榜 108 → 18ms、AC 趋势 120 → 41ms
- flowchart_submission (create_time) — 64kB
  列表分页从 hash join 全表再 top-N 排序(4.5ms / 551 buffers)变成 0.19ms / 47

全列 ASC NULLS LAST 靠反向扫,理由同 submission_public_create_time_id_idx 那段注释。
动手前把三种写法在库上对了一遍:两列 ASC 和 create_time DESC NULLS FIRST 一样快,
写成 DESC NULLS LAST 规划器直接不认这条索引、回落到分页索引带 Filter,注释没说错。

回归检查:不带筛选的列表和深翻页仍然走 submission_public_create_time_id_idx,没被
新索引抢走。submission_result_37e2f67a 留着并补了注释 —— 它看着像被新的部分索引
覆盖了,但那条带 WHERE contest_id IS NULL,管不了全库含比赛按 result 统计(实测删掉
之后 count(*) where result in (6,7) 从走索引掉回 75ms 全表扫)。

两条迁移都在生产结构副本和本机 dev 库各跑过一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 18:29:53 -06:00
a5b57d8ab8 fix(判题): 任务被遗弃时给提交落一个终态,别永远停在「等待评分」
judgeSubmission 自己的 try/catch 已经把正常路径上的异常都接住、落成 SYSTEM_ERROR
了,但队列的 failed 事件只有一行 console.error。判题队列没配 attempts(flowchart
队列配了 3),失败即终局;worker 进程被杀那种 —— 机房断电、容器 OOM、部署重启 ——
BullMQ 走完 stalled 重入队还是没人接,最后 emit failed,那条提交就永远停在
「等待评分」:学生看着转圈,教师统计里还占着一个「判题中」的名额。

加 failAbandonedSubmission(),在 failed 里调用,复用已有的 markSystemError(只动
PENDING/JUDGING,判完的和重判过的都不会被覆盖)。三种情况实跑验过:不存在的提交
安全返回、卡住的那条变成 result=5 并写入 err_info、已 AC 的那条没被动。

生产库里 3 条卡死的 PENDING(2022-11 / 2026-03 / 2026-04)都是旧栈时代留下的,
OJ2 上没有实例 —— 这次是把口子堵上,不是修已发生的故障。那 3 行和跟着差 1 的三道题
计数器还得手工处理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 18:29:30 -06:00
64ad6139f5 fix(后台用户): 删账号前拦住还有提交的人,改名回填按 user_id
两处都是「提交与用户的归属」在写路径上没维护住。

删账号:处理器那段注释早就点到了 submission.user_id 没有外键、全连坐会留下孤儿行,
但只把它当成「不改成 CASCADE」的理由,没有加对应的检查。结果是报错文案里写着
「该用户还有提交、题目等历史数据」,而提交恰恰是唯一拦不住的那一类 —— 只交过题、
没拿过成就没进过题单没参加过比赛的学生照样删得掉。

生产快照实测复现:删 id=1454(李若菡,13 条提交)返回 200 deleted:1,用户没了、
13 条提交留在库里,孤儿总数从 935 涨到 948。那 935 条就是这么攒出来的(28 个已删
账号)。线上今天还有 5 个这样能删的学生。

补一次提交查询把它拦下来,和 delete 放同一个事务里免得中间正好交了一发。文案一个
字没改 —— 它本来就是对的,缺的是兑现它的代码。混批删除整批回滚,干净的那个也留着。

存量 935 条不动:四条读路径现在都用 leftJoin 兜住了,列表显示冻结的名字、不出死链,
统计按 user_id 聚合他们本来就不在花名册里。加外键得先清历史数据,收益只有「以后
不再产生」,而那一半这次已经解决了。

改名回填:条件从「等于旧用户名」改成按 user_id。前者只改得动当前正好还等于旧名的
行,一个已经漂移过的账号再改一次名,更早那批仍然改不动 —— 库里 726 条挂着旧名字的
提交就是旧栈时代这么留下的,之后每次改名都从它身边绕过去。实测拿 user 2039 验过:
user 表叫 ks248吴紫妍、13 条提交挂着 ks24数媒1班ks吴紫妍,老写法一行都匹配不上,
改成按 user_id 之后一次拉平。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 18:29:19 -06:00