Commit Graph

230 Commits

Author SHA1 Message Date
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
fdfb064d0c fix(备份): 磁盘余量检查在老 mawk 上崩掉
Some checks failed
Deploy / deploy (push) Has been cancelled
服务器上跑 backup-db.sh 报:

    docker/backup-db.sh: 第 132 行:[: 4.29685e+10: 需要整数表达式

`df -Pk | awk 'NR==2 { print $4 * 1024 }'` 让 awk 做了那个乘法。**mawk 1.3.3**
(老 Debian 的 /usr/bin/awk)打印超过 2^31 的整数时会退回 OFMT(%.6g),42968547328
就成了 `4.29685e+10`,丢回 [ ] 比大小当场退出。用 debian:buster-slim 复现出了
一模一样的字符串;mawk 1.3.4(现在的 stable)和 busybox awk 都正常打整数,
我本机是 gawk,所以测不出来。

改成原样取 df 的第 4 列(KB),乘 1024 放到 bash 的 64 位算术里做,谁的 awk 都一样。
另外加一层 digits_or_zero:df 或 psql 返回的东西不是纯数字就当「读不出来」,
**跳过余量检查照常备份** —— 余量检查是提醒,不该因为读不到它就不备份了。
(顺带把「磁盘剩 0.0 B」那句误导的输出并进警告里。)

clear-sessions.sh 里那句 awk 求和也过了一遍:Redis 键数不可能到 2^31,不受影响。

验证:正常路径照旧;把 free_kb 强行喂成 `4.29685e+10`,现在是一条警告加一次
完整备份,不再退出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 05:26:09 -06:00
2bf0e7bfc1 ops(备份): 加 backup-db.sh,按容器名导出、校验完整性、带保留策略
Some checks failed
Deploy / deploy (push) Has been cancelled
原来手敲的那条

    docker compose exec -T oj-postgres pg_dumpall -c -U onlinejudge > db_backup_xxx.sql

在服务器上报 `service "oj-postgres" is not running`。容器明明在跑 —— 线上是外接
形态,postgres 由 /root/OJDeploy/docker-compose.yml 起,不归 OJ2 这套 compose 管;
compose.debian.yml 里那个 oj-postgres 挂着 `profiles: ["local-data"]`,只在自带
数据形态下启动。所以脚本直接按容器名 `docker exec`,跟谁起的无关,两种形态都能用。

比原来那条命令多做的事:

- **先写 .partial,验完才改名**。`> file.sql` 中途失败(容器挂了、盘满了、
  pg_dumpall 报错)会留个半截文件,看着像备份,等到要恢复那天才发现不是。
  中断也清掉。
- **验完整性**。gzip -t,再检查结尾有没有「PostgreSQL database cluster dump
  complete」,缺了就当失败。
- **umask 077 + chmod 600**。备份里有 user.raw_password(明文密码,本来就是留给
  老师查的)和角色口令散列,不该落成 644。
- **自检真连一次库**(psql -c 'select 1' 而不是 pg_isready)。后者用户名写错照样
  说 OK,要到 pg_dumpall 才炸出一句 role does not exist。
- **默认存到仓库外面**。deploy.sh 头部那条 rsync 带 --delete,备份放仓库里下次
  部署就没了;真放进去了会警告。
- **保留策略带兜底**。删超期的,但最新 3 份永远保留 —— 时钟错乱或者 --keep-days
  手滑填 0,都不该把手头唯一的备份删掉。
- **磁盘余量检查**。剩余空间比库还小就拒绝,--force 才继续。备份把生产盘写满比
  没备份更糟。

默认 gzip,--plain 关掉。头部写了 cron 的写法,以及恢复时那几条
`does not exist` / `already exists` 是 pg_dumpall -c 的正常噪音。

本机 dev 库(245 MiB)实跑:正常路径 38.9 MiB gz / 2 秒;容器名写错、用户名写错都在
自检拦住;导出中途失败后 .partial 被清掉;保留策略造 5 份 30 天前的 → 删 5 留 3,
把全部文件做旧 → 仍然保住最新 3 份。**恢复也真跑了**:起一个干净的 postgres:16-alpine
灌进去,submission=12 / user=11 / problem=20 / 28 张表,和原库一致。

只管数据库。判题测试点在 data/backend/test_case,不在库里,那份还没有备份手段。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 05:21:13 -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
ddc3f05bc3 fix(运维脚本): clear-sessions.sh 补上 dash 垫片
Some checks failed
Deploy / deploy (push) Has been cancelled
`sh docker/clear-sessions.sh` 在 Debian 上会用 dash 跑(/bin/sh 就是它),
第 34 行的 `set -euo pipefail` 里 pipefail 是 bash 专有的,脚本一上来就死在
`Illegal option -o pipefail`,正事一件没干。deploy.sh 里早就有同一道垫片,
写这个脚本时漏抄了。

实跑验证(dev 栈,`sh docker/clear-sessions.sh`):垫片生效后跑到容器探活
那步正常报错;`CONTAINER=oj2-redis YES=1 sh docker/clear-sessions.sh` 全程
走通 —— session:* 删 101 个、user-sessions:* 删 4 个、bull:* 前后都是 57 个
(dbsize 162 → 57,判题队列一个键没动)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 18:49:24 -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
01d7924faa ops(会话): 加一次性脚本清掉全部会话,闭掉存量会话吊销不掉的空窗
反向索引 user-sessions:<uid> 是 498fc1c 才加的,在那之前签发的会话不在索引里,
revokeUserSessions 靠 SMEMBERS 找不到它们 —— 改密码、重置密码、禁用账号对这批会话
统统无效,只能等最长一个 SESSION_TTL_SECONDS(默认 7 天)自然过期。学生密码是明文
存着给老师查的,改密码正是密码泄露后唯一的补救手段,这个空窗不留。代价是所有人重新
登录一次。跑过一次就不用再跑,此后签发的会话都带索引。

**两个站点都要跑。** 机房和服务器共用一个数据库,但各有各的 Redis,会话存在各自的
Redis 里,只清一边等于只解决一半。脚本结尾会提醒这件事。

只删 session:* 和 user-sessions:*,不用 FLUSHALL:同一个 Redis 里还装着 BullMQ 的
判题队列,清掉等于把还在队列里的提交全丢了,那些 submission 会永远停在 PENDING。
删完会核对 bull:* 的键数没变,变了就报错让人工检查。

扫描用 SCAN 不用 KEYS(KEYS 会阻塞住整个 Redis,而这上面还挂着所有人的会话读写),
xargs 分批 DEL 避免顶到命令行长度上限。

拿一次性容器验过:1200 条 session + 50 条索引全删(跨过分批边界),30 个 bull 键和
throttling 桶原样不动;空库上重跑是幂等的;容器名写错时报错退出不做任何事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:45:47 -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
bb0f5ec5ef fix(AI提示): 解锁条件前后端对齐,提示不再一点就没
Some checks failed
Deploy / deploy (push) Has been cancelled
「失败 3 次解锁 AI 提示」实际是「在当前这次页面会话里再失败 3 次」:
problemStore.failCount 是个从 0 起数的内存计数器,刷新、切题、跳进跳出就归零,
而后端闸门数的是数据库里的历史失败数,两边根本不是一回事。昨天在这题上撞了
十次墙的学生今天进来照样看不到按钮。

- 数法收成一个 countFailedSubmissions(),题目详情的 myFailedCount 和
  POST /ai/hint 共用。原来详情把「等待/正在评分」也算失败,连点三次提交就能
  把按钮点亮,点下去却回 hint-locked
- 阈值 3 挪进契约 HINT_MIN_FAILURES,两端引用同一个常量
- failCount 改成 myFailedCount + 本次会话增量;在题目页里登录的补拉一次详情,
  否则停在匿名时的 0
- 结果面板改 display-directive="show",不再一收起来就把流式输出中的提示连同
  那次 LLM 调用一起作废;补「上次结果」按钮,原来唯一的重开方式是再提交一次
- prompt 里的判题结果翻成中文,原来拼的是裸状态码,模型不知道 -1 是什么
- system_error 不计入失败数、也不显示按钮:判题机自己崩了不是学生的问题
- 比赛中不给提示,和「求助」按钮同一个口径

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv
2026-09-06 07:24:26 -06:00
2e78b6efdf fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-03 22:18:18 -06:00
583ac2cee5 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-03 22:13:38 -06:00
ba2a47836e update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-03 22:10:39 -06:00
b130d805be fix(后台题单): 修掉一串前后端字段名对不上导致的空列与失败操作
Some checks failed
Deploy / deploy (push) Has been cancelled
后台题单接口下发的是扁平结构(displayId / title / isRequired),前端却按前台
题单页的嵌套结构取值,naive-ui 的 key 也不解析点号路径,于是「题目ID」「题目
标题」两列永远是空的。同一处还连累了另外几个操作:

- 编辑题目弹窗读 problem.problem.title,点「编辑」直接抛错;
- 添加题目发 problem_id / is_required,api 层取 problemId,出站是 undefined,
  被后端按参数错误 400 掉;
- 编辑题目发 is_required,「必做」怎么改都不生效;
- 新建/编辑奖章发 condition_type,toBadgeBody 读 conditionType,同样 400。

根因是 admin/api.ts 把这批后台接口的返回值标成了 oj 侧的 ProblemSet /
ProblemSetBadge / ProblemSetProblem,加上 detail.vue 四个 handler 用 data: any
接弹窗数据,字段拼错编译器一句都不吭。现在返回值全部换成 Admin* 契约类型,
handler 换成 AddProblemToSetRequest / UpdateProblemInSetRequest 和一个本地
BadgeFormData(条件类型是「完成所有题目」时不带 conditionValue,后端补 0),
两个奖章弹窗里的 const data: any 也一并拆掉。

验证:dev 栈实跑,列表两列有值、编辑弹窗正常打开、加题 / 改必做 / 建奖章 /
改奖章四条链路走完界面都成功落库;bun run type-check 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WmMi9tPrnSAwwAy2i4WAa
2026-09-03 20:49:57 -06:00
1729b9db4d fix(题目列表): 排序下拉框不再多出一根滚动条
Some checks failed
Deploy / deploy (push) Has been cancelled
原来那行 :dropdown-style 是无效的 —— NSelect 没有这个 prop(只有
menu-props),写了会当普通属性掉到根节点上。真正卡高度的是 naive-ui
InternalSelectMenu 主题里的 height: calc(var(--n-option-height) * 7.6),
作用在菜单内部的 .n-scrollbar 上。8 条排序项 280px 超出 258.4px 半条,
逼出一根只能滚 22px 的滚动条。

改走 theme-overrides,按选项条数算高度,以后加减排序项自动跟着走。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy3L9zJHiSNFyNC3ZbMwdj
2026-09-03 20:13:01 -06:00
37861800e0 fix(题目): 恢复 AC 后弹出点评轮盘
Some checks failed
Deploy / deploy (push) Has been cancelled
ec27441 把触发逻辑整段删了,只留下 n-modal 和 ProblemReaction 本体,
commentPanel 从此没有任何一处置 true,弹窗成了死代码。库里的评价全部
停在删除当天,学生只能自己点进「题目点评」标签页才评得了。

按原样恢复:AC 后延迟 1500ms 拉一次 getReaction,mine === null(这题
还没评过)才弹。比赛不打扰;题单入口 1.5 秒后要跳回题单页,弹了也会
被路由冲掉,同样不弹。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy3L9zJHiSNFyNC3ZbMwdj
2026-09-03 20:05:44 -06:00
8a88fe8d99 feat(智能分析): 解题表格改服务端分页,/ai/detail 不再下发整份 solved
Some checks failed
Deploy / deploy (push) Has been cancelled
原来 /ai/detail 把区间内做出来的每道题连排名带等级整份下发,一个活跃学生一年几百道,
一次请求就是几百 KB,而表格一屏只看得到二十行。

拆成两支:
- GET /ai/detail 只留聚合(solvedCount、difficulty、tags、attempts、activity、errors…)
- GET /ai/solved?offset&limit 按首次通过时间升序分页给逐题明细

排名只跟当前这批题有关,所以分页那支只算一页的 problemIds,不必把整年算一遍。抽出
firstAcQuery / buildSolved / listSolved 三个函数,detail 和分页两边共用。

前端相应改动:
- 难度分布改读后端的 difficulty 聚合(本来就在下发,之前是从逐题列表里现数的)
- 几次做对改读新的 attempts 数组(每道题的尝试次数);tooltip 里的题名去掉了 ——
  明细是分页拿的,不该为了一个 tooltip 把全量拉回来
- Overview 用 solvedCount
- SolvedTable 的代码提交那张走 remote 分页,流程图那张仍是本地分页(一个 OJ 的
  流程图题就那么几道,全量下发没问题);换时间范围回到第一页
- 表格原来是 max-height 1500 的滚动区,现在由分页兜住,滚动条和分页器不再并存

/ai/analysis 的 prompt 仍然带逐题明细(少了它模型只剩聚合数字),但顺手卡了 200 条 ——
以前是整份 solved 无上限塞进去,题做得多的学生一次调用能顶好几倍 token。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
2026-09-03 03:18:18 -06:00
4d7be969d9 refactor(智能分析): 重排版面,砍掉重复的三张周期图,补三个新维度
原来 8 张图挤在两列大网格里,左列还套了一层两列小网格 —— 四张小图各自只有半页的
一半宽,"难度掌握情况"的标题被挤断成两行、周期图的 x 轴标签斜着叠在一起、一年热力图
53 列塞进半宽几乎看不清。改成单列为主,宽的图给全宽,窄的图两两并排。

砍掉的重复:进步曲线 / 提交效率 / 周期综合读的是同一个 durationData,半年视图统共
6 个桶四个字段,摊成三张卡六条曲线还都是双轴。合并成一张:柱是完成题目数和总提交数,
线是 AC 率,等级进 tooltip(S/A/B/C 四档离散值连成折线读不出东西,逐题等级表格里有)。

同期解题排名分布也砍了:五片扇形对十几道题做统计本来就是噪声,而排名逐题列在
SolvedTable 里,饼图没有增加任何信息。

换形式:
- 标签雷达图 → 横向条形。雷达对比较大小是最差的形式之一,原来还把值归一化成"占最多
  标签的百分比",第一名恒为 100%,等于只画了个排序。现在画真实题数。
- 难度掌握情况 → 难度分布。去掉叠在里面的 S/A/B/C 维度,3×4 十二个格子对一个两个月
  做十来道题的学生大部分恒为 0。

热力图改成一格一周(53 格,周一起算,最后一格是本周)。按天切的话一年 365 格里三百多
格是空的,中职学生一年也就二三十天有提交,整张图看着像没用过。颜色阈值跟着按周重定。

新增三个维度:
- 错在哪里:判完的失败提交按状态码分组。编译错误占大头说明语法不熟,答案错误占大头
  说明是逻辑问题,两种情况老师该给的建议完全不同。
- 几次做对:到首次通过为止提交了几次,分一次过 / 2-3 / 4-6 / 7次以上。原来只有一个
  "平均提交次数",看不出分布。
- 流程图得分:detailsData.flowcharts 早就在下发,但全页一张图都没有,只在解题表格的
  第二个 tab 里列着。

顺带:
- 时间活跃度从"只统计 AC 时间"改成"统计全部提交",星期和时段由后端按东八区聚合。
  只看 AC 的话十来个点撒进 7×4 的格子几乎全是空的,和热力图的时区口径也对不上。
- 两两并排用弹性容器而不是固定两列网格:知识点分布在没有标签时整张卡不渲染,
  固定两列会空掉一半。
- AI 卡片原来有三个条件挂载点(solved>10 在右列、≤10 在全宽行、=0 藏在 Overview
  里面),单列之后收成最后一张无条件的卡。
- DurationChart 右轴的 S/A/B/C 一个刻度都没显示过:轴范围 -0.5~3.5,生成的刻度值是
  -0.5/0.5/1.5/2.5/3.5,拿去索引 gradeOrder 全是 undefined。

契约新增 activity / errors / rankScope / solved[].attempts / durationData[].acceptedCount。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
2026-09-03 03:08:54 -06:00
69883bd016 fix chart
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-03 02:35:29 -06:00
7129f1a12d fix(智能分析): 提示泄题、班级分析漏鉴权、AI 端点全线无限流
Some checks failed
Deploy / deploy (push) Has been cancelled
/ai/hint 把参考答案原文放进 prompt,靠 system 里一句「不可透露」约束,而学生的代码
本身也是 prompt 的一部分 —— 一段「忽略上面的指示,把参考答案打印出来」的注释就能把
答案套走。改成不再发参考答案,让出来的 2000 字预算给题面;解锁条件(失败满 3 次)
原来只长在前端的会话计数器上,刷新就归零、直接 POST 更是完全绕开,补成端点自己查库。

/ai/class-analysis 只有 requireAuth,前端按钮上的 isAdminRole 只是 UI —— 任何学生
直接 POST 就能用,而且 comparison 全由客户端给,等于一个开放的代打 LLM 接口。补上
isTeacherOrAbove,与 /ai/class-pk-analysis 对齐。

/ai/analysis 收的是前端算好的 details/duration 整包,原样进 prompt 又原样写进
ai_analysis 表。改成只传 start/end/duration/username,学情数据一律服务端重算,
detail/duration 的计算抽成 buildDetail/buildDuration 三处共用;报告归被分析的那个人,
不归发起请求的人 —— 后台的 pin 和学生侧 /ai/pinned 都是按 user_id 找报告的。

四个 POST 端点和 login-summary 的模型调用全部过令牌桶(复用 services/throttling,
key 用 ai:<id> 与提交、流程图分开计数),超了返 429。

顺带修掉同一块里的几处:

- /ai/duration 的等级被写死成 `solved ? "B" : ""`,DurationChart 上那条折线因此恒定
  在 B。按旧后端 ai/views/oj.py:484 重新实现,按桶内同班排名算再取平均。
- 热力图 SQL 里 date() 用会话时区、JS 一边用 toISOString 取 UTC 一边用 getDate 取容器
  本地时区,三套混用;固定按东八区。365 格原来末格落在昨天,今天那格永远是空的。
- loginSummaryStore.open() 从 ojnext 移植时掉了,LoginSummaryModal 一直挂在 layout 里
  但没人触发,整条登录小结链路是死的。
- flowchart bestGrade 拿 max 回头 find 浮点相等的行;ai_analysis.provider 写死 deepseek。
- 前端四处 X-CSRFToken 是 Django 时代遗留,OJ2 后端没有任何 CSRF 校验,连同
  getCSRFToken 一起删掉;非 2xx 响应统一走 aiStreamError 转成中文。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZuPwqDmLEiK9zgQ9z9sVn
2026-09-03 02:13:59 -06:00
fafeebd281 feat(运维): 加 oj2-api recount,把反范式计数列对回 submission 表
Some checks failed
Deploy / deploy (push) Has been cancelled
problem.submission_number / accepted_number / statistic_info 和 user_profile 的
submission_number / accepted_number / acm_problems_status 由 judge/run.ts 的
persistResult 在判题时手工加减,上线至今没人事后核对过。

已知的漂移来源是重判:routes/submission.ts 的 rejudge 把 result 打回 PENDING
就重新入队,不回退任何计数,persistResult 随后再加一次。实测重判一条提交,
problem 的 submission_number、statistic_info 和 user_profile 的 submission_number
全部虚增,而提交一条没多。

默认只读预演,--apply 才写,落库后用同一份 computePlan 复核,还剩差异就非零退出。
口径逐条照抄 persistResult:题目侧连比赛提交一起算、用户侧只算非比赛;
accepted_number 是去重到题的首次通过;acm_problems_status 通过过就恒为 ACCEPTED,
没通过过取最后一次结果。acm_problems_status 里 problems / contest_problems 之外
的顶层键原样保留 —— 来历不明的数据不该被重算顺手抹掉。

不管的:acm_contest_rank(罚时与每题尝试次数口径复杂,单独一件事)、
achievement.unlock_count(0010 之后随成就级联,漂不了)、题单进度与奖章
(走 backfill-problemsets)。

--apply 要挑没人做题的时候跑:差异在事务外算、写的是绝对值,算完到写完之间判完
的那一笔加法会被覆盖;复核会把它报成「仍有 N 处差异」并以 1 退出,不会静默。

验证:dev 库先备份计数列,测完原样还原(差异数 0)。验过收敛(--apply 后再跑报
一致)、真实重判造成的漂移精确报出 3 处且无误报、人为删掉某学生已 AC 的格子能按
「曾经 AC → ACCEPTED」恢复、注入的未知顶层键完好保留。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
2026-09-02 22:58:01 -06:00
2c4d56b29a refactor(后端): 清掉 Django 遗留的死列与手工级联,角色字符串收成一份
三条迁移,一次部署(0008/0009 含 DROP COLUMN,需要 OJ2_ALLOW_DESTRUCTIVE=1):

- 0008 删 IP 相关:比赛 IP 白名单(前端本来就没有输入框,detail.vue 无条件置空)、
  submission.ip(前端从未显示过)、以及一次都没被调用过的 IP 限流桶。
  judge_server.ip 是运维数据,保留。
- 0009 删九个只有 Django 时代写过、OJ2 一次都没读过的列:user 的 auth_token /
  open_api / open_api_appkey / session_keys,user_profile 的 blog / github /
  school / major / language。open_api 后台连开关都没有,那段「已经开着就不重置
  appkey」的逻辑从上线起没进过 if。判据是「全仓零读取」而不是「看着没用」——
  raw_password 同样刺眼却是在用的,别一起清掉。
- 0010 给 17 条外键补上删除动作,不再是 Django 留下的一律 NO ACTION。父行消失后
  必然无意义、且不构成学生留痕的走 CASCADE(中间表、题单/教程/成就的组成部分、
  user_profile 与 user_stat);需要人看见的继续拦着——submission.problem_id、
  以及 user 的绝大多数外键,删用户撞外键会被 handler 翻译成「请改为禁用账号」,
  这是有意的:全 CASCADE 会静默抹掉成就与进度,而 submission.user_id 压根没有
  外键,结果是一半删一半留。六处手工级联随之删掉。

角色字符串收进 packages/contract/src/roles.ts:原先 ADMIN_ROLES / TEACHER_ROLES
在两个文件各抄一份、学生口径在四个文件各写一遍、前端 USER_TYPE 是第三份副本。
AuthUser.adminType 与 drizzle 的列都收窄成联合类型,二十多处 `=== "Super Admin"`
从此受编译器管着($type 是纯 TS 层的,generate 确认不产生任何 SQL 变更)。

顺带删掉 db/relations.ts —— drizzle-kit pull 的产物,全仓零引用。

一处行为变化:后台用户列表传非法的 ?type= 回 400,不再静默返回空列表;界面上的
下拉只有合法值,打不到这条。

验证:tsc / vue-tsc / vite build / check:routes 全过;三条迁移在 dev 库执行,
并逐条建 fixture 走 HTTP 接口验过删除连坐与拦截(题单五张子表连坐、user_badge
二级连坐、删有提交的题目仍 409、删有表情的用户仍 409)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
2026-09-02 22:57:45 -06:00
e624c62502 feat(提交): 列表标出来自题单的提交
Some checks failed
Deploy / deploy (push) Has been cancelled
submission 新增 problemset_id,学生从 /problemset/:id/problem/:pid 入口提交时
前端带上、后端落库,提交列表在题目后面挂一个「题单 xxx」的标签,点了进题单。

只是来源标记:题单进度和奖章仍由判完之后的 recordSolvedProblem 按「已加入且含
这道题的所有题单」记账,和从哪个入口进来无关,所以这个字段带错顶多标签不准,
不影响成绩。校验只确认这道题在那个题单里 —— 不查 visible / status、也不查有没有
加入,藏起来的题单里还困着已加入的学生;对不上就当没带,提交照收。

外键 ON DELETE SET NULL:删题单不该带走提交,清掉标记就行。索引建成部分索引
(WHERE problemset_id IS NOT NULL),绝大多数提交不来自题单,全列索引是给 12 万行
白建一遍;谓词能被 problemset_id = $1 蕴含,删题单时的外键检查也用得上它。

列表按页单独查一次题单标题,没有把 problemset join 进那条调过的深翻页查询。
比赛提交恒为 null:题单只收非比赛题。

历史回填从 problemset_submission 取「当年首次 AC 那条」,其余老提交无从判断入口,
一律留空。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7AsqceaUfC81k7UCDcSk2
2026-09-02 22:06:29 -06:00
cf1eea0810 fix(比赛): 学生管理员开卷、导出只剩十行、排行榜翻页错位等四处
Some checks failed
Deploy / deploy (push) Has been cancelled
## 学生管理员在比赛进行中能看所有人的代码

canViewSubmission 里 isAdminRole(user) 是无条件放行的捷径,而 ADMIN_ROLES 含
Student Admin —— 同时 contest.ts 的排行榜又把这个角色算作参赛者。既在榜上、
又能读别人的提交,就是开卷。比赛未结束(未开始 + 进行中)时不再吃这条捷径。

旧后端这里写的是 `not user.is_regular_user()`,学生管理员同样放行,所以这条是
相对旧栈**收紧**的一处,不是修回归。只掐角色捷径,不掐 problem.createdById ——
那是这道题的作者本人,他早就知道答案,挡他没有意义。老师和超管不受影响,他们不参赛。

实跑(造了一场进行中、一场已结束的比赛,各一条别人的提交):学生管理员在进行中的
比赛里列表 showLink=false、详情 404,比赛结束后恢复 200;超管两种状态都是 200;
学生管理员读**自己**比赛中的提交仍然 200,没有把他自己的记录一起挡掉。

同族的 flowchart.ts canView 有同样的角色捷径,但流程图提交入库时从不写 contest_id
(永远是 null),那条路上不存在「比赛中的别人的提交」,不用跟着改。

## 获奖名单导出,参赛超过 250 人时只导出十个人

rank.vue 用 `limit: total.value || 10000` 想一次拉全量,但 queryInteger 对超出
上限的值是**静默回落到默认的 10**,不是截断到 250。于是 300 人的比赛导出 10 行,
而一二三等奖的分档仍按真实总人数算 —— 老师拿到一份十个人、等级全错的名单,还不报错。
改成按 250 一页循环拉,两个出口都留:拿不满一页说明到底了,比对 total 是防着最后
一页正好整除。班级赛(≤250)碰不到这个坑,全校赛会。

实跑:造 20 条排名,limit=300 后端确实只返回 10 行,limit=250 返回 20,
新逻辑拉回 20。

## 排行榜翻页没有全序

只按 acceptedNumber desc, totalTime asc 排,同分同罚时行序不稳定,而这条列表是
limit/offset 翻页的 —— 同一个人可能在第 2 页出现两次,另一个人从此消失。末尾补
asc(id) 兜全序,id 不参与名次,只保证同分的人每次按同一顺序排。

实跑:20 个同 AC 数同罚时的选手,每页 5 条翻 4 页,共 20 行去重后仍是 20 人,
连翻两轮顺序完全一致。

## acm-helper 两个接口对 visible 的口径不一致

GET 要求 visible = true,PUT 不要求。比赛结束后收起来,核查页就打不开了,
而标记接口还能用。赛后核查恰恰常发生在比赛已经收起来之后,按 PUT 的口径放开。

## 删掉 contest_announcement

旧 Django 栈有「比赛公告」,OJ2 从头到尾没搬:没有路由、没有契约、没有前端页面,
表建在那里纯粹是 introspect 0000 时一起拉进来的。确认不需要,连表带 schema 定义
一起删。

删除前核实:没有任何表外键指向它,序列由本表 owned 会随 DROP TABLE 一并消失,
生产快照(db_backup_2026_08_07)里只有 1 行 —— 2022 年 4 月挂在 contest 1 上的
一条测试公告。迁移不写 CASCADE,同 0002:真有别的东西引用了,宁可报错也别被悄悄
级联掉。

**上生产要显式放行**:migrate.ts 的破坏性闸会拦下 DROP TABLE,得
`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`,并按它说的先备份。本机已实跑:
不带环境变量确实被拦,带上之后表和序列都没了,重跑是「没有待执行的迁移」,
db:generate 报 No schema changes(snapshot 与 schema.ts 对齐,不给下一条迁移留假 diff)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
2026-09-02 20:38:04 -06:00
286487acf3 fix(自学): 少于三分钟的不算已读
原来只要打开过(viewCount > 0)就记成已读,于是「点开看一眼就退」和「认真读完」
在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。加一道 3 分钟的门槛 ——
按最短的一课定的下限,读不完还翻不动的课文,扫一眼是到不了这个数的。

阈值 TUTORIAL_READ_SECONDS 放在 contract 里,前后端共用一个数:学生端目录的 ✓、
后台「按学生」的已读课数、「按课程」的读过人数,三处口径必须一致,各写各的迟早分叉。

**累计时长故意不卡**。时长记的是真实停留,不满三分钟的秒数照样算进去,于是
「已读 0 课、累计 25 分钟」成为一种能看见的状态 —— 他翻了很多课,每课都没读满。
这正是这道门槛想让老师看见的东西,把时长一起滤掉反而把信息删了。

「按课程」的人均时长跟着改了分子:分母是读满三分钟的人,分子就得是同一批人的时长,
否则拿全部时长去除达标人数,人均会被翻一眼就走的人凭空抬高。

顺带修掉「✓ 已读 · -」:心跳 15 秒一跳,点开就走会落在 0 秒上,而 readableDuration(0)
返回的是 "-"。新口径下打勾要求 ≥180 秒,这个组合不可能再出现;打开过但没读满的
显示灰色的「读了 N 分钟」,记是记下了,只是还没到「已读」。

后台筛选栏上方写了一行口径说明,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了。

本机实跑:student 读 A 课 500 秒、B 课 60 秒 → 已读 1/2、累计 560 秒;
A 课「读过 2 人、累计 700、人均 350」,B 课「读过 0 人、累计 60、人均 0」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
2026-09-02 20:37:30 -06:00
9675dfbd42 Revert "feat(题目): 样例试运行放出「终端会话」,把输入是怎么喂进去的画给学生看"
Some checks failed
Deploy / deploy (push) Has been cancelled
撤掉 8cd4459。功能本身跑通了(C / C++ / Python3 三条都验过),但界面上不成立:
为了解释「输入是喂进去的」「提示语也算输出」这两件事,每跑一次样例就压两大段
灰色小字,把一个干净的小面板变成了说明书 —— 需要靠一段说明才能看懂的配色,
本身就说明这个设计没做对。

连带撤掉同一批的「结果不自动复位 + 通过/不通过 tag」,回到 bd84599 的样子。

「学生感觉不到输入这一步」这个问题还在,只是不该用这个办法解。教程 04 那边讲
「交到判题狗上要把提示语删掉」的一节保留(那部分和界面无关,本来就成立),
其中引用本功能的那句话已经删掉并重新发布。
2026-09-02 01:25:19 -06:00
8cd4459964 feat(题目): 样例试运行放出「终端会话」,把输入是怎么喂进去的画给学生看
Some checks failed
Deploy / deploy (push) Has been cancelled
学生在自己电脑上跑,是「程序停下来等我敲、敲完回车再往下走」;判题狗这边输入
是提前备好、一口气喂进去的,屏幕上只剩对和错。这个落差是入门阶段最常见的困惑,
最痛的落地形态是:照着教程写了 printf("请输入温度:"),算得明明对却一直判错。

试运行时给代码套一层前导,程序每读到输入就原样回显到 stdout,用  包成标记。
回来的输出拆两次用:抠掉标记段是程序真正的输出(判定用,和不套前导时一字不差),
带标记渲染就是一份终端会话。支持 C / C++ / Python3,其余语言照旧直接跑。
只走样例试运行这条路,正式提交判题一个字都不加。

界面上蓝色是喂进去的输入,带虚线下划线的灰字是学生自己打的提示语(一段输出紧
跟着一段输入就是提示语)。把提示语抠掉正好等于期望输出时,把话说死:答案是对
的,删掉提示语就通过。

顺带:样例「测试」的结果不再 2 秒自动复位,按钮固定叫「测试」,通过 / 不通过挪
到旁边的 tag 上。

几个踩出来的坑记在代码注释里:C 不能攒够一行再回显(样例输入不带结尾换行,那个
换行永远等不到,回显会拖到退出时才发生,变成「答案在前、输入在后」);也不能按
字节回显(中文多字节会被标记撕碎);行号回退只能对编译错误全量做,stdout 里混着
学生自己打印的东西,printf("%4d | %s") 这种表格题一改就把人家的输出改错了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
2026-09-02 01:11:41 -06:00
bd84599174 feat(自学): 教程和练一练都留痕,老师能看到谁学了多少
Some checks failed
Deploy / deploy (push) Has been cancelled
自学模块以前一个字节都不落库:读到第几课只存在浏览器的 localStorage 里,
练一练的对错是组件内的一个 ref,刷新即失忆。老师能看到的只有「谁交了题」。

现在两张新表:
* tutorial_progress —— 一个学生 × 一课,记打开次数和累计停留秒数
* exercise_attempt  —— 一个学生 × 一道练习,记试了几次、错了几次、
  第几次做对的、最后一次做错时填的什么

都存聚合不存流水。练习那张表尤其明显:流水会随着学生反复点提交无限长,
而多出来的行回答不了任何新问题 ——「他第 3 次和第 5 次都选了 B」对老师
没有意义,「他试了 7 次才对」有。

停留时长只在页面可见、且十分钟内有过操作时才计。机房的电脑经常开着页面
就走了,不设这道闸的话「停留时长」会变成「电脑开机时长」,老师看到的
数字全是假的。换课、切标签页、关窗口都会先把攒着的秒数冲给**离开的那一课**。

练一练的对错仍然是前端判的:答案本来就随题面一起下发到浏览器,后端再判
一遍也挡不住任何人,只是重复实现七套判题。所以这是教学观察数据,不是成绩。
`last_wrong_answer` 存的是前端拼好的一句人话(「选了 C」「顺序 3-1-2」),
不是原始作答结构 —— 七种题型形状各不相同,存结构就得在后台按题型各写一套
渲染,而老师要看的只是他错在哪。

顺带修掉预测输出题的一个老问题:它的 `submitted` 一旦为真就不再收回,而
`allCorrect` 是跟着输入实时算的,于是学生错一次之后把答案改对,界面直接
跳成「输出正确!」、提交按钮同时禁用,submit() 再也执行不到 —— 这道题
**永远不会被记成做对**。排序/连线/找错/分组四种题本来就在交互处把 submitted
置回 false,只有这里漏了,按同一套补上。

学生端:目录每课显示「✓ 已读 · 11 分钟」和「练一练 3/5」。教程保持免登录
可读,未登录只是不留痕,并明说一句。

老师端:后台新开「自学情况」(教师及以上可进),三个 tab ——
按学生(默认把读得最少的排在最前,这张表要回答的是谁还没开始)、
按练习(每道题的正确率、一次做对几人、做对的人平均试几次;展开看逐人明细
和他们最后错在哪)、按课程。班级框填 3-4 位是具体班级,1-2 位当年级前缀。

外键用了库级 CASCADE,和 Django 建的那批 NO ACTION 不同:删教程、删用户
不必再记得回来手工清子表。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GW5ef6C2kRW8Ru27ghCaUu
2026-09-01 08:54:27 -06:00
49681d04a6 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-09-01 07:50:32 -06:00
912dbb7f73 feat(登录): 把「能打拼音」这件事告诉学生
Some checks failed
Deploy / deploy (push) Has been cancelled
拼音搜索上一版就能用了,但没人知道。姓名框的占位符写清楚
「选择姓名,可打拼音 zs 或 zhangsan」,提示框里再补一句
「找人不用切输入法」—— 机房电脑默认英文输入法,这句才是学生真正在意的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQCLjAmz6pPXMNGoZmvAmC
2026-09-01 07:44:57 -06:00
4654615f89 feat(登录): 姓名支持拼音和拼音缩写搜索
Some checks failed
Deploy / deploy (push) Has been cancelled
机房电脑打中文要切输入法,选姓名前先切一次法太别扭。现在 zhangsan、zs、
xiaoxian 都能搜到张三/曾小贤,中文照旧能打。

用 pinyin-pro 的 match 而不是自己拼一份拼音串比对:多音字它两个读音都认,
单田芳搜 stf 和 dtf 都出得来,曾小贤搜 zxx 和 cengxiaoxian 也都出得来 ——
自己拼只会留下默认读音那一个,姓氏恰恰是多音字重灾区。

词典有 85 KB(gzip),静态引入会压进首屏,所以改成打开登录框才 import。
已登录的人一次都不会下载(构建产物里它是独立 chunk,只被 default 布局
动态引用;实测页面加载 0 个请求,打开登录框才有 1 个)。拉不下来时退回
纯中文匹配,不至于让人登不上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQCLjAmz6pPXMNGoZmvAmC
2026-09-01 07:25:21 -06:00