Commit Graph

209 Commits

Author SHA1 Message Date
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
a89eed7bdd fix(提交统计): 按 user_id 归属提交,不再按提交时冻结的用户名
submission.username 是提交那一刻的快照。教师统计全程拿它当主键用 —— ilike 过滤、
group by、再拿结果去 user 表 join 班级 —— 学生改名之后旧提交还挂着旧名字,一条都
匹配不上。

生产快照实测(2026-09-08):24 级数媒两个班改成编号制用户名之后,查 ks249 旧口径
0 条、新口径 7 条,整个班 48 人全掉进「一条没交」;查 ks248 是 20 条 / 54 条,
13 个人的成绩查不出来。ks248林依晨那 8 条提交原来一条看不到,人在「一条没交」栏,
现在落到「交了没完成」并带出最后一次失败在 1082 题。

四条路径一起改:

- /submissions/statistics 的过滤、聚合、花名册全部改按 user_id,用户名从 user 表
  join 出来。已删号的学生 user 表里没有行,退回提交里冻结的名字(personCount
  那句兜底就是给这种情况的)
- /submissions/statistics/items 展开行先把用户名解析成 user_id
- /submissions 和 /contests/:id/submissions 的用户名筛选取并集:user_id 匹配到的
  账号,加上按冻结用户名匹配的(已删号的 28 个账号只剩这一份名字,顺带「按记得的
  旧名字搜」也还查得到)。列表显示的名字改成当前用户名,否则筛 ks248 出来一堆写着
  ks24数媒1班ks的行,看着像筛错了
- /rankings/activity 同样按 user_id 分组。这条眼下是预防性的:改过名又有 AC 记录的
  4 个人只有 1~2 题,够不到前 10,但真上榜会显示旧名字或被拆成两条

顺带把统计接口的四次全表扫换成索引扫:ilike 走不了索引,换成 user_id in (...)
之后单条 18448 → 537 buffers,整个接口查一个班 120~250ms → 10ms 上下。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 18:29:02 -06:00
9c5a1d551d perf(教师统计): 展开行的明细改成按需拉,统计响应不再背着 4.9 万行没人看的数据
Some checks failed
Deploy / deploy (push) Has been cancelled
上一版把明细收成「只取有 AC 的人、每人最近 50 条」,拿生产快照(12.4 万条提交)
实测下来只从 105631 行降到 49108 行 —— 2.1 倍,不是一个数量级。原因是绝大多数人
本来就不到 50 条,每人截断那道闸在真实分布上基本没咬到,最坏情况响应体仍有 ~2.4MB。

真正的问题是形状不对:表格一次只展开一行(updateExpandedRowKeys 只留最后一个 key),
却给 1900 个人各准备了一份。所以明细整个从统计响应里拿掉,改成展开时按需拉:

- 新端点 `GET /submissions/statistics/items`,要用户名 + 同一套时间窗和题号。
  用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 ks251 要圈出整个班,
  这边是「点开的这一行是谁」。上限 200 条,多取一条来判断 truncated,被截断时
  展开行里说明「只显示最近 200 条」,免得老师以为这人就交了这么多。
- 时间窗和题号抽成共用的 statisticsScope,两个接口必须同一个范围,否则展开行
  看到的是另一个窗口的数据。
- 前端按人缓存,收起再展开不重拉;每次重新统计(含 15 秒自动刷新)清缓存,
  并把当前展开着的那一行重拉一遍 —— 展开行跟着一起活着,不然刷新之后上面的数
  变了、下面的明细还是老的。
- 去重放在 loadItems 里:点一行会同时走 rowProps 的 onClick 和表格的
  update:expanded-row-keys,两边都想拉,改之前真发出了两条一模一样的请求。

顺带把 submissionItems 从 submissionStatisticsUserSchema 里删掉。

生产快照上的验证(12.4 万条提交 / 1956 用户 / 961 题,恢复进一次性容器跑完即删):

- 最坏情况(全部时段 + 不填条件)少搬 49108 行,约 2.4MB
- 真实课堂量级(最忙的一小时:583 条提交 / 81 人)四条查询分别是
  主聚合 45ms、明细 6.5ms、语法未过 2.4ms、最近错因 <1ms
