Commit Graph

103 Commits

Author SHA1 Message Date
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
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
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
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
a2f67eebf6 perf(用户导入): 批量导入从几秒降到半秒
Some checks failed
Deploy / deploy (push) Has been cancelled
导入一个班(45 人)要转好几秒,慢的全在密码哈希这一处:

- 哈希在重名检查之前算。老师习惯把同一份名单粘两次,那种情况要白等
  一整个班的 argon2 才看到 409。把校验和重名查询提到前面,这条路径
  现在是 5.6ms 返回。
- 45 次 argon2 串行 await。改成固定 4 路并发池;不用 Promise.all 是因为
  oj-api 的 mem_limit 只有 512m,一个年级 300 人全量并发撑不住。
- argon2id 参数从 Bun 默认的 m=64MiB 显式降到 OWASP 推荐下限 m=19MiB,
  单次 140ms → 20ms。参数编码在哈希串里,存量账号照常验证、不用迁移,
  旧的 pbkdf2 那条分支也不受影响。

45 人端到端 2.04s → 0.55s。验过:旧 m=65536 的哈希、新 m=19456 的哈希、
Django 的 pbkdf2 三种都能正常登录,错误密码照常拒绝。

顺带把生成页下载的 CSV 裁成用户名和密码两列 —— 发给学生的就这两样。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-09-01 00:28:16 -06:00
e5a6f2d1e7 fix(题单): 归属校验、进度分母、截止时间可见性等六处零碎
Some checks failed
Deploy / deploy (push) Has been cancelled
## user-progress 缺归属校验

学生端那条 GET /problem-sets/:id/user-progress 只有 requireTeacher,没有归属校验,
任何 Teacher Admin 都能读到别人建的题单的学生名单与进度。补上,口径与后台
loadOwned 一致(超管放行,其余人只能看自己建的),越权报 404。

顺带订正 docs/specs/phase4-review-authz.md:449:那条写着题单进度「显式下发真名」,
与代码不符 —— 学生端这条走 sampleUser 且没传 includeRealName,realName 恒为 null
(SQL 里那次 leftJoin userProfile 是白查的)。真正下发真名的是后台那条
GET /admin/problem-sets/:id/progress,结论仍成立,但当时漏掉了归属校验这个缺口。

## 头部进度的分母

分母只算必做题之后,ProblemSetHeader 还在拿 completedCount / problemsCount 算 ——
前者是必做完成数,后者是总题数。做完全部必做题的人会看到「9 / 10、90%」,而同一张
卡片上又标着「已完成」,题单 6 那 10 个人正是这种。改成读 userProgress,另外把
「另有 N 道选做」标出来,否则「共 10 道题目」和「9 / 9」对不上。

## 截止时间

end_time 管的不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
而学生端一个字都不显示 —— 被挡住的人不知道为什么,也不知道什么时候解锁。头部加一个
带解释的「截止 …」标签;提交列表那个锁图标的说明也补上另外两条解锁路径。

## 两个必然筛空的筛选器

学生端题单列表的难度、状态两个下拉:线上 16 个题单全是 Easy / active,选「中等」
「困难」「已归档」永远是空列表。撤掉,保留关键词搜索。接口那两个 query 参数留着,
哪天真的用起这两个字段再把 select 加回来。

## 题目移出题单时的提交记录

旧栈 problemset/signals.py 的 post_delete 会清掉该题在本题单的 ProblemSetSubmission,
OJ2 没做,于是那张表一直在攒指向已移出题单的孤儿行。补上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-09-01 00:01:58 -06:00
9d9e104df6 fix(题单): 进度记账挪到判题这一路,不再靠前端回调
前端记账是这条链上最松的一环:SubmitCode.vue 看到 AC 就回调
PUT /problem-set-progress,而且只认路由参数里那一个题单。于是

  · 从普通题库入口做出同一道题 → 不计进度
  · 网络一抖、页面提前关掉      → 进度静默丢失
  · 一道题同时在两个已加入的题单里 → 只有进去的那个记上

旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账 ——
2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度就是它跑的。

改成判题落库之后由 judge/run.ts 记账(recordSolvedProblem),记进该用户**所有**已加入
且包含这道题的题单。位置在最后那条 publishSubmissionUpdate("finished") 之前,所以前端
收到「判完了」时进度已经落库,跳回题单页看到的就是新数据。前端那次回调删掉。

不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积;
更要紧的是这条口径必须和补账工具一致,否则补账工具会永远「发现」差异。

实跑(本地起 api + worker 真判一次):提交时**完全没带 problemSetId**,判完之后
progress 变成 1/1 100% 已完成、10 分、complete_time 落下、all_problems 奖章发出、
problemset_submission 也记上了。

## 补账那半

backfill-problemsets 前面加一道「按实际 AC 补进度」,移植自旧栈的
fix_problemset_progress,口径和 recordSolvedProblem 逐条对齐(非比赛提交、
ACCEPTED 或 AST_CHECK_FAILED、取最早那次)。补录的格子先并进 detail 再重算,
所以预演里的奖章名单是照着「补完账又重算过」的进度算的,和 --apply 的结果一致。

在生产快照上实跑:

  合计:补录 10 道题,进度 497 条要重算(完成 +23 / -0),奖章补发 56 条、收回 0 条
  已订正 11 个题单 → 复核通过
  user_badge 1180 → 1236,已完成 621 → 644,problemset_submission 7735 → 7745
  「未完成但有完成时间」仍是 4 条;重复跑幂等

补录的 10 道分布在题单 4/5/6/15/16(1/1/1/4/3),和离线独立算的数字逐个对上 ——
其中题单 15 那位 AC 了全部 12 题却一直显示未完成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-09-01 00:01:38 -06:00
74b97c610f fix(题单): 「选做」终于作数了,分母只算必做题
isRequired 一直只是卡片上的一行字:卡片写着「(选做)」,进度分母和 all_problems
奖章却照样要求做完。结果是学生按提示跳过选做题,进度条卡在 100% 以下、全通奖章也
拿不到 —— 快照里 22 个人做完了全部必做题却显示未完成(题单 5 三人、6 十人、8 两人、
11 七人)。旧栈的 update_progress 同样不区分,是一路继承下来的。

改成:分母只算必做题,选做题做了仍然计分(totalScore 把它算进去),只是不卡完成。
一道必做都没标的题单退回「全部都算必做」—— 那种题单多半是没用这个字段,而不是真的
整单选做,不兜住的话它永远完不成。

## problem_count 奖章不能跟着改

老师当初是按题单的**总题数**设阈值的:题单 5 的「一职欧拉」要 8 题,而它的必做只有
7 道。要是 problem_count 也改用只数必做的 completedProblemsCount,这枚奖章一夜之间
不可得,76 个已经拿到的人会被 recalculateBadge 收回。所以它数的是「做出的题目总数
(含选做)」,从 progress_detail 的键数来。score 类同理不受影响。

预演证实了这道闸门有效:22 人拿到完成状态,奖章补发 56 条、**收回 0 条**。

## 顺带:第三份手抄的达标逻辑

PUT /problem-set-progress 里还藏着一份 inline 的奖章判定,和 services 里那份、
补发脚本里那份是三份各写各的 —— 这次改 problem_count 的语义,漏掉任何一份都会让
学生提交时发的奖章和后台重算的结果对不上。三处统一到 eligibleForBadge。

## 工具改名并扩到进度

backfill-badges → backfill-problemsets。语义变更之后,已有的进度行要跑一遍才会按新
规则重算,否则那 22 个人得等到老师下次动题单才生效。两笔账本来也是同一笔:进度一变,
奖章达标面就跟着变,所以落库走 resyncProgress(它重算进度后会顺手重算该题单全部奖章),
预演里的奖章差异也是照着订正后的进度算的,保证预演和 --apply 的结果一致。

在生产快照上实跑:

  合计:进度 494 条要重算(完成 +22 / -0),奖章补发 56 条、收回 0 条
  已订正 10 个题单 → 复核通过:题单数据与规则一致
  user_badge 1180 → 1236,已完成 621 → 643
  「未完成但有完成时间」仍是 4 条(d7a6414 那条语义保住了)
  重复跑幂等:进度 0 条、奖章 0 条

