Commit Graph

8 Commits

Author SHA1 Message Date
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
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
6c2381ad2c perf(提交列表): 越早的页越慢,最后一页要等 1.2 秒
Some checks failed
Deploy / deploy (push) Has been cancelled
翻页用的是 LIMIT n OFFSET m,而 Postgres 对 OFFSET 没有捷径:前 m 行必须真的
产出再丢掉,丢弃又发生在 join 之后,每一行都白回了一次表。生产快照(10.4 万条
公开提交)实测最后一页 1258ms、碰了 95347 个 buffer。最早那几页平时没人翻,
数据页从来不在缓存里,全是冷读,所以感受上比新的几页慢得多。

改成两步:先只 select create_time / id 数到第 m 行拿游标——这两列正好是部分索引
的全部内容,跳过 m 行走 Index Only Scan,Heap Fetches 为 0,纯在索引页里数数;
再拿这一行做 keyset 回查,只回表取 limit 行。同一页降到约 9ms、885 个 buffer,
端到端 HTTP 10.7ms。代价变成 O(m) 个索引条目而不是堆页,按快照密度外推,
涨到 100 万条时最深一页仍在几十毫秒量级。

接口签名和前端都没动,页码跳转照旧。offset 为 0、以及按题号筛选时(条件在
problem 表上,第一步得跟着 join,index-only 就没了)退回普通 offset。

部分索引从 (create_time) 换成 (create_time, id):create_time 由
new Date().toISOString() 生成,只有毫秒精度,不是全序,游标用 <= 回查时同毫秒的
上一页末行会重复出现在下一页页首。加 id 之后两步走同一个顺序。索引 2.3MB → 6.9MB。

索引两列都建成默认 ASC,靠 Index Only Scan Backward 反着扫。别照着 ORDER BY
写成 (create_time DESC, id DESC):ORDER BY 的 DESC 默认 NULLS FIRST,索引的 DESC
默认 NULLS LAST,规划器认为出不了序,会退化成 external merge sort(5.2MB 落盘),
比不加索引还糟。这一条已写进 schema.ts 和迁移文件的注释。

正确性:在快照上把新旧写法返回的 id 序列逐页比对,14 个 offset × 4 个 limit
共 56 组全部一致,含末尾残页与越界。

比赛提交列表暂不改:单场比赛撑死几千条,offset 不构成问题。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:13:38 -06:00
eabb61189c fix(数据库): 索引方向被 .op() 吞掉,schema.ts 和真实库对不上
根因不是 .desc(),是 opclass。drizzle-kit 的 CreatePgIndexConvertor 里那个
三元一旦走进 opclass 分支就回不到方向分支,而 pull 给每一列都挂了 .op(...),
于是"写了 .desc() 却生成不出 DESC"每次都会重演。实测确认:不写 .op() 时
多列混合方向能正常生成,所以原先"多列混合方向的索引别指望 generate"这条
自我限制不成立。

announcement_list_idx 和 contest_create_time_idx 两条真实索引踩在这上面:
生产库里它们带 DESC(schema.sql:1636、:1685),schema.ts 却会生成成全 ASC。
contest_create_time_idx 的 opclass 还串了位(contest_id 标 timestamptz_ops、
create_time 标 int4_ops),那条 SQL 真拿去执行 Postgres 会直接拒绝。

改快照而不是生成迁移:generate 想 drop + recreate,但重建出来的和生产库里
那两条完全等价(DESC 默认就是 NULLS FIRST),执行它只是在 12.3 万行的表上
白挨一次锁。手法和当初处理 problem_tag_name_ci_unique 的 opclass 一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:53:05 -06:00
586c88f629 build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
Some checks failed
Deploy / deploy (push) Has been cancelled
一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉
不再需要的 Django 表,最后接进部署和 CI。

## 1. 让 drizzle-kit generate 可用

原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条
`DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。
逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的:

- **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。
  已从 0000_snapshot.json 清掉。
- **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS
  number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条
  ALTER COLUMN。改成字符串。
- **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带
  opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。

改完 `generate` 是干净的 no-op。

两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢
(单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY
DESC,实测同样 0.08ms);migrator 把所有语句包一个事务,
CREATE INDEX CONCURRENTLY 跑不了。

最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate
会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。

## 2. 0001 提交列表索引

`WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的
contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把
`IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把
enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列
contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。

换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照
(12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks →
0.22ms / 读 34 blocks。端到端 94ms → 6ms。

真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍
shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。

## 3. 0002 删掉 Django 残留

确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何
OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有
漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随
DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。

**回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」
都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。

## 4. migrate 接进部署与 CI

deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧
容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据,
也不用把生产库对外开放。

迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。
这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移
不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移
手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」
那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个
数据目录不在此列。)

三道闸门,都是写完测出来才补上的:

- **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA /
  ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要
  `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算,
  拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。
- **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是
  `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败
  留下的)同样是没基线。
- **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle
  的堆栈,现在直接说该检查哪一行。

另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着,
可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从
0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。
现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。

验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、
放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。
tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:56:30 -06:00
0f0a5cb65a fix(阶段1): 修正 problem 主键与展示编号映射 2026-08-06 21:11:44 -06:00
f635737453 feat(阶段1): drizzle schema 从本地库生成并剪掉 Django 框架表
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:45:51 -06:00