- 「已解决」那条口径修正的实际影响:13.3% 的「人×题」有重复 AC,同一题最多 AC 45 次;
  按人看最夸张的是 419 条 AC 其实只有 38 道题
- 语法要求的题 15 道、result=10 共 57 条,其中「最后也没改对」的 18 个人×题
  —— 角标会出现,但稀有

浏览器实跑:展开前不发明细请求;展开后 1 条、12 个按钮;收起 +0、再展开 +0(走缓存);
自动刷新时统计与明细 1:1 配对、间隔 15 秒,没有重复请求。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 05:43:32 -06:00
f66bafafaf feat(教师统计): 提交统计面板返修 —— 统计口径、未完成分两栏、多题、自动刷新
排查这个面板时发现的一串问题和缺口,一起修掉。改动集中在
`GET /submissions/statistics` 和 `StatisticsPanel.vue`,两边互相咬着,
拆不成独立的 commit。

**「已解决」数的不是题数**(真在显示错数字)。`count(*) filter (accepted)`
按用户名分组、没有 distinct problem_id,同一道题重复 AC 会重复计数。查单道题时
看不出来,老师查「这节课全班」时「已解决 5」可能是同一道题交了 5 次。新增
`solvedCount = count(distinct problem_id) filter (accepted)`,表格那一列换成它;
`acceptedCount` 保留,正确率的分子仍然是提交条数。

**正确率把判题中的算进了分母**。PENDING / JUDGING 进分母不进分子,全班同时交卷的
那几秒正确率凭空掉一截 —— 而老师盯着看的就是这个数。分母改成判完的条数,
`UNJUDGED_RESULTS` 提到 judge/status.ts 共用。总提交仍是全部条数(交了就算交过,
否则人数口径会跟着变,正在判的学生会掉进「没交」名单),另外下发 `judgingCount`,
非零时面板多显示一块「判题中」,三个数字才对得上。

**「未完成」实际是「一次没交」**。做了但一次没对的学生既不在「完成人数」也不在
未完成名单,等于从屏幕上消失 —— 而那恰恰是最该去看一眼的人。新增
`dataAttempted`,未完成栏拆成「还没交」/「交了没对」两组,tab 计数是两者之和。
请假隐藏对两组同时生效:他们同样占着班级人数这个分母,只藏一半会把完成度算错。

**点名字能看到错在哪**。`dataAttempted` 带上最近一条提交的题号、状态和
`statistic_info.err_info`(截断 400 字),点名字弹出来,还能一步跳到代码。
老师不用再切到提交列表、翻到这个人、点开代码。

**支持一次查几道题**。题号框接受 `1001,1005,1010`(中英文逗号、分号、空格都当
分隔符,投影前手敲不该因为打了全角逗号就查不出来),有一个题号不存在就整体 404。
**完成 = 这几道全解决**;只填一道时和原来完全等价,不填题号时退回「至少做出一道」。
差一道的人落在「交了没全对」里,名字后面缀 `2/3题`。

**零提交时整块面板消失**。判空条件是 `count.total > 0`,可一节课刚开始一条提交都
没有、后端已经把整份花名册当作「未完成」返回了 —— 最该看名单的时刻反而只显示
「暂无数据」。流程图那边更彻底:`/flowcharts/statistics` 的零提交分支把
`dataUnaccepted` 写死成空数组,名单压根没下发。

**面板不会自己刷新**。打开是空的、要点一次按钮,拿到的是那一刻的快照。改成打开即查
+ 每 15 秒滚动重查,页面切到后台就停,关掉面板随组件卸载停掉。上一次没回来就跳过
这一次。班级从手打 `ks251` 换成下拉(选项来自网站配置的 class_list,和登录框同一份),
保留自由输入,查过的班级记进 localStorage 下次带上 —— 机房电脑一台对一个班。

**明细查询没有 LIMIT**。展开行用的 submissionItems 原来把窗口内**全部**提交捞进内存
再原样序列化,「全部时段 + 不填条件」就是十几万条。改成只取有 AC 的人、每人最近 50 条,
截断用窗口函数发生在数据库侧;表格「提交数」仍是真实总数。