抽查题单 6(9 必做 + 1 选做):只做必做的显示 9/9 100% 已完成、80 分;连选做一起做的
同样 9/9 已完成,但 90 分。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:55:36 -06:00
d9e6a2a3f0 perf(题单): 学生端题目列表少两次 join 一次查询,顺带把并列的 order 排稳
## 排序

学生端 /problem-sets/:id/problems 只按 order 排,没有 tiebreaker,而卡片是按数组
下标编号的(#1 #2 #3)。order 并列时 Postgres 不保证次序,题单 8(3 道题 order 都是
10)、题单 11(2 道并列 4)、题单 14(3 道并列 0)实际就有并列,于是「第 3 题」指
哪道题每次刷新都可能变。后台那条列表一直是 order + id 排的,两边本来就不一致。

补上 asc(id)。教师进度视图里那份题目清单(同一路由文件 :398)有同样的问题,一并补。

实跑:四道题、三道 order 并列,连打 5 次次序完全一致。

## 载荷

这个接口原来复用 problemListItemSchema,为此要多 join user + user_profile 凑
createdBy、再多查一次标签表凑 tags —— 而题单卡片只渲染题号、标题、难度、分数和
完成标记。tags / submissionNumber / acceptedNumber / createdBy / contestId /
allowFlowchart / showFlowchart / hasAstRules 一个都不用,myStatus 甚至写死是 null。

而且 select 的是 schema.problem 整行,题面、样例、答案、ast_rules、flowchart_data、
sql_display 全都白拉回来一遍。

改成 problemSetProblemItemSchema(id / _id / title / difficulty 四个字段),查询相应
收窄:两次 join 去掉,标签那次查询整条去掉,行查询只取四列。每次请求从 4 条查询降到
3 条,其中最大的那条不再拖整行题面。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:47:57 -06:00
d7a6414735 fix(题单): complete_time 翻回「只设不清」;完成题单后那个必然报错的 tab
三件事,都是上一轮结论没查扎实留下的。

## complete_time

f9354b0 把「退回未完成时清空 complete_time」定成两边统一的行为,理由写的是
「快照里 complete_time 非空 ⟺ is_completed」。那个检查只做了一个方向(已完成但
没有完成时间 = 0 条),反方向没查 —— 实际有 4 条 is_completed=false 却留着
complete_time,全在题单 8:那批人 2025-11-14 在它还只有 6 题时完成过,老师后来
加到 12 题,进度退回未完成,完成时间保留了下来。

旧栈是故意不清的(problemset/models.py:218 只有 `if is_completed and not
complete_time` 这一条赋值),语义是「曾经完成于」。清空是重写时在学生路径上引入的,
上一版又把它推广到了后台路径。

翻回只设不清。「未完成 + 有完成时间」是允许的组合。反过来清空的代价不可逆:往一个
100 人已完成的题单里加一道题、再改主意删掉,这 100 个人的历史完成时间就一起被冲成
「现在」—— 上一轮的实跑已经在题单 9 上复现过。

## 两处注释订正

「旧后端不做加题后的重算」这个说法是错的,本仓早先的注释里有,上一版我照搬进了
services/problemset.ts 和提交信息。旧栈用 signals 做了,而且两件事都做:
problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete 上重算全部
参与者进度、再重算该题单全部奖章。重写时 views 里翻不到显式调用就当成没做,于是
奖章那一半漏了 —— 53 条应发未发正是这么来的。

另外补上 53 条里那 23 条的出处:旧栈的管理命令 fix_problemset_progress 按实际 AC
补 progress_detail,而 signals 不挂在 Progress 上,所以进度补了、奖章没补。

## 用户进度 tab

detail.vue 的 showTabs 写的是「超管 或 自己完成了题单」,而它渲染的
UserProgressView 调的是 requireTeacher 的接口。两边正好错开:

- 学生做完题单 → tab 出现 → 点进去 403(实跑确认:已完成该题单的 student 拿到
  403 permission-denied,devadmin 拿到 200)。而 loadUserProgress 没有 try/catch、
  loading.value = false 又写在 await 之后,转圈永远停不下来。
- Teacher Admin 看不到这一栏,尽管他们才是它的目标用户、也是唯一调得动的角色。

条件换成 isTeacherOrAbove,取数补 try/finally。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:42:40 -06:00
05cf8011e3 fix(题单): 奖章补发改成二进制子命令,独立脚本在生产跑不了
Some checks failed
Deploy / deploy (push) Has been cancelled
上一版把补发写成 `bun src/scripts/backfill-problemset-badges.ts`,那在生产上根本执行
不了 —— api 镜像是 debian-slim + 一个编译好的 oj2-api,既没有 bun 也没有源码。

改成 main.ts 的子命令,跑法对齐 migrate(deploy.sh:197 就是这个形态):

  docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-badges
  docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-badges --apply

脚本本身从「导入即执行」改成导出一个返回退出码的函数,main.ts 负责解析参数和 exit。
package.json 的 backfill:badges 也指向同一入口,本机和生产走的是同一条代码路径。

用编译出来的二进制在 /tmp 下实跑过全部四条路径(预演 / --apply 后自动复核 / 重复跑
幂等 / 误发时拒绝执行并退 1,加 --allow-revoke 才收回),确认动态 import 的模块进了
bundle、不依赖源码树。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:26:27 -06:00
f9354b0df1 fix(题单): 进度重算漏了分数和完成状态,奖章没人补发
Some checks failed
Deploy / deploy (push) Has been cancelled
进度算法在学生路径和后台路径各写了一遍,于是各自漂了一段。后台那份
(resyncProgress)名叫「把所有参与者的进度重算一遍」,实际只更新分母和百分比:

- 不碰 total_score。改题目分值时专门调了它,函数体里却没这个字段,score 类奖章
  因此按陈旧分数判定。
- 不碰 is_completed。加一道题之后分母变大、百分比掉下来,人还标着「已完成」。
- 不清理 progress_detail。删掉一道题之后 least(completed, total) 只保证不超过分母,
  会把没做的题算成做了 —— 3 题的题单只做出 C,删掉 C 就变成 1/2 = 50%。
- 不重算奖章。题目集一变 all_problems 的达标面就变了,没有任何地方补发。

最后一条攒下了实打实的欠账:8-07 的快照里 53 条应发未发,涉及 30 名学生,误发 0 条。
其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度 —— 进度补了,
奖章没人回头判。

两边不再分叉的唯一办法是只留一处算法,所以抽出 services/problemset.ts:
computeProgress 是纯函数,学生做出一题和后台改题目都只是调用者;
eligibleForBadge / recalculateBadge / resyncProgress 一并搬过来,补发脚本才能复用
同一套判定。批量写回仍是一条 UPDATE ... FROM (VALUES ...),没退回逐行往返。

顺带修掉空题单的坑:isCompleted 加了 total > 0 前提。原来 0 === 0 也成立,老师先建
题单、学生先加入、题目后加,加入那一刻就写下 complete_time 并计进「完成题单数」成就,
而且后面补上题目也不会自愈。

一处行为变化:退回未完成时 complete_time 会清空。后台那份原来保留旧时间,学生那份
一直是清空的,统一成后者 —— 否则同一行会出现「未完成 + 有完成时间」,破坏现有数据里
「complete_time 非空 ⟺ is_completed」这条不变量。代价是加题再删题会把历史完成时间
洗成「现在」。

scripts/backfill-problemset-badges.ts 补历史欠账,默认只读预演,--apply 才落库。
只要存在误发就拒绝执行并打出名单,要连收回一起做得显式加 --allow-revoke ——
recalculateBadge 的删除是真删,user_badge 的 earned_time 没有别处备份。

在生产快照上实跑:补发 1180 → 1233 条,复核全部一致,历史 earned_time 未被刷新;
resyncProgress 对题单 9(7 题 / 117 人 / 100 人完成)加题后正确退回 0 人完成并收回
100 枚奖章,删题后完整恢复;改题目分值 10→20 使总分和 7460 → 8590,正好 +113×10。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:23:38 -06:00
ff31f7abd1 fix(题单): 补回加入题单前旧提交的遮挡
旧栈的 SubmissionListSerializer.get_show_link 有一条防作弊规则:学生加入含某道题的
题单后,他在加入之前留下的 AC 代码就摆在提交列表里,复制粘贴即可过关,所以要把这段
旧提交藏起来。重写时整条丢了 —— canViewSubmission 里 grep 不到一个 problemset,
自己的提交第一行就无条件 return true。

前端反而把 UI 留着了:ProblemSubmission.vue 那个锁图标的 tooltip 一直写着「这道题在
你已经加入的题单中,只有在题单中完成此题,代码才可见」,而后端永远不会给出
showLink: false,图标一次都没亮过 —— 提示语本身成了操作指引。

题单的 end_time 也因此成了孤儿字段:它不是「截止后不能提交」,是这道闸门的时间边界。

影响面不是边角:8-07 的快照里 1734 人次、188 名学生在加入题单之前就已经 AC 过题单
里的题,占全部已解题次的 22.5%。

补回来的同时改了三处旧栈的做法:

- 判断放进 canViewSubmission,列表和详情一起挡。旧栈只挡列表链接,
  SubmissionAPI.get 光走 check_user_permission,知道 submission id 直接访问照样
  拿得到代码,遮挡是虚的。
- 一题落在多个已加入题单里时取 max(join_time),「存在任一题单要求遮挡就遮挡」等价于
  「早于最晚的那次加入」。旧栈用 .first() 取任意一条,行为不确定。
- 比赛提交列表不挂闸门。题单里的题必定是非比赛题(加题时卡了 contestId IS NULL),
  而那条列表只出比赛提交,交集恒空,挂上去就是每页白跑一次查询 —— 而比赛进行中它是
  被刷得最狠的。旧栈 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。

闸门只挡「看代码」这一路,不挡 allowShared=false 那一路 —— 后者是分享开关的归属校验,
与作弊无关,挡了学生连自己旧提交的分享都动不了。管理员不受限,对齐旧栈的
is_regular_user() 前提。

解锁三条路实跑验证过:在题单里做出该题、题单过 end_time、题单归档。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:23:13 -06:00
2031e6a434 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-08-31 07:41:44 -06:00
38b81881d5 feat(collab): 教师端弹框补上代码提示,语言跟着学生走
Some checks failed
Deploy / deploy (push) Has been cancelled
教师端的协作编辑器只有高亮和括号匹配,没装 autocompletion —— 老师替学生
写代码时没有下拉补全,只能盲敲。而且语言写死 cpp(),学生写 Python 时老师
看到的是 C 的高亮。

- 求助协议带上语言:help_request 记到 HelpRequest,accept 时写进 Room,
  room_open 发给两端;认不出的语言一律当 C(老客户端不带这个字段,而它
  以前就是写死 C 的)
- 新增 help_language / room_language:学生在排队或协作期间切语言,只更新
  语言,不动队列位置和房间;已开房就把新语言推给老师,弹框的高亮和补全
  实时跟着换
- CollabModal 装上 autocompletion,和学生端用同一套 enhanceCompletion +
  completeAnyWord;语言→高亮扩展的映射抽到 shared/extensions/language.ts
  两端共用,免得再分叉
- 学生端求助按钮合并状态提示:原来按钮旁边还挂一个 n-tag,一行工具栏在
  1280 的机房屏上放不下,改成状态全进 label(已求助 · 待接入 / 已求助 ·
  前面 N 人 / xxx 老师帮你中)

扩展数组变了 vue-codemirror 会整体 reconfigure,但 CM6 对已存在的
compartment 取 `compartments.get() || ext.inner`,collabDoc 那个装 yCollab
的 compartment 内容会被沿用,切语言、切主题都不会把协作弄断 —— 实跑验证过
双向同步、补全下拉、协作中切语言三条路径。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015APGLsyaCUa7XFMYAaCho4
2026-08-30 10:28:36 -06:00
66a564710f fix(collab): 两处会让协作「看起来正常、其实各写各的」的缺陷
都是实测复现出来的,不是推测。

1. 教师第二次接单,看到的是上一个学生的代码拼在自己文档里
   CollabModal 的 code ref 跨会话不清。n-modal 默认 display-directive="if",
   每次打开都重挂 CodeMirror,拿这个 ref 当初始文档;而 yCollab 只观察 ytext、
   从不反过来用 ytext 覆盖编辑器。于是上一轮的代码留在文档里,新学生的内容
   作为 delta 插到位置 0,两边偏移从此对不上。
   实测三轮会累积成 CCC/BBB/AAA/XXX,且教师敲一行之后,教师看到
   「BBB/AAA/XXX」、学生看到「BBB/XXX」——两份不同的文档。
   改法:不绑 v-model,文档完全交给 Yjs;并把 start 的起点从「房间打开 +
   await nextTick」换成「编辑器 ready」,顺带修掉绑到已 destroy 的旧 view。

2. 学生换连接后房间静默死掉
   handleCollabOpen 迁移 socket 时只补发了 help_status,没补 room_open,
   而前端每次连接建立都会把 room 清成 null —— 页面显示「老师正在帮你」,
   编辑器却早把 yCollab 摘了,老师敲的字一个也到不了。
   补发 room_open 也修不好:CRDT 状态跟着旧连接没了,新建 Y.Doc 再 seed
   会和老师那份合并成重复文本。所以改成直接拆房、请求退回排队,
   老师再点一次 —— 和老师掉线走同一条路子。

顺带修掉验证时挖出来的第三个:握手帧会被丢。
两端挂 yCollab 的时刻不同步(教师等模态框、学生等 chunk),先挂好的那端发的
SyncStep1 到对面时还没有 binaryHandler,服务端转发是成功的、客户端却静静丢掉。
丢的偏偏是握手——y-protocols 里 A 的内容靠 B 发的 Step1 换回来,教师的 Step1
一丢,学生的代码就永远到不了教师那边(单向同步,同样看着像在协作)。
CollabWebSocket 改成 handler 装上之前先把二进制帧缓着,装上再按序放行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:37:41 -06:00
4b6242ba70 fix(比赛): 比赛进行中,学生打开比赛题必 500
contest.ts 两处把「藏起来的难度」表示成空串,而 problemDifficultySchema
是严格的三值枚举,parse 直接抛 ZodError。contestDetailsAllowed 在
「比赛进行中 + 看的人不是管理员」时就是假 —— 也就是正常学生在正常比赛里,
比赛题列表和详情页一开就挂。dev 库 contest 表一直是空的,这条路径没被跑到过。

改成下发 null,契约里给这两个字段单独起了 maskedProblemDifficultySchema。
语义上对齐旧 Django:ProblemSafeSerializer 把 difficulty 放在 exclude 里,
字段整个不下发,而不是给一个假值。

端上顺着 null 补了四处:ProblemInfo 的「难度」项整条不渲染(和旧栈表现一致),
题目列表、题单列表同理,transforms 的 ProblemFiltered.difficulty 也放开为可空。
这几处是 vue-tsc 逐个揪出来的,正是严格枚举该起的作用。

实测(学生 / 超管 × 比赛题 / 普通题 四种组合):学生看比赛题详情与列表
从 500 变 200 且 difficulty 为 null,超管仍拿到真值 Low,普通题不受影响;
页面上「难度」整项消失,统计面板其余内容正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 09:01:56 -06:00
c7132025f7 fix(collab): 二进制帧的宽松限流档名存实亡
两档共用一个令牌桶:桶按严格档的 20 初始化,之后每条文本帧(含 30 秒
一次的心跳)都会 Math.min(RATE_BURST, ...) 把它压回 20,二进制帧那档
标的 200 突发根本拿不到。实测连打 150 帧在第 101 帧被 1008 踢下线——
正是设计里要避免的「协作编辑时打字把自己踢掉」。