**顺带**:`personRate` 前端从来没读过(完成度是前端按「减掉请假人数的分母」自己算的),
从契约里删掉;「语法未过」的题数单列出来(AST_CHECK_FAILED 全站仍然算通过,口径没动,
只是让老师看得见谁是绕过语法要求做出来的,那批人教学上没达标)。

实跑验证(dev 全栈 + 浏览器):

- 已解决:student 两条 AC 都在题号 5 上 —— 改前显示 2,改后显示 1
- 正确率:临时把两条改成 PENDING/JUDGING,12 条提交 2 条通过 —— 16.67% → 20%,
  面板多出「判题中 2」,表格显示「12(2 条判题中)」
- 未完成两栏:student2 没交、student 交了 12 次没对,请假隐藏 student 后
  班级人数 2→1、tab 2→1、出现「恢复 1 位」
- 错因弹层:点 student 弹出「最近一次:1020 · 编译失败 / Test case not found / 看代码」
- 多题:`1004` 完成 1 人;`1004,1005` 完成 0 人、交了没全对 student 1/2题 8次;
  全角逗号同上;`1004,9999` 报 `Problem 9999 does not exist`
- 自动刷新:打开即发请求,之后 04:44:22 → 04:44:36 → 04:44:51 → 04:45:06 每 15 秒
  一次且窗口跟着滚;关掉面板后 20 秒请求数不再增长
- 班级下拉:选「25计算机1班」→ ks251,重开面板自动带上并立刻查

tsc / vue-tsc / check:routes 全过。验证用的 dev 库改动已还原。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 05:20:51 -06:00
3a8f4fcd51 fix(后台用户): 管理员密码显示出来之后能再隐藏
Some checks failed
Deploy / deploy (push) Has been cancelled
原来只发 reveal 事件,露出去就没有回头路,只有翻页 / 搜索才会回到打码状态 ——
看一眼就该关掉,不然一整页管理员密码那么摊着。改成 toggle,打码与否只看
revealed 这一个 prop,按钮文案跟着走。

密码列顺带从 150 拓到 180:露出明文后面还跟着「隐藏」按钮,150 挤不下。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:59:54 -06:00
6a438872b9 feat(排行榜): 页头显示当前在线人数,在线绿点只给老师
Some checks failed
Deploy / deploy (push) Has been cancelled
新增匿名可读的 GET /api/site/online,一条 ZCOUNT 让 Redis 自己数,
不拉成员、也不写(清理过期成员留给后台列表,匿名接口不带写操作)。
榜单页进页面拉一次,为 0 时不显示。

/rankings/users 的 isOnline 是三态:null 表示「这个调用方不该知道」,
只有老师及以上拿到 true/false。写成普通 boolean 的话学生看到的 false
和真的离线分不开,等于默认把每个人的在线状态摊给全校同学看。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:36:12 -06:00
856b7a280e feat(后台用户): 新增「在线优先」排序,列表直接显示在线标记
在线状态库里没有、会话也判定不了 —— session 的 TTL 是 7 天且每次请求续期,
「有会话」只说明这人一周内来过。新开一个 Redis sorted set(auth/presence.ts)
记最后活动时间,5 分钟内有活动算在线。

写入全搭在已有的 pipeline 上,不多一趟往返:登录、每个带鉴权请求的续期、
以及 touchSession —— 只挂着 WebSocket 不发请求的人靠最后这条,sweepSessions
每 60 秒一轮,所以窗口取 5 分钟,明显大于那个间隔。登出、改密码、禁用账号
会立刻把人摘掉;过期成员在后台读列表时顺手清理(整个 key 不能设 TTL,
ZADD 不重置 key 的 TTL,到期会把还在线的人一起抹掉)。

排序 orderBy=-online 先捞在线 id,SQL 里 case when 分两档,档内继续按
最近登录排;没人在线时那个 case 恒等于 1,直接省掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:30:45 -06:00
5222e012e1 fix(比赛): 修四处 —— 倒计时两倍速、排名不自动刷新、题目状态恒空、比赛隐藏后审核页取不到代码
查比赛功能时实跑出来的四个问题,都在这一条里修掉:

**倒计时两倍速**(store/contest.ts)。init() 里 setInterval 之前不清旧表,而
detail.vue 在「未开始 → 进行中」那一刻会再 init 一次(为了捞开赛后才拿得到的题),
于是两个 interval 一起给 now 加 1000。学生赛前挂着页面就会中招:一场 60 分钟的
比赛,真过了 30 分钟页面就显示「已结束」、倒计时归零,而服务端还在正常收提交。
ojnext 里就有,是原样搬过来的。

**排名页「开启自动刷新」开着但不刷新**(contest/pages/rank.vue)。useIntervalFn
传的是 immediate: false,而 watch(autoRefresh) 只在开关变化时才 resume ——
开关初值就是 true、进页面不产生变化,表从没启动过,得手动关一次再开。改成
watchEffect,由「开关 + 比赛进行中」共同驱动,顺带不再在赛后空转轮询。同样来自
ojnext。

**比赛题的 myStatus 恒为 null**(routes/contest.ts)。判题其实把状态记进了
user_profile 的 acm_problems_status.contest_problems,只是这两条路由硬编码下发
空值,于是题目页的「状态」列永远是「未做」,赛后也不恢复。旧后端在赛后/管理员
视角是给的,这是回归。不按赛中赛后分档:这是学生自己的判题结果,不泄露别人任何
信息(旧后端赛中不给,只是因为它整条路换了个 serializer)。

**比赛一隐藏,审核页的「查看代码」必 404**(services/contest.ts)。acm-helper
故意不卡 visible(赛后核查恰恰发生在比赛收起来之后),它调的比赛提交列表却卡着,
两边对不上。findVisibleContest 换成 findAccessibleContest:公开的谁都取得到,
隐藏的只有比赛管理员取得到,学生看隐藏比赛照旧 404。

实跑验证(dev 全栈,判题走临时 worker 绕开本机 token 不一致):

- 浏览器跨过开赛时刻挂着不动 —— 墙钟 20.0 秒,倒计时正好减 20 秒(原来会减 40)。
- 排名页停在「无数据」,另一账号提交一发 AC,5 秒内表格自己长出
  `1 student2 1/3 0:00:42`,没刷新页面。
- 学生 AC 后列表和详情都回 myStatus: 0,没做过的另一个学生仍是 null,匿名照旧 401。
- 比赛隐藏 + 已结束:出题人 detail / problems / rank / submissions / acm-helper
  全 200,学生这 5 条全 404,提交也 404,隐藏比赛不进公开列表。
- 排名记账口径未受影响:10 次提交(8 编译失败 + 2 AC)落库 submission_number=9、
  accepted_number=1、total_time=231=ac_time、is_first_ac=true。

tsc / vue-tsc / check:routes(175 条无遮蔽)均干净,测试数据已清库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:19:29 -06:00
5d60bb15bb perf(redis): 热路径合并往返,限流脚本改走 EVALSHA
Some checks failed
Deploy / deploy (push) Has been cancelled
审查 Redis 用法时找到的四处小账,都是确定性改动,语义不变:

- `getUserByToken` 里两条串行 EXPIRE 合成一次 pipeline。这是全后端最热的
  Redis 路径 —— 每个带鉴权的 HTTP 请求都要续一次会话和反向索引。上次给
  `touchSession` 修的正是同一个形状,这处漏了,两边现在一致。
- `createSession` 的 SET / SADD / EXPIRE 三趟并成一趟。登录是突发的,
  一个班同时登录时差别全压在这一下。
- 限流的 Lua 从 `redis.eval` 改成 `defineCommand`,稳态走 EVALSHA 只发
  40 字节 sha1,不再每次带上 937 字节的脚本全文;NOSCRIPT 由 ioredis
  自动回退成 EVAL 重新灌,Redis 重启和 SCRIPT FLUSH 都不用管。
- 删掉 websocket 订阅连接上重复的 error 监听 —— `withErrorLogging` 已经
  打过一遍且带连接名,留着只会把同一条错误打两份。