改成两个独立的桶。文本帧仍是 20 / 每秒 2,二进制帧 200 / 每秒 100。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj
2026-08-30 08:29:36 -06:00
6f873be367 feat(web): 题目页的「开启同步」换成「求助」
学生发起求助,状态与排队位置显示在按钮旁。
教师端入口不在题目页,所以对教师隐藏这个按钮。

顺带修正 handleHelpCancel:去掉 request.socket !== ws 的校验。
学生取消自己的求助,不管从他哪个标签页发起都合法——
getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,
不是跨用户操作,不需要再比对是不是同一条连接。这层校验此前
会让「第二个标签页点取消」被服务端静默丢弃,前端却已经乐观
地把按钮变回「求助」,是一处 UI 说谎;现在两边状态一致。
handleCollabClose 里排队分支的 socket 归属校验不受影响,
那里的关闭事件是顺带触发的,仍然需要认出是不是本人这条连接。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 06:33:25 -06:00
ab8fcc2d42 fix(collab): room_closed 无条件归位、断线重连补状态、学生 socket 重连迁移
Critical:room_closed 不再按 reason 挑着重置 helpStatus——学生自己的 socket
发送失败时服务端删了请求却发不出任何纠正帧,store 会卡在陈旧的 active/pending
上出不来。现在一律先归 idle,老师掉线那条路径服务端会紧跟着补一条
help_status:pending,同一条连接消息严格按序到达,不会被这次重置抢跑。

Critical:断线重连没有补状态。前端 CollabWebSocket 加 onConnected 钩子,
每次连接建立(含重连)都清空本地 requests/helpStatus/room,等服务端补发;
后端 handleCollabOpen 对非老师的重连方,如果这个账号名下还有请求,
补发对应的 help_status(pending 带重算的 queueAhead,active 带 teacherName)。

Critical:重连后请求仍绑在旧 socket 上,导致 handleHelpCancel 的归属校验
认不出新连接、后续通知也写进死连接。handleCollabOpen 里把请求和(如果有)
房间迁移到新 socket,并清掉旧 socket 的 roomOwnerId,防止它稍后的 close
反过来拆掉刚迁移出去的房间。

Minor:disconnect() 补齐 queueAhead/teacherName/notice 的重置,不留陈旧值。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 06:01:41 -06:00
aef687bcfc fix(api): collab 二进制转发的背压误判与发送失败后的幽灵请求
Important 1:handleCollabBinary 把 send() 返回的 -1 当成失败,实测证明
-1 只是背压——消息已排队,最终照样送达(8MB 帧原样完整到达);只有 0
才是真丢。之前 sent <= 0 一触发背压就拆房间,慢网/大粘贴反而先弄死正常
会话。改成只认 sent === 0;同时空 Uint8Array 的 send() 也回 0(送达和
真丢用同一个返回值分不清),先按长度 0 直接忽略,不转发也不参与失败
判定,否则任意一方发一个空二进制帧就能把房间拆了。

Important 2:发送失败后走 teardownRoom("peer_offline") 从不 removeRequest
(只有 reason === "done" 才删),请求卡在 status: "active" 没有房间,
之后 handleReject / handleHelpCancel / handleLeave / handleAccept 全部
因为状态或归属对不上而拒绝处理,那个学生的后续求助永远被静默吞掉。
补上 offlineSide 参数,和教师断线共用同一条收尾路径:老师那侧消失,
请求退回 pending 并清 teacherId/teacherName,学生收到新的 help_status
pending;学生那侧消失,请求整条清掉。两种情况都发 room_closed 并
broadcastRequests()。

Minor:handleHelpCancel 没有 request.socket === ws 校验,同一账号第二个
标签页能取消第一个标签页排队中的请求——和上一轮修的 close 排队分支是
同一类归属漏洞,补上同样的检查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 05:28:14 -06:00
457df1ef3c fix(api): collab 房间越权与几处时序/静默丢帧缺口
Critical:学生排队断线分支补归属+状态校验,避免同账号双标签页
把 active 请求错删、被另一个学生请求顶替后又被别的老师抢建出
第二间房;handleAccept 补 getRoom(studentId) 兜底同一学生 id
下已有房间的情况。

Important:handleAccept 的库查询是个 await 点,之后补上
teacherSockets().has(ws) 判活,教师在等待期间断线不会再对着
死 socket 建房间。

Minor:handleReject 补上和 handleAccept 一致的库复核,堵住
连接存活期间被降级/禁用的教师继续掐请求的口子;
handleCollabBinary 检查 peer.send() 返回值,转发失败时走既有
teardownRoom 拆房间,不再静默丢帧、悄悄分叉两边的代码。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:48:35 -06:00
b14639d890 feat(api): collab 房间管理与 Yjs 帧转发
accept 时按库里的 adminType 复核身份,房间以学生为键。
服务端只按房间转发二进制帧,不解析内容。
老师掉线请求退回排队,学生掉线请求随人清除。

顺带处理三项 Task 2 评审遗留:
- TEACHER_ROLES 去重,handler.ts 改为从 routes/helpers.ts 导入,不再自留一份
- 补全学生排队中断线(尚未进房间)的清理分支,避免陈旧请求卡死在列表里
- 修正 websocket.ts 里一处过期注释:username/adminType 是三种 kind 都会填,
  不是只有 collab 才填

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:36:12 -06:00
1db2c49b87 feat(api): 新增 /ws/collab 通道与课堂求助控制面
学生发起/撤销求助,在线教师收到全量列表。求助只在内存,不落库。
二进制帧的限流单独一档,避免协作输入把连接踢掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-08-28 02:24:44 -06:00
f38444c97a fix(代码规则): 给 C 题配的规则一半是哑弹,C++ 题根本配不了
Some checks failed
Deploy / deploy (push) Has been cancelled
后台的节点下拉是一张 C/Python 混在一起的 15 条表,整份铺给每种语言。给 C 题选到
只有 Python 有的 list_comprehension、f-string,规则存得进去,判题时拿裸名去比节点
类型,C 的语法树里永远不存在它——「必须使用列表推导式」永远失败、「不能使用
f-string」永远通过,两头都不报错,只有学生受着。反过来 mappings 支持的 do_while、
switch、struct、include 在表里没有,编辑器根本选不到。

标签表改成按语言分组,和判题机 mappings 的键集逐条对齐;保存时校验规则与语言是否
匹配,不匹配给中文提示。C 的 target 从 8 个可用变成 14 个。

C++ 接上了 tree-sitter-cpp,386 道 C++ 题从此能配规则。它继承 tree-sitter-c 的语法,
C 那 14 个 target 实测全部通用,另加范围 for、类定义、try-catch、throw、namespace、
模板、lambda、using 共 22 个。调用形态和 C/Python 都不同,一并处理:a.push_back()
和 p->push_back() 是 call_expression + field_expression,不是 Python 的 attribute;
std::sort(...) 的 function 是 qualified_identifier 而不是 identifier,所以额外比一次
:: 末段,否则学生写没写 using namespace std 会得到不同判定。

一起收掉的几处:

- Java/Golang/JavaScript 配的规则一条都不会跑,题目页却照常把它们渲染成「要求」
  挂给学生看。现在后台不给这些语言开 tab,下发给学生的要求也按语言过滤。
- 「出现次数」不填数字存下来是一条恒真规则,描述还退化成光秃秃一个「for 循环」。
  切换引擎时给默认值,保存时拦下,读取时整条丢弃。
- 次数规则失败只说「if 条件 出现 2 次 ✗」,学生不知道自己写了几次,补上「当前 N 次」。
  旧栈的引擎其实算了这个数,但 checker 只取 describe,算完就扔。
- must_have_nesting 的文案没走标签表,学生看到的是「必须使用 for_loop 嵌套」。
- 运算符文案给的是逻辑名,C 题的学生看到「必须使用 and 运算符」,而 C 里写的是 &&。

语义校验放在 astRulesError() 而不是 zod 的 refine 上:astRulesSchema 同时用于读后台
题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。保存前先 pickAstRules()
剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死——tab 里看不到那组
规则,保存却被拦下。

生产库那 17 条规则(全是 Python3 的 must_exist_node / count_node)行为不变,逐条实跑
核对过。C++ 的 22 个节点 target、25 个运算符也逐个跑了,没有恒假的哑弹。改了带 wasm
内嵌的 ast.ts,dev 和编译两种形态都验过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:53:37 -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
e921506f40 fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉
`bullmq` 对自定义 jobId 有一条兼容老式可重复任务的校验:一旦包含 `:`,就必须
正好切成三段(`job.js` 里的 `split(':').length !== 3`),否则抛
`Custom Id cannot contain :`。

- 代码提交的重判用的是 `${id}:rejudge:${时间戳}` —— 三段,能过;
- 流程图的重判用的是 `${id}:${时间戳}` —— **两段,必抛**。

所以这个接口从上线起就没有成功过一次。更糟的是清空评分发生在入队**之前**:

    await db.update(...).set({ status: 0, aiScore: null, ... })
    await flowchartQueue.add(...)   // ← 在这里抛,500

每点一次「重新判题」,原来的分数、等级、反馈、评分明细就永久丢一次,提交卡在
PENDING,队列里没有任何任务会来救它,老师看到的只是「重新评分失败」。

改成三段式 jobId,并把入队失败落成 FAILED(而不是留在 PENDING)——
与 `POST /flowcharts` 的处理保持一致。

之前几轮验证没发现,是因为当时手上只有一条 PENDING 的提交,被 409(状态不允许
重判)挡在了入队之前,正好绕开了这个 bug。这次完整走教师流程才撞上。

实测:修复前点重判 → 前端「重新评分失败」、库里 status 变 0 且分数清空、
api 日志抛 `Custom Id cannot contain :`;修复后 → 「重新评分已提交」,
70分B级 重评为 86分A级。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:51:00 -06:00
e00ab7b876 perf(流程图统计): 给词云的分词量封顶
Some checks failed
Deploy / deploy (push) Has been cancelled
统计接口会把时间窗内所有已完成提交的 criteria / feedback / suggestions 全取回来,
逐条走一遍 jieba。而前端的「全部时段」是不带 start 的 —— 攒一学年就得把所有评语
重新 cut 一遍,而这是个同步阻塞的请求。

只给词云的分词条数封顶(3000),并按时间倒序取,留下的是最近的那批。

**数值不封顶**:总数、均分、等级分布、各项平均分、完成人数仍然按整个时间窗精确
计算 —— 那只是已取回行上的算术,不额外花钱。这些数一旦采样,老师看到的完成率和
均分就是错的,而且从界面上完全看不出来;词云是辅助性的,看的是高频问题,取最近
这些条足够。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:27:33 -06:00
c27c9fdbf9 fix(流程图评分): 队列重试从没生效过、AI 调用不设超时、等级由模型自报
## 重试是摆设

`flowchartQueue` 配了 `attempts: 3` + 指数退避,但任务开头有一道守卫:

    if (!row || ![0, 1].includes(row.flowchart.status)) return

而 catch 里第一件事就是把 status 写成 3(FAILED)。于是第 2、3 次尝试进来一看
状态是 3,直接 return、算作成功 —— **实际只跑了一次**。AI 侧的偶发失败(限流、
超时、网络抖动)永远等不到重试,学生看到「评分失败」只能自己重新提交。

改成只有最后一次尝试才落 FAILED,中间几次把状态留在 PROCESSING(1) 让守卫放行。
「是不是最后一次」由 worker 算好传进来:`attemptsMade` 是「此前已失败几次」,
当前这次还没计入,所以判据是 `attemptsMade + 1 >= attempts`。

实测(用没配 AI_KEY 这条必然失败的路径):修复前 t+1s 就落 FAILED、只评一次;
修复后评满 3 次,状态到 t+7s 才落 FAILED。

## fetch 不设超时

`completeChat` 直接 `fetch`,而 fetch 默认不超时。AI 侧一挂就把 worker 的并发位
(只有 2 个)一直占着,学生那边的按钮也就一直转。加 60 秒超时。

流式调用**不加**:那边超时会把正在推的长回答直接掐断,而客户端断开本来就能收尾。

## 等级不该由模型说了算

提示词里写死了 S/A/B/C 四档分数区间,但模型偶尔会给出「88 分配 S 级」这种自相
矛盾的结果,甚至直接吐「优秀」。脏值会一路串到等级分布图、等级筛选,以及
「A/S 才把流程图展示给学生」的判断里。改成一律由分数推出等级,模型自报的 grade
不再采信;score 本来就已经 clamp 到 0-100。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:14:21 -06:00
8f08ed03a0 fix(流程图): 学生能翻出全班评分、AI 调用没有限流
## 列表漏了一道门

代码提交列表在 `routes/submission.ts` 里有 `submission_list_show_all` 兜底:
关掉时非管理员一律返回空。流程图列表**从来没有这道门**,而它的过滤是

    if (myself === "1" || (!username && 是普通用户)) 只看自己
    else if (username) 按用户名模糊匹配

—— 只要带上 `username`,第二支就把第一支的限制绕过去了。学生在提交记录页把
语言切成「流程图」、用户名框随便填一个字,就能翻出全班同学的 AI 评分,不需要
动接口。补上和代码提交同一套口径。

## 提交与重判没有限流

每一次流程图提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源,而这两个
入口都没限流。`canView` 还允许**本人**重试自己的提交,等于学生可以对着自己的
提交反复点,无上限地刷 AI 调用。

限流桶不能直接用 `throttling:user:<id>` —— 那是代码提交在用的桶(capacity 20,
回填约 1.8 个/分钟),共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙
交不上去。单独开 `throttling:user:flowchart:<id>`。

重判对教师放行:成批点几十行是他们的正常用法。

## 提交编号的权限判断在前端自己算了一遍

契约里 `flowchartListItem.showLink` 是后端逐行下发的(与 `GET /flowcharts/:id`
的放行条件同源),前端却没用,自己按「超管或本人」重算了一次 —— 教师因此看得到
「重新判题」却打不开评分详情。

更要命的是无权限那一支渲染的 `n-text` **照样挂着 @click**,权限判断只改了外观。
学生点别人的编号,后端以 404 挡下,`loadSubmission` 只 console.error,于是弹出
一个 600px 高的空白面板,什么提示都没有。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:14:05 -06:00
64facc5701 fix(WebSocket): 断线不重连、评分结果会丢、登出后连接还活着
Some checks failed
Deploy / deploy (push) Has been cancelled
排查 WS 这一块时发现的一批问题,多数是「机制写了但从没生效过」。

## 重连

`disconnect()` 里 `enableAutoReconnect = false`,而 `connect()` 从不改回 true ——
登出再登录后,这条连接就永远失去了自动重连能力(configUpdate 那条 watch 上尤其
明显)。改成用 `closedByUser` 表达「用户主动断开」的意图,和 `enableAutoReconnect`
这个**配置**分开。

`scheduleDisconnect` 的回调里断完紧接着一句 `enableAutoReconnect = true`,而
close 是异步的 —— 等 onclose 跑到时标志已经翻回来了,1 秒后又自动连上。那个
「15 分钟空闲省资源」从来没真正断开过。现在只断开,不做事后翻转。

重连的 setTimeout 没存句柄,组件卸载后照样触发 `connect()`,在已销毁的组件上
又建一条连接。现在 `disconnect()` 里 clearTimeout。

退避从「线性 ×5 次」改成「指数 + 抖动、30 秒封顶、次数不封顶」。原来 1+2+3+4+5
只有 15 秒,后端 deploy 重启一次就超了,之后这条连接死到用户刷新为止。抖动是
为了避免一个班几十台机器在同一毫秒一起冲回刚起来的后端。另挂 online /
visibilitychange,网络恢复或切回标签页立刻重连,不必等退避走完。

所有 socket 回调改成闭包住局部 ws 并在入口 `if (ws !== this.ws) return`,旧连接
迟到的 onclose 不再污染新连接的状态 —— 也是让 `disconnect()` 能被 onclose 识别
出来的关键。