实跑验证(dev 栈,API 跑在 3999):登录后 `session:*` 多一条、
`user-sessions:1` 的 scard 和 ttl(604800) 都对;把两个键的 TTL 压到 100
再打一次带鉴权的请求,两个都回到 604800。限流侧 `info commandstats` 显示
evalsha calls=3/failed=1 + eval calls=2 —— 失败那次正是 SCRIPT FLUSH 之后
的 NOSCRIPT 回退;令牌桶数值与旧实现逐位一致(10 个初始额度扣 3 剩 7,
再要 20 个被拒并返回 wait=433.33 = (20-7)/0.03)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 18:44:59 -06:00
57bc652629 perf(数据库): user 表补两个索引,班级过滤和活跃人数统计不再全表扫
`user` 原来只有主键和 username 唯一约束两个索引 —— 它是 drizzle-kit pull 从
Django 建的表拉出来的,Django 那边也没建过别的。于是两条常走的查询都是全表扫:

- `problems/:id/beat-count` 里「近两年登录过的活跃人数」按 is_disabled + last_login
  过滤,每打开一次题目详情算一遍;
- `routes/classroom.ts` 的 loadClassUsers 按 class_name(或年级前缀 like '241%')
  取学生,班级榜、班级对比、AI 学情的排名 scope 都走它。

两千行的表现在扫起来确实不贵,但这是随人数线性涨的那类成本,而且比在应用层加缓存
更根本:不引入陈旧,也不需要考虑失效。

`user_active_idx` 的列序是 (is_disabled, last_login):等值条件在前、范围条件在后。

迁移只有两条 CREATE INDEX,无附带改动;本机 db:migrate 跑过,两个索引都在。
索引效果没法在本机验 —— dev 库只有 12 条提交、几十个用户,规划器无论如何都走
seq scan,要到生产(12 万提交)才看得出来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:42:46 -06:00
ec1509c46d perf(限流): 桶参数加 60 秒进程内缓存;Redis 连接补 error 监听
getBucketConfig 每次都查一次 `throttling` 配置项,而限流点在提交判题、AI 分析、
流程图评分上(5 处调用)—— 判题高峰期等于每条提交多一趟数据库,只为读一个几乎
从不变的值。上一代在 options/options.py 的 my_property 里也是带 TTL 缓存的,
重写时漏掉了。

缓存放进程内而不是 Redis:每站只有一个 api 进程服务读请求(oj-api 单容器、
Bun.serve 没有 reusePort、worker 只消费队列),进程内 Map 就等于全站缓存,
放 Redis 只是多一趟网络加一次序列化。异常分支特意不写缓存 —— 数据库抖一下不该
让接下来一整分钟全站都按默认参数限流。`throttling` 没有后台界面、只能直接改库,
改完最多一分钟后生效。

顺带给三条 Redis 连接都挂上 error 监听。ioredis 对没有监听者的 error 走
silentEmit:不崩进程,但把连接错误直接 console.error 到 stderr,绕开这里的日志,
而且不说是哪条连接 —— 这个进程同时开着会话读写、两条队列、一条订阅,「哪条」正是
要先知道的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:41:09 -06:00
22a7700b89 fix(会话): touchSession 续期时一并续反向索引,否则会话吊销不掉
WebSocket 巡检走的 touchSession 只 EXPIRE `session:<token>`,不碰
`user-sessions:<uid>`。而这条路径存在的理由恰恰是「只开着页面挂 WebSocket、一次
HTTP 请求都不发的人」—— 这种连接碰不到 getUserByToken 里那两条并排的 expire。

于是索引先到期、会话却被巡检一直续着。之后改密码 / 禁用账号走 revokeUserSessions
就 SMEMBERS 不到这张 token:WebSocket 那边还有 publishSessionRevoked 按 userId
兜底能断掉,但 HTTP 一侧拿着那张 cookie 照用不误 —— 而改密码要的恰恰是让 HTTP
立刻失效(学生密码是明文存着给老师查的,改密码是密码泄露后唯一的补救手段)。

签名加一个 userId,两条 EXPIRE 走一次 pipeline,仍然只有一趟往返,原来「比
GET + EXPIRE 少一趟」的理由保住了。三个调用点都有现成的 ws.data.userId。

返回值只看会话那条:反向索引是 498fc1c 才加的,在那之前签发的会话本来就没有索引
键,续不到是正常的,不能因此判定会话已死。