## 订阅重放

`pendingSubmissionId` 一发送成功就清空,它只解决了「还没连上就 subscribe」,
**没解决断线重连**。而真正会丢结果的恰恰是后者:服务端收到 subscribe 会回一份
当前状态,掉线期间错过的推送就是靠这次重放补回来的;不重新订阅,重连后只收得到
「将来」的事件,可结果已经是过去式了。改成订阅意图保留到显式 `unsubscribe()`,
每次 onConnected 都重发。

这套逻辑原来只有 SubmissionWebSocket 有,FlowchartWebSocket 是 send 失败打一行
日志了事 —— socket 一掉,那次评分结果就再也回不来,按钮一直转圈。提成公共基类
SubscribingWebSocket,两条通道共用。

流程图另加轮询兜底:提交后 5 秒 WS 还没出结果就每 3 秒拉一次,读 status 2/3
结算,3 分钟上限。判题那边一直有兜底,流程图这边没有,而 Redis pub/sub 是发完
不管的,worker 推的那一刻连接不在就永远丢了。

`useSubmissionMonitor` 里 `watch(wsStatus, ..., { immediate: true })` 的回调在
watch() **返回之前**就同步跑了,已经连着时 `unwatch` 还是 null,if 不成立,
watcher 永远停不掉:每提交一次泄漏一个,往后每次重连它们都会把各自那个早就判完
的旧 submissionId 重新订阅一遍。整块删掉,直接 subscribe —— 基类已经管了时序。

WS handler 原来不校验 submissionId。学生同时开着几道题的页面时,每条连接都订在
同一个用户 topic 上,别的页面的评分结果会被当成自己的。

## 会话

握手时校验过一次会话就再也不管了,这条连接却能挂几个小时:用户在别的标签页
登出、或者会话本身到期,旧 socket 照样收推送。加一条 60 秒一轮的巡检,用
Redis EXPIRE 一条命令同时完成「判断存在」和「续期」(续期是必要的:只开着页面
挂 WS 的人一次 HTTP 请求都不发,不该被算成不活跃踢下线)。Redis 抛错时整轮
放弃,绝不因为一次抖动把全班踢下线。

禁用只改数据库的 isDisabled 列、不动 Redis 里的会话,巡检永远发现不了。加
`session:revoked` 频道主动通知,**两种作用域不能混**:

    { token }   用户登出。只断这一张会话 —— 同一个人在别的设备上是另一张
                会话,按 userId 广播会把他手机上的登录一起踢掉
    { userId }  账号被禁用。所有设备都得断

先发一帧 force_logout 再隔 100ms 断开。只断不发的话前端只看到一次普通掉线,
会照常重连、页面上还显示着登录态。token 不进帧里 —— 那是 httpOnly cookie 的值,
推到 WS 上就等于交给了 JS,匹配全在服务端做。

前端在协议层拦截 force_logout(和 pong 一样,不下发给业务 handler),并主动
disconnect —— 否则会一路 401 重连到退避上限,正是这机制要消掉的浪费。表现刻意
和 utils/api.ts 里 account-disabled / login-required 两支保持一致:同一件事从
HTTP 和 WS 两条路进来,学生看到的不该有两个样子。

## 开销与安全

`void handleMessage(...)` 是裸的,里面有两次 DB 查询和一个会抛的 schema.parse,
库抖一下就是一个 unhandled rejection(隔壁 bridgeSubmissionEvents 两处都接住了,
只有这里漏了)。

ping 提到用户查询之前。原来的顺序是「先查 user 再看消息类型」,每个客户端每
30 秒都要为一次心跳打一趟数据库。禁用用户不会因此漏网:推送路径上 bridge 会查,
subscribe 这条真正读数据的路径下面照样查。

bridge 两条 per-user 通道都先看 `server.subscriberCount(topic)`,没人订阅就别
查库了 —— 判题高峰期绝大多数事件的目标用户此刻并不在线。

flowchart 评分失败原来把 error.message 原样推给学生、前端直接弹出来,AI provider
的地址和内部报错就这么进了浏览器。改成真实原因写服务端日志。

加每连接令牌桶(20 突发 + 每秒回填 2)。一条 subscribe 在服务端是一到两次数据库
查询,一个学生开着一条 socket 狂发就能压住库。正常流量离阈值几十倍远。

升级时校验 Origin。会话 cookie 是 SameSite=Lax、WS 握手不是导航,跨站页面本来就
带不上 cookie,所以这是防御纵深不是唯一防线。同源放行;本机开发(Vite 5173 →
API 3000)自动放行,且只在两边都是本机时成立 —— 生产环境 url.hostname 是正式
域名,这条永远不触发;跨域部署走 ALLOWED_WS_ORIGINS。不发 Origin 的一律放行:
真正的攻击面是带着受害者 cookie 的浏览器页面,而浏览器一定会带 Origin。

顺带:useConfigWebSocket 的 handler 从 onMounted 挪到同步注册(调用方在 setup
阶段就 connect() 了),删掉每条消息打完整内容的 console.log 和死字段
ws.data.username。

## 没动的

题目页上并没有两条 /ws/submissions —— Form.vue 里 SubmitFlowchart 和 SubmitCode
是 v-if/v-else,互斥。学生实际是 2 条连接:全站一条 /ws/config + 一条
/ws/submissions,正常,不必合并。

## 验证

55 个用例,分六组打桩跑(假 WebSocket + 假计时器;会话/限流/吊销三组对着真
Redis):

    重连语义        7   断开后不再自我复活、卸载后计时器已取消、退避封顶、
                        online 立即重连、旧 onclose 不污染新连接
    订阅重放        9   未就绪时补发、重连后重新订阅、unsubscribe 后不再重放
    会话巡检        8   EXPIRE 三态、只断失效的、同 token 只查一次、
                        Redis 抖动时一个都不踢
    Origin/限流    13   跨站与跨端口拒绝、生产不因 localhost 开后门、
                        突发额度、按时间回填
    强制登出       13   登出只断同 token(别的设备不受牵连)、禁用断所有设备、
                        巡检先通知再断
    前端登出        5   两支表现、收到后不再重连、两条通道只处理一次

前两组做了改动前/后对比,老代码该挂的都挂了 —— 「重连后自动重新订阅」正是这么
跑出来的,此前我以为 pendingSubmissionId 已经覆盖了这种情况。

apps/api 的 tsc 和 apps/web 的 vue-tsc 都干净,仓库既有测试照常通过。

**SubmitFlowchart.vue 的轮询兜底未经运行时验证** —— 在 SFC 内,没搭组件挂载
环境,只过了类型检查和人工核对。要验的话,停掉 worker 提交一次流程图,看 5 秒
后是否转入轮询、3 分钟后是否给出超时提示。

这批改动动了 WS 的行为面(限流会断连接、Origin 会拒绝、巡检会踢会话),上线前
建议手测:提交代码看判题、切流程图看评分、后台改配置看全站生效、开两个标签页
在一个里登出、禁用一个在线学生看另一端反应。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 05:07:26 -06:00
6ac458e6f3 refactor(WebSocket): 判题/流程图推送改用驼峰,去掉没人读的三个字段
submission_id / time_cost / memory_cost / err_info 是 OJ2 两端自己定的线上格式,
没有第三方消费(/ws/submissions 只有 apps/web 一个客户端),没理由留着 snake。
flowchart 那条更别扭:同一个对象里 submission_id 是 snake、criteriaDetails 是驼峰。

三个字段直接删掉而不是改名 —— 它们是从 statistic_info 原样抄出来的一份,前端
一处都没读过(useSubmissionMonitor 只用 submissionId / result / status)。耗时和
错误信息在提交详情里本来就有,判完了去拉一次就是,不必让推送顺带背一份 JSONB
的形状。score 保留,它不涉及大小写。

**没动的都是有外部约束的**,别顺手一起改:

- 发给判题沙箱的请求体(language_config / max_cpu_time / max_memory /
  test_case_id / io_mode)和它回的字段(cpu_time / memory / test_case)——
  那是沙箱的 API,不是我们的