实跑验过四种情况:正常会话两边都续到 7 天;无索引键的存量会话仍判活;会话已删返回
false;空 token 返回 false。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:40:58 -06:00
25b86ec17e refactor(提交): 删掉从来没用起来的「提交互相可见」
Some checks failed
Deploy / deploy (push) Has been cancelled
problem.share_submission(题目级)和 submission.shared(单条)两个开关,连同
判定分支一起删掉。

生产备份(2026-08-07)里的实际用量:956 道题只有 2 道开过 share_submission,
还都是 contest_id=1 的比赛题 —— 比赛未结束时那条分支根本走不到,等于一天都没
生效过。123140 条提交里 shared=true 的有 40 条,39 条在 2022 年、1 条在
2023-03-30,之后近三年零使用;出题页从来没给过题目级开关,单条分享的入口在更早
那版前端上,ojnext 和 OJ2 都没搬过来,OJ2 里 PUT /submissions/:id 一个调用方
都没有。

canViewSubmission 剩下三条:本人 / 管理员(比赛中的 Student Admin 除外)/ 本题
作者,其余一律看不到。结尾的 `shareSubmission || shared` 没了;它上面那条「比赛
未结束一律不给」也一并去掉 —— 走到那里的必然不是这三种人,现在无论比赛与否都是
false,留着是重复的。allowShared 参数随之消失,它唯一的用途就是分享开关的归属
校验。

一并删掉:PUT /submissions/:id、响应里的 shared / canUnshare(列表、详情、站内
信内嵌三处)、题目详情与后台题目里的 shareSubmission、shareSubmissionRequestSchema,
以及新建提交时那句 shared: false(列上本来就有 default false)。

两个数据库列保留不删,只在 schema.ts 上注明已停用:删列是破坏性迁移,而留着不读
不写零成本,还留着那 40 条的历史痕迹。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:18:41 -06:00
2256597838 fix(后台建题): 修好 dev 代理走错服务,以及新建页带出上一题 id、超长标签报英文
三处不相干的小毛病,都在「新建/编辑题目」这条路上。

vite 的 /api 代理写的是 localhost:3000。api 用 Bun.serve 起、只绑 IPv4 的
0.0.0.0:3000,而 Node 解析 localhost 时 ::1 排在前面 —— 别的项目的 dev server
一占 [::1]:3000,两边端口就不冲突(一个 v4 一个 v6,谁都不报错),浏览器发出的
/api/* 整个落到那个站上,表现是后台一进就被弹回首页。改成 127.0.0.1。

新建页和编辑页共用同一个 localStorage 草稿键,而编辑页会把服务器数据连 id 一起
写进去。没保存就离开的话,这个 id 跟着草稿漂到新建页:「下载测试点」下的是那道旧
题的包,SQL 测试点编辑器也会去回显那道旧题的脚本。新建分支进来先把 id 摘掉。

新标签没有长度限制,超过 32 字要到保存时才撞出 zod 的英文原文(Too big:
expected string to have <=32 characters)。把 32 提成契约里的
PROBLEM_TAG_MAX_LENGTH,输入框拿它做 maxlength,validateNewTags 再兜一道粘贴
绕过的情况,提示换成中文。校验的松紧没动,32 字仍然存得进、33 字仍然拦下。

editContestProblem 和 editProblem 函数体逐字相同(都是 PUT admin/problems/:id,
比赛由后端从题目自己推导),合成一个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:18:23 -06:00
d1d071ff7b fix(后台): 测试用例生成器允许无输入的测试点
Some checks failed
Deploy / deploy (push) Has been cancelled
「这行算不算数」原来判的是 `f.in.trim()`,把「这题没有输入」和「这行我还没
填」撞成了同一种状态。打印类题目(输出星号矩形、只靠 print 的格式化输出题)
输入留空 → 点「先运行」整行被删 → 一行不剩 → 「上传」永远是灰的,老师只能
往输入框里塞个「无」才存得下去,那两个字节就真的进了 stdin。8019 / 8020 /
8021 三道题的 1.in 就是这么变成 3 字节「无」的。

拆成两个判据:

- hasOutput —— 有输出且没报错,这一行才打进 zip,输入空不空无所谓;
- isFilled  —— 输入或输出任一非空,只用来在「先运行」里筛掉面板上的空占位行,
  全没填时留一行当无输入题跑。

canUpload 保留原来那条守卫:填了输入却没跑出输出的行仍然挡住上传。「先运行」
不再要求先填输入。

后端本来就收空的 .in(processTestCaseZip 无非空校验),已上线的 8007、P003
的 1.in 就是 0 字节,卡住的一直只有这个面板。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdMvixPo27sWFrUmk6ZStZ
2026-09-07 06:29:23 -06:00
ca29fcb554 feat(前端): 头像下拉里加「我的成就」入口
Some checks failed
Deploy / deploy (push) Has been cancelled
放在「我的主页」之后,跳 /achievement。以前只有用户主页那张成就卡片能进去,
菜单里没有直达。

图标用 award-medal-4 而不是奖杯:顶栏「排名」已经是 fluent-emoji:trophy,
同一屏两个奖杯容易混。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM2SWox3ke1NiVJCgWSvpQ
2026-09-07 05:34:31 -06:00
ab0d585939 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-07 03:56:56 -06:00
498fc1ceca fix(后台): 会话吊销、导入查重、练习题校验,以及一批后台页面的小毛病
Some checks failed
Deploy / deploy (push) Has been cancelled
后台代码审查后的一批修复。

后端:
- 改密码 / 重置密码 / 禁用账号现在真的把该用户所有设备的会话删掉。原来只
  publishSessionRevoked 广播断 WebSocket,HTTP 拿着旧 cookie 照样能用到会话
  自然过期 —— 给被盗用的账号改密码等于没改。为此在 Redis 里补了反向索引
  user-sessions:<id>(createSession 写入、登出和失效路径清理、跟着会话续期)。
- 导入用户补齐校验:邮箱走 z.email()、批内查重、库内查重,用户名和邮箱各报各的;
  用户名和邮箱都归一成小写,和登录的 lower(username) 比较口径对齐。以前导入这条
  路什么都不查,而前端占位邮箱按「班级+批内序号」拼,同一个班导第二批必然重号,
  那两个账号从此在后台保存一次就撞 409、再也改不动。前端生成的占位邮箱同步加了
  每批随机后缀。
- PUT /users/:id 的邮箱查重改比 lower(email),存量大小写混着的数据也能拦住。
- 删用户的裸 catch 收窄成只认外键冲突 23503(顺 cause 链找,drizzle 0.45 把驱动
  错误包了一层),别的错照常抛 500,不再把连接故障说成「该用户还有历史数据」。
- 练习题 data 补语义校验(services/exercise.ts):没有 {{空位}} 的填空题、空选项的
  选择题、越界的下标等一律拒收。以前后端零校验,坏数据只有学生端会撞到。
- PUT /judge-servers/:id 改用 queryInteger,非数字 id 回 404 而不是 500。
- 比赛克隆不加归属校验是**有意的**(快速再开一场以前的比赛;保密边界在师生之间不在
  教师之间),把这条政策和它的副作用写进注释,免得反复被当成漏洞。

前端:
- 编辑用户弹窗的「班级」输入框改成只读 —— 它一直是个改了没用的控件,班级由后端从
  用户名推导。
- 新建用户预填唯一占位邮箱、角色默认改成实际会建出来的 Regular User、密码留空直接拦。
- 比赛题目列表的列过滤写的是 top_reaction,实际 key 是 topReaction,空列一直没被滤掉。
- AI 生成流程图加 try/finally,接口失败不再把按钮卡在 loading。
- 单个判题机删除后刷新表格;后台首页显示在线判题机数量(后端一直在下发)。
- 下载测试点失败时读 Blob 里的错误信封弹提示,不再毫无反应。
- 练习题编辑器补上和后端一致的前置校验。

验证:tsc / vue-tsc / vite build / check:routes 全过;后端每条改动都在本机起服务
实跑确认(会话吊销、导入各种重复、练习题七种题型、外键 409、非数字 id)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv
2026-09-06 08:41:54 -06:00