- 测试点 info 文件的键,沙箱直接读那个文件
- submission.statistic_info 里的 time_cost / err_info / ast_results ——
  判题机按这套写,12 万条历史提交就是这形状
- acm_problems_status、progress_detail 这些存量 JSONB
- AST 规则键(for_loop)、成就指标(accepted_count)、reaction 语义键 ——
  那是词汇表标识符不是字段名,for_loop 还要映射到 tree-sitter 的 while_statement

验证:起 dev 栈(api + worker + 沙箱),学生账号真提一次代码,抓 /ws/submissions
的帧:

    {"type":"submission_update","submissionId":"bf13b7f0…","result":6,"status":"pending"}
    {"type":"submission_update","submissionId":"bf13b7f0…","result":7,"status":"judging"}
    {"type":"submission_update","submissionId":"bf13b7f0…","result":-2,"status":"finished","score":0}

subscribe 帧也换成 submissionId 并被接受(否则会回一个 error 帧,没有)。
流程图那条路径要 AI 评分才跑得起来,本机没配,只做了类型检查。

前后端同一个 docker 栈一起构建部署,没有版本错配窗口。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:48:54 -06:00
8ae14f055a refactor(站点配置): WS 直接推契约字段名,去掉前端那层 snake→驼峰胶水
上一版是在前端加 toCamel 把 `enable_maxkb` 换成 `enableMaxkb`。但 snake_case
是 options 表从 Django 继承来的**存储格式**,只该活在库里;WS 这一跳两边都是
OJ2 自己写的,没理由让前端再维护一层换名。

后端改成广播 OPTION_KEYS 的 key(契约字段名)而不是 value(表列键),前端直接
按名字赋值。库里的列名一个字没动。

顺带清掉两处已经死掉的客户端→服务端配置推送:

- admin/setting/config.vue 保存后调 updateConfig("enable_maxkb", ...) 和
  ("submission_list_show_all", ...)。那条 ConfigWebSocket 从没 connect() 过,
  send() 直接返回 false;就算连上了,服务端的消息处理也只认 ping / subscribe,
  会回一个 error 帧。广播本来就由后端在 POST /admin/website 里做,八个键一个
  不落,这两行纯属多余,且用的正是刚废掉的 snake 键。
- ConfigWebSocket.updateConfig 一并删掉,免得再有人调一个静默失败的方法。
  这条通道是单向的,原地留注释说明。

验证:dev 栈 + headless chromium 走 CDP 抓 Network.webSocketFrameReceived。
后台保存配置后,页面收到的八条帧的 key 全是驼峰(websiteName / enableMaxkb
/ submissionListShowAll ...),且页面不刷新,document.title 和页头站点名当场
跟着变。小助手那四条路径重跑一遍照旧:未登录不加载、登录后出现、后台关掉当场
消失、开回来当场重现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:42:14 -06:00
0f34d67cc8 fix(站点配置): WebSocket 广播的 key 是 snake,store 字段是驼峰,实时生效一直空转
后端推的是 options 表里的 snake_case 键:

    {"type":"config_update","key":"enable_maxkb","value":false}

前端 configUpdate.ts 收到后写的是 `if (data.key in configStore.config)`,而
store 里的字段是驼峰(enableMaxkb)。这个 `in` 判断**永远是 false**,一条配置
都写不进去 —— 站点名称、页脚、班级名单、允许注册、提交列表看全部、知识库挂件,
全部都要刷新才生效,且没有任何报错。`conf.ts` 里那句「前端 configStore.config
用的就是这套键名」是写错的,八成就是照着它写的这段代码。

加一层 snake → 驼峰的转换。八个键都符合通用规则,所以用正则转而不是列表,
将来加键不用改这里;转完认不出来的键直接忽略,别把 store 撑出野字段。
站点改名时顺便同步 document.title —— getConfig() 里本来就是这么设的,
只更新页面里那份、标签页还是旧名字说不过去。

顺带把 MaxKB 挂件那块理干净:

- 加载条件改成「已登录 且 后台开关打开」。挂件本来就要登录才能用,原来对匿名
  访客也照样注入。
- 删掉它自己那条 /ws/config 连接和 handleConfigUpdate。store 修好之后,
  原有的 watch 自然就跟着动了;多开一条连接是重复的,而且没登录时会撞 401
  (后端 /ws/config 要求会话),控制台常年一条「[WebSocket] 连接错误」就是它。

匿名访客不做实时生效:/ws/config 保持要求登录,不对外开不鉴权的 WS 端点。
没登录的人下次刷新页面时拿到新配置。

验证:起 dev 栈,用一个假的 MaxKB embed 端点(返回建出 #maxkb-chat-button 的
脚本)顶掉真实地址,headless chromium 走 CDP 看 DOM:

    ① 开关=开、未登录        → 挂件不在,script 标签 0
    ② 开关=开、已登录        → 挂件在,  script 标签 1
    ③ 后台关掉(页面不刷新)  → 挂件消失,script 标签 0
    ④ 后台开回来(不刷新)    → 挂件重现,script 标签 1

另外把其余三条 WS 通道的键名也对了一遍,没有同类问题:submission_update
(submission_id / time_cost / memory_cost / err_info)两边都是 snake;
flowchart 的 criteriaDetails 两边都是驼峰且 handler 没读它;成就通知的字段
全是单词,不涉及大小写。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:34:51 -06:00
d3757954cd fix(AI 学情报告): pinnedOnly 那支返回裸数组,页面每次打开都白屏
Some checks failed
Deploy / deploy (push) Has been cancelled
`GET /admin/ai/reports` 同一个 URL 返回两种形状:带 limit/offset 时是
`{results, total}`,带 `pinnedOnly=true` 时是裸数组。前端 getPinnedAIReports
声明的返回类型是 AdminAiReportList、读的是 `res.results`,于是
`pinnedReports.value` 被赋成 undefined,模板里 `pinnedReports.length > 0`
在渲染时抛 `Cannot read properties of undefined (reading 'length')`,
整个页面白屏。空库也照抛 —— 裸数组同样没有 .results。

置顶那支改成同样的 `{results, total}` 信封,total 就是条数。「不分页」的意图
没变,只是别再让一个 URL 有两种形状:调用方没法照着一个类型写,类型标注也就
成了谎话。

验证:本机起 dev 栈,用 headless chromium 走 CDP 抓未捕获异常。修之前
/admin/ai/reports 稳定复现 `TypeError ... at Proxy._sfc_render
(src/admin/ai/list.vue:182)`;修之后同一页面零异常,且置顶提示条正确渲染出
「以下 2 位用户的 AI 分析报告已被锁定」加两个用户标签、表格 3 行,PIN 切换
往返(取消 → total 1、再 PIN → total 2)也正常。

顺带把另外 19 个后台/学生端页面扫了一遍,只有这一处抛异常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:18:58 -06:00
a75c70c82d perf(后端): 干掉 14 处 N+1 查询
Some checks failed
Deploy / deploy (push) Has been cancelled
列表接口按行发查询是从阶段 3 一路带过来的写法:`Promise.all(rows.map(...))`
看着是并发的,但每行都往库里打一次,行数一多就是几百上千次往返。同样的模式
也散在几个后台批处理和写入路径里。全部改成「先收集 id,一条 inArray/group by
查回来建 Map」——`routes/problem.ts` 的 getProblemTags 早就是这么写的,这次
只是把剩下的地方对齐。

用户可见的列表:

- `GET /problem-sets` 每行 5 条(题目数/我的进度/奖章/已获奖章/创建者),
  limit 上限 250 就是 1250 次往返。改成固定 5 条,与行数无关。
- `GET /contests` 每场比赛一条 creator 查询。后台的比赛列表本来就是 join
  出来的,只有这条公开列表漏了。
- `GET /admin/problems`、`GET /admin/contests/:id/problems` 每题一条标签查询。
- `GET /admin/problem-sets` 每行 3 条;`.../badges` 每个奖章一条 count。

后台批处理:

- `refreshContestJoinedForAll` 原来是每个用户 1 条 count 加一个独立事务里的
  insert/select for update/update。改成一条 group by 出全部用户的场次,再分批
  upsert,`metrics || excluded.metrics` 是 jsonb 浅合并,只覆盖 contest_joined
  一个键,其余指标原样保留 —— 合并在一条语句里完成,for update 那把锁不再需要。
- `rescanAchievement` 补发循环、`unlockAchievements`:命中的一次插完,
  onConflictDoNothing 的 returning 就是真新解锁的那批,unlockCount 改成一次 +N。
- `resyncProgress` 逐行 UPDATE 改成一条,completed 用 least() 夹住。

写入路径:

- 标签解析抽出 normalizeTagNames + findTagsByName(一条 lower(name) IN),
  新建题、改题、批量打标签三条路共用。
- 克隆比赛:题面一条 INSERT、标签一条 SELECT 加一条 INSERT。新旧题的对应
  关系靠 _id 认,不依赖 returning 的行序。
- `POST /admin/website` 8 个键一条多行 upsert。
- 题单奖章判定一次插完。

`/ai/duration`:原来每个时间桶两条查询、桶之间还串行,一年 12 个桶 24 次往返。
改成先算桶、再一条查询把整段区间拉回来在内存里分桶。时间戳用
`extract(epoch) * 1000` 取毫秒回来比,别指望 Date.parse 认 pg 那个
`2026-08-12 00:00:00+00` 格式。**相邻桶首尾相接、两端闭区间**(落在边界上的
提交两个桶都算)这条旧语义是照搬的,不要顺手改成半开区间。

验证:本机起 dev 栈,造了覆盖各分支的种子数据(创建者重复的题单、零题目/零
奖章的题单、completed > total 的脏进度、除不尽的百分比、大小写混写的已有标签、
带/不带标签的比赛题、正好落在分桶边界上的提交),旧代码跑一遍、新代码跑一遍:

- 51 个接口响应逐字节一致
- 12 张表的快照逐行一致(唯一差别是 progress_detail 里的 submit_time 墙钟值)
- 打开 log_statement=all 数过条数,例如 `GET /admin/problems` 20 道题
  24 → 5,`GET /problem-sets` 28 → 8,`/ai/duration` years:1 28 → 5,
  202 个用户的成就补发 1828 → 614

一处可观察的行为变化:补发现在整批共用一个 unlockTime,原来是每人一个
new Date()。rescanAchievement 上方的注释本来就写着补发会给几百人盖同一个
时间戳、前端据此只显示「已获得」不显示日期,所以这个方向是对的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:43:57 -06:00
bb4b5825c3 feat(迁移): 0000 改成可执行,空库能自举
0000_crazy_gateway.sql 原本是 drizzle-kit pull 的产物,整份被 /* */ 包着、
可执行语句 0 条,所以任何新库的结构都只能先手工 psql 灌一遍 schema.sql 再打基线。
现在它的内容由 docs/specs/schema.sql(2026-08-07 的生产 pg_dump --schema-only)
机械转换而来:去掉 psql 专有指令 12 条、去掉 7 张 Django 遗留表及其索引外键 41 条,
保留 212 条语句、顺序不动。转换脚本入库在 docs/spikes/。

不含 Django 那 7 张表,是因为 meta/0000_snapshot.json 从来就没有它们(pull 当时
tablesFilter 滤掉了),不建它们才和快照一致;0002 那串 DROP ... IF EXISTS 在新库上
空转、在生产库上真删,两边跑同一串迁移落点相同。

改 0000 对生产库没有影响:migrator 只比 created_at、从不校验 hash,而生产库那行
baseline-0000-faked 早把它挡在门外了。

migrate.ts 配套:空库直接从 0000 建起(并自建 drizzle 记账表——原来这步由 drizzle 的
migrate() 顺手做掉);自举时不触发破坏性闸门,因为空库上没有数据可丢,拦下来只会逼
每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE,把这道闸训练成习惯动作。「有表但没基线」
仍然 exit 3。

验证:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」这条老路子跑出来的
结构,pg_dump --schema-only 逐字节一致(734 行,零差异)。

转换时踩到两个坑,都写进注释了:注释里不能出现 statement-breakpoint 的字面量
(readMigrationFiles 纯文本切分,会把注释从中间切开);过滤 Django 对象要看
public.X 形式的对象引用,不能扫语句字样——user 表有一列叫 auth_token。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:54:31 -06:00
2e871cb5b0 refactor(迁移): 换掉 drizzle 的执行器,一条迁移一个事务
drizzle 的 migrate()(pg-core/dialect.js)把所有待执行的迁移塞进同一个
session.transaction(),两个后果:第 3 条失败会把第 1、2 条一起回滚,和 Django
migrate 的逐条提交语义不一样;以及 CREATE INDEX CONCURRENTLY 一律跑不了,
没有任何开关。

现在自己按 journal 逐条执行,一条一个事务。文件第一行写 `-- oj2:no-transaction`
的迁移走裸执行(简单查询协议——扩展协议会把语句包进隐式事务块,CONCURRENTLY
照样被拒),代价是没有回滚,所以这类迁移一个文件只放一条语句。

记账行的写法和 drizzle 完全一致(hash = 整文件 sha256,created_at = journal 的
when),migrator 又只比 created_at、不校验 hash,两套执行器可以互换。

顺带:日志和报错说得出迁移文件名了(readMigrationFiles 不返回 tag,自己读一遍
journal),失败时打印的是 Postgres 那句话而不是 postgres.js 的内部堆栈,
新增退出码 5。

实跑验证(本机 Docker,生产 schema 灌进探针库):CONCURRENTLY 带标记成功、
indisvalid = t,去掉标记复现 "cannot run inside a transaction block";
0003 成功 + 0004 失败时,0003 留下、0004 的首条合法语句没留下。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:54:01 -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
2ee61756b8 perf(提交列表): count 去掉无谓 join、只取序列化用得到的列
用户反馈「HEADER 点提交后页面空白一段时间才有数据」。拿生产快照
(12.3 万条提交 / submission 表 169MB)在本机实测,问题分两头。

**后端**(本次改的):

- count 无条件 `innerJoin(problem)`,但 problem 只有按题号筛选时才出现在
  where 里。带 join 的 count 走 seq scan 78ms,去掉 join 走索引 7.5ms。
- 行查询 `select submission.* + problem.*` 把两张表所有列都拉回来,包括
  submission.code(学生源码)、info、ip,以及 problem 的 description /
  hint / samples / answers / flowchart_data / sql_display —— 这些字段 map
  的时候一个都没用上。改成只 select 需要的列。
  canViewSubmission 的参数类型随之从整行 $inferSelect 收窄成实际用到的
  字段,完整行结构上仍然满足,详情接口调用不受影响。

公开列表和比赛列表两处是同一份代码,一起改了。

**前端**:

- list.vue 静态 import 了四个只在默认关闭的 n-modal 里用的组件,其中两个
  统计面板还只有老师看得见。光 chart.js 就 197KB,进页面前必须先下完。
  改成 defineAsyncComponent 后本路由增量下载 675KB / 59 个文件 →
  375KB / 43 个文件。
- n-data-table 没传 :loading,等接口这段时间表格就是一片空白,连转圈都
  没有 —— 这是「页面空白」最直接的观感来源。用 try/finally 包,接口抛错
  不会把转圈卡死。
- isAuthed 变化时重复拉了一次今日提交数。它不看登录态,onMounted 那次
  就够了。列表本身仍然重拉(要更新提交编号列的可点击状态),那次不是
  浪费;本想用 userStore.isFinished 把首次请求延后,但 getProfile() 一旦
  reject,isFinished 会永远停在 false,匿名用户就再也看不到列表了。

还有一个更大头的原因是索引用不上,导致每次翻页全表扫 169MB,那部分
需要加索引,走下一个提交。

验证:起真实 API 打生产快照,匿名 / 已登录 / myself / 题号筛选 /
语言+状态 / today / offset=5000 / 比赛列表全部 200,响应体大小前后一致
(2990 bytes),字段没丢。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:55:47 -06:00