xuyue and Claude Opus 5.5
85108df9d8
feat(成就): 下架三个拿不到的成就、理顺稀有度和图标;接口多给本班几人有和「本班」卡片
...
- 0032:下架六边形战士(判题机只剩 3 种语言)、坚持三天/一周(按天算基本拿不到);
不撞南墙改白银、屡败屡战改黄金;百发百中换 💯 、屡败屡战换 💪 ,不再和别的撞图标;
代码行数只算做对的、空行不算,并按新口径重算所有人;已经拿到的一个不收回
- 奖杯收藏家只数上架的成就(判题结算、补发重算、0032 存量一起改)
- GET /achievements:每个成就带全站几人有、本班几人有;看自己时带「本班」(最近拿到、成就最多前五、自己第几);
隐藏成就同一串拿到一档就整串亮出来;连续几周给「现在连着几周」而不是历史最长
- 删掉只有旧成就页在用的 /achievements/summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-13 00:26:37 +08:00
xuyue and Claude Opus 5.5
3ca3b42997
fix(排名): 老师点「本班」不再随手挑个交题最多的班,改成布置过作业的班、上次选的班,都没有就停在全部
...
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-12 23:28:55 +08:00
xuyue and Claude Opus 5.5
890e731a1c
feat(智能分析): 后端改成按「每节课」给数据,AI 小结跟着改,不再把交了好几次的题夸成做得好
...
GET /ai/report 一次给整页:每天分课上的题和自己练的,和班上中间那位比,差一点的、错在哪、
知识点;时间段改成 这周 / 这个月 / 这学期 / 全部。删了 /ai/detail、/ai/solved、
/ai/duration、/ai/heatmap、/ai/pinned,换成 /ai/analysis/latest。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-12 23:20:34 +08:00
xuyue and Claude Opus 5.5
11194543a3
feat(自学): 教程有了「所属项目」和课末的相关题,正文里的代码能直接运行
...
- 0031 迁移:tutorial 加 project 列(从正文「所属项目:」回填),新表 tutorial_problem
挂每课的相关题(两头 CASCADE)
- 学完一课 = 练一练全对(契约 isLessonDone),不再按停留满 3 分钟算
- /tutorials/:id 带相关题和做对没有,/tutorials/:id/exercises 带每道做对没有
- /trial-runs 收 tutorialId:教程里的代码块走同一台判题机跑一组
- 后台:教程列表带练一练 / 相关题数 / 打开过的人,拖动排序、项目改名、挑题候选(必会题在前)
- 自学情况换成学生 × 课的方块格(/learn-analytics/board),默认班和排名页同一套猜法
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-12 09:40:34 +08:00
xuyue and Claude Opus 5.5
eaab224ba8
feat(出题): 测试数据最多 20 组,几百组的题提交详情截图截不下
...
上传 zip 超过 20 组直接报错(编程题、SQL 题都算);出题页加满 20 组后「加一组」
「AI 想几组输入」变灰,导入超过 20 组的 zip 在浏览器里就拦下,改老题超了不让保存。
上限在契约 TEST_CASE_MAX_CASES。线上超过 20 组的题已经手工缩到 20 组。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-10 14:07:30 +08:00
xuyue and Claude Opus 5.5
2553c6ddfe
feat(排名): 个性签名挂到排名上,领奖台上每人一句、「你」卡里就地改;全服排名只列到第 100 名,展开不再卡死
...
- 领奖台:头像旁边一个签名气泡(手机在头像上面),只露两行,长的指上去看全文
- 「你」卡:自己的签名带「改」,就地改不用跳设置页;没写的提示「写一句个性签名」;前一名、后一名名字下面带他们的签名
- 赛道:桌面有签名的名字后面一个引号,指上去看;手机直接写在名字后面
- 个性签名以后限 30 个字(老的长签名不动,榜上只露两行)
- 老师在名单「⋯」或台上气泡的「×」能清空不合适的签名(DELETE /rankings/mood/:userId)
- 全服名单最多列到第 100 名:原来展开会一次摊开一千五百多人把页面卡死
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-10 09:52:06 +08:00
xuyue and Claude Opus 5.5
d83804b814
feat(班级PK): 班级 PK 只比两个班都布置过的同一批题,按做对的人占几成分输赢;登录了就能看,AI 分析由服务端现算
...
- 新接口 GET /rankings/pk:一个班过半的人交过算布置过,至少两个班布置过的题才比;
显示的整数百分比一样算平;口径和排名页一样(这学期、比赛不算、不计入排名的人不算)
- 不给班就用自己的班(老师用默认的班),只给一个班就配同年级一起布置过的题最多的班,
一样多挑班级对抗排得更前的
- 去掉 POST /classes/comparison(原来不登录也能查,综合分、四分位数、优秀率都按所选几个班算)
- /ai/class-pk-analysis 只收班号和时间段,数字和页面上的是同一份
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-10 09:06:21 +08:00
xuyue and Claude Opus 5.5
3c6e4a1e7c
feat(排名): 班级详情改成右侧抽屉、老师能在里面直接出 AI 分析;排名页加上个人智能分析入口;用词改成前一名 / 后一名
...
Deploy / deploy (push) Canceled after 0s
- 班级对抗点班名打开班级详情:人均做对和年级比、中间那位同学、做对过的人、全班分布点阵、
每周人均新做对;去掉四分位数、标准差、综合分。老师多看到「要多关心的同学」(这学期做对不到
3 道)和 AI 分析,报告直接在抽屉里出
- 班级 AI 分析只传班号,数字由服务端按抽屉同一套现算(services/ranking.ts),报告和抽屉对得上
- 学生「你」那张卡底下加「看我的智能分析」;老师名单「⋯」加「看他的智能分析」;班级对抗右上角
「班级 PK ›」
- 「要追的 / 追你 / 冲得最猛 / 对手」会被学生读歪,改成「前一名 / 后一名 / 进步最大」,
对前后一名只说「比你早 / 晚几秒做到几道」
- 老师打开排名页默认看全部,班级下拉框第一项「全部」,选了班就切到本班
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-10 07:58:52 +08:00
xuyue and Claude Opus 5.5
646c57c748
refactor(题单): 难度、状态、分数、提示、总分、百分比和 problemset_submission 表不再读写,先补默认值、下一步再删
...
Deploy / deploy (push) Canceled after 0s
机房和服务器共用一个库、不一定同时升级,所以分两步退役:0027 只给还非空的几列补默认值,
新代码插行不用再填、没升级的那边照旧能跑;两个站点都换上新代码后,再单独一条迁移删列删表
(要手工放行破坏性迁移,语句写在 0027 的注释里)。
进度算法随之去掉分数和百分比,判题记进度不再往 problemset_submission 写一行(从来没人读)。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-09 22:08:15 +08:00
xuyue and Claude Opus 5.5
8c53e28dcb
feat(题单): 「截止时间」改成「布置期」,布置期内没加入的人也藏旧代码;老师能看全班情况,后台一次加好几道题
...
- 布置期(列名仍是 end_time,机房共库不改名)+ 新列 assigned_at:布置期内题单里这几道题,
学生在布置开始之前交的代码先藏起来,不管加没加入,堵住「先抄再加入」;做对或到期放出来,不拦做题
- 「总分达到 N」奖章换成「做对 N 道」,草稿并进「不公开」(0026)
- /problem-sets 带布置期、必做题数、奖章获得时间;题目带做对时刻、错几次、旧代码藏着没有
- 新增 /problem-sets/:id/class-view(老师看一个班:真名 × 每题,加入前就做对过)、/problem-sets/locks
- 后台:批量加题、拖动排序、切必做;去掉状态、难度、分数、提示和单独的进度页
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-09 22:01:39 +08:00
xuyue and Claude Opus 5.5
b94e028ff4
feat(比赛): 榜单一次给全,带名次变化和每题谁最先做对;期中、期末考试中学生看不到排名;老师能看全班情况
...
- GET /contests/:id/scoreboard 代替 /rank:整张榜、5 分钟前的名次、每题做对 / 做过人数和学生里最先做对的人(不再用 is_first_ac,老师赛中试做会占掉它)
- 期中、期末进行中:学生只拿到自己那一行,人数清零、「全班最先」也不给,考完一起公布
- GET /contests/:id/class-view(老师):真名、每人最后一次交、没进来的人 —— 比赛不绑班,来了一半以上的班才算这场的班
- 比赛列表带题数、参赛人数、我的名次和做对数,能筛「我参加过的」「期中 · 期末」
- PUT /admin/contests/:id/problems/order:比赛题按顺序重编 1、2、3,比赛结束后不让动
- 后台「审核」列表接口去掉,并进前台老师的成绩页;「看过了」的写接口留着
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-09 20:58:59 +08:00
xuyue and Claude Opus 5.5
ba5b9ada0e
feat(后台): 编程题出题页重做——左边写,右边是学生看到的样子;例子和测试数据的输出都由标准答案跑出来,改老题能直接看、改测试数据
...
- 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后
- 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存
- 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip
- 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测
- 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页
- 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-09 20:07:34 +08:00
xuyue and Claude Opus 5.5
ed7752afbb
feat(题目列表): 学生自己挑题时只看得到一堆标签和提交数,不知道自己练到哪了——改成左栏知识点进度、顶上先说做到了什么
...
Deploy / deploy (push) Canceled after 0s
- 左栏是知识点(可切到主题)和自己的进度:做了一部分写 37/96 加进度条,全做对写「通关」,
一道没做对过的只写题数,不再是一整列 0/108
- 顶上一行:做对几道 · 这周 +n(或连续几周都有做对)· 离哪个成就最近 ·「有 n 道差一点」
(点开才列出来);新生引去「输出入门」;一言照旧在右边
- 每题最后一列是做对 / 做过的人数,代替提交数和按提交算的通过率(和题目页统计同一个口径)
- 「画流程图 / 语法要求 / 有参考图」挂在题目名后面,「全部类型」下拉单独筛这三类,
不再混在排序里;题目页的「代码要求」统一叫「语法要求」
- 「只看没做完」开关(默认关)、「随便来一道」(当前知识点里先挑没做对的简单 / 中等题)
- 画流程图的题,流程图评到 A / S 在状态列也打勾,和题单、课堂条一个口径
- 老师看不到状态列和个人进度;出题者筛选保留
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-09 12:31:49 +08:00
xuyue and Claude Opus 5.5
e7b95d211d
fix(题目): 修掉题目页重构审查查出的问题——提交途中换题会把结果算到新题上、运行例子把对的菱形判错、点评完「下一题」不跳等
...
题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修:
提交与换题
- 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、
弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号
- 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门
- 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题
- 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开
运行例子
- 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」
- C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out)
- Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」
- 比赛里仍然把空格画成「·」、把不同的地方标红
入口与协作
- 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号
- 「看他交过什么」按整名匹配,不再混进学号相近的同学
- 协作中点上下文条、相似题、下一题开新标签,协作不断
- 题单入口给管理员的「⋯」里留「本题提交」
结果与题面
- 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 "
- 流程图 A 级的学生标题下还写着「还没交过」
- 「我的提交」上方补回「答案错误 × 3」这样的计数
- 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评
- 「结果」页签有新结果没看时加粗上色,看过就恢复
- 空画布拖进第一个节点画布会跳一下;画布底下空着一条
- 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚
- 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB)
- 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文
设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-29 00:07:16 +08:00
xuyue and Claude Opus 5.5
83b6b7a81a
fix(题目): 修掉代码审查查出的几处问题——流程图拿到的徽章不弹、点评弹窗读失败时关不掉等
...
- 流程图画到 A / S 拿到的题单徽章、成就原来不弹:流程图那条 WebSocket 只认评分完成 / 失败,
成就消息被丢掉了,要等下次换路由拉 pending 才出来
- 强制点评弹窗读不出点评、或者一直交不上时,「下一题 / 继续」按钮也给出来:原来弹窗关不掉,
只能按浏览器后退;拦下换题时就记上「继续」,按钮文字不会写错
- 「放回编辑器」走正常的换语言路子(画流程图时切回代码编辑器、读那门语言的草稿);
这道题已经不收的语言(0020 摘掉的 Java / JS / Go)置灰,只能复制
- 举着手时刚交对、1.5 秒内点下一题:原来「确定要换题吗」要问两遍(点评完再问一次),
现在同意过的那个地址不再问
- 课堂看板上评分中、评失败的流程图不再算「尝试过」,和学生的课堂条一个口径;
新加的流程图查询和其他查询并行,不再多等一轮数据库
- 课堂条的数据只认最后发出的那次请求:进页面那次后到的话会把刚做完的那道盖回「没做」
- 「A / S 算做完」收进契约,前后端只留一份;协作中老师的判断统一用 useTeacherCollab;
几处放错位置、说法过时的注释
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-28 19:35:07 +08:00
xuyue and Claude Opus 5.5
3fe3caa944
feat(流程图): 流程图画到 A / S 算这道题做完——题单进度、课堂条都认;加入题单之前画的图先藏起来
...
Deploy / deploy (push) Canceled after 0s
- 流程图评到 A 或 S,记进所有已加入、且包含这道题的题单(和判题那一路同一个口径,不看从哪个
入口交的),徽章和题单类成就照样推送。原来题单进度只在判代码时记,流程图作业在题单里永远是
「没做」。流程图提交的 id 进不了 problemset_submission(外键指向代码提交表),所以只写
progress_detail —— 解锁旧提交的那道闸看的也是它
- 课堂条(/me/class-activity)的 myStatus:流程图 A / S 也算 accepted,评完的非 A/S 算 tried
- 题单防抄闸门补到流程图上:加入题单之前画的图,对本人藏起来(列表 showLink、详情 404、
current / history 不数它),规则和代码提交同一份(problemSetJoinTimes 挪进 services/problemset)。
原来加入之前画到 A 的那张图点一下就能载回画布、交上去就是 A
- 新增 GET /problems/:id/flowchart/scores:一次给出自己评完的每一次的分数和等级,外加被藏起来
的次数(原来 history 一次只给一条,看分数走势得一页一页翻)
- 「A/S 算过关」和按分数定等级收进 flowchart/grade.ts;判题那边的记账 + 通知抽成
recordSolvedAndNotify,两条路共用
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-28 17:56:35 +08:00
xuyue and Claude Opus 5.5
71670e609b
feat(成就): 新增「最长连续新通过周数」指标,按课表的节奏算连续;修掉比赛成就重扫因孤儿提交失败
...
Deploy / deploy (push) Canceled after 0s
按天的连续成就对这里的学生基本拿不到:2025 秋最长连续交题天数的中位数是 1 天,
连续 7 天的只有 3 人。按周算,连续 2/4/8 周分别有约 73%/49%/8% 的学生做到过。
- 新指标 max_ac_week_streak:连续几个自然周(周一起、东八区)每周都至少新通过一道题
(不含比赛)。判题结算时增量累计(比按天那段多一道判断:重判更早的提交不会把连续清零);
后台建成就触发重扫时,先从提交记录全量补算(refreshAcWeekStreakForAll),同 contest_joined
- 老的「坚持三天」「坚持一周」不动:下架的成就连已获得的人也看不到
- 修:库里 28 个已不存在的用户留下了 935 条提交(其中 11 人有比赛提交),全量补算往
user_stat 写时撞外键,整次失败——比赛类成就的重扫因此一直是失败的;两处都改为只算
还在的用户。本地修好后重扫「初入赛场」补发了 3 人
- 本地验证:连续 2/4/8 周补发 1086/618/123 人,与独立 SQL 一致
线上要在后台手动建成就(指标选「最长连续新通过周数」),建的时候自动补发。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-28 08:27:25 +08:00
xuyue and Claude Opus 5.5
2a4405d2ea
feat(题目): Python 语法错误改成中文说明,在编辑器里标出位置,中文标点可以一键换成英文
...
Deploy / deploy (push) Canceled after 0s
中职学生看不懂英文报错。原来提交前用浏览器里的 Skulpt 拦语法错误,只弹一句
「第 N 行存在语法错误」——Skulpt 九成报的是 bad input / bad token,没有列号。
- 提交前的语法检查挪到服务端:/code/format 先用 CPython 编译一遍(只编译不执行),
api 镜像加 python3-minimal(3.13,与判题机同版本,+17MB),前端删掉 Skulpt
- 报错按规则表翻成中文(pythonError.ts),生产库 2695 条 Python 编译错误覆盖 99.7%;
提交前拦下的和判题回来的编译错误共用同一张卡片
- 编辑器里标红出错行和 ^ 指的位置
- 中文标点(历史编译错误的 22%)一键换成英文,跳过字符串和注释、处理 f 字符串花括号;
594 条里修好 593 条,经 tokenize 核对没有改到字符串内容;走编辑器事务,可 Ctrl+Z
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 21:47:01 +08:00
xuyue
d25b5dbe6d
update
Deploy / deploy (push) Canceled after 0s
2026-09-27 19:36:28 +08:00
xuyue and Claude Opus 5.5
9412ba3cf6
refactor(契约): 展示题号统一叫 problemDisplayId,题单 id 统一叫 problemSetId
...
Deploy / deploy (push) Canceled after 0s
前后端同时改,部署前开着的旧页面要刷新一次(题号列会是空的、提交筛选会失效)。
- 挂在非题目对象上的展示题号,原来叫 problem(字符串)或 displayId,统一成
problemDisplayId:提交列表、流程图列表、统计的 lastFailure / items /
今日最热题、站内信里的提交、后台题单的题目列表。别处的 problem 都是对象
或数字 id,这几处是字符串,最容易看错
- 列表筛选的 query 参数 ?problemId= 实际传的是展示题号,改成
?problemDisplayId=(提交列表、流程图列表、三个统计接口)
- 契约里 problemsetId / problemSetId、problemset / problemSet 两种拼法并存,
统一成 problemSetId / problemSet,和 ProblemSet* 类型名、problem-sets
路径、前端路由参数一致。drizzle 的列键跟表名走,没动
- aiDetail.user(用户名字符串,而契约里别的 user 都是对象)→ username。
ai_analysis.data 里存的历史快照仍是 user,没有代码读回它
- 后台题单题目表的列是按字符串键 'displayId' 取的,类型检查抓不到,一并改
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 16:08:31 +08:00
xuyue and Claude Opus 5.5
5ae830a7f9
refactor: 一批名不副实的命名改掉(只动一端,不碰契约)
...
后端:
- getUserByToken → authenticateToken:它会删会话、续 TTL、记在线,不是纯读
- todayStart → dayStart(instant):本来就接受任意时刻,不只是「今天」
- objectValue → asRecord,删掉 4 份一模一样的私有拷贝和 `as toObject` 别名
- 报错文案 "does not exists" / "Already be a public problem" 改通顺
前端:
- utils/types 里 Contest / User / Problem 三个别名删掉:Contest 原来指后台
比赛、契约里同名的却是学生侧。现在 Contest = 学生侧、AdminContest = 后台,
后台用户表单叫 AdminUserForm,题目详情直接用 ProblemDetail
- SubmissionInfo → ContestSubmissionInfo,免得和 submission.info 撞名
- utoa / atou → compressToBase64 / decompressFromBase64(实际是 zlib + base64),
encode / decode → base64EncodeUtf8 / base64DecodeUtf8
- filterResult → toProblemRow、toProblemListItem → toAdminProblemRow,
行字段 submission / rate → submissionCount / acRate
- 组件:Modal → AddFromLibraryModal、Form → EditorToolbar、
ContestEditor → BasicEditor(题单也在用它)、rank 的 Index → RankMedal、
Name → UserNameCell、Password → PasswordCell、ContestType → ContestAccessTag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 16:08:21 +08:00
xuyue and Claude Opus 5.5
e17da63fc6
fix(上传): 头像和题面插图按文件头认格式,不信扩展名
...
原来只看扩展名:改个后缀什么都能落盘,再由 /public/* 按扩展名配 content-type
发出去。线上 Caddy 有 nosniff 兜着,但不该把非图片存到盘上。
- services/image.ts:按 PNG / JPEG / GIF / BMP 的文件头识别,返回落盘用的扩展名 ——
以内容为准,改名成 .png 的 JPEG 存成 .jpg,content-type 才对得上
- 头像上传、后台插图上传都走它;插图那边扩展名白名单保留当第一道快速筛
- serveUpload 自己也带 x-content-type-options: nosniff,不只靠 Caddy
实跑核过:真 PNG / GIF 正常;改名成 .png 的 JPEG 存成 .jpg;改名成 .png 的 HTML
两个入口都拒;/public/upload 回 image/png + nosniff。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 15:15:36 +08:00
xuyue and Claude Opus 5.5
b105fe3cbd
fix(测试点): 解压前按条目头拦大小,防 zip bomb
...
原来 unzipSync 先把整包解进内存,再逐个查大小 —— 检查之前就已经解完了。
实测一个 300KB 的 zip(10 个 30MB 的全零文件)让进程 RSS 涨 455MB,
容器只有 512MB。
- 第一遍 unzipSync 的 filter 只读目录、恒返回 false,按条目头的 originalSize
累计,单个超 32MB / 总计超 128MB 就抛;第二遍才真解,而且只解 N.in / N.out /
N.sql(N ≤ 500),压缩包里别的文件一律不解
- originalSize 可以造假,但 fflate 按它分配输出缓冲、不扩容(解多了只截断),
所以按它限额就是按实际内存限额
实测:同一个 zip bomb 被拦下时 RSS 只涨 1MB;单文件 300MB 的包按单文件上限拦;
正常测试点照常落盘、CRLF 照常归一、带 ../ 的条目名照常被忽略。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 15:14:12 +08:00
xuyue and Claude Opus 5.5
6406794971
refactor(AI): 学情统计查询从 routes/ai.ts 挪到 services/learning-stats.ts
...
routes/ai.ts 1149 行里约六成是 buildSolved / buildDetail / buildDuration 这些查询
构造器,它们不碰 Context,只是查询。挪出去之后 route 文件剩 585 行,只管
「鉴权 → 取参 → 调统计 → 回包」和 AI 调用本身;targetUser / throttleAi 依赖
Context,留在 route 里。
纯搬移,函数体一行没改。实跑核过:同一个用户的 /ai/detail、/ai/duration(按月、
按周)、/ai/solved、/ai/heatmap 五个响应,改前改后逐字节一致。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 15:09:21 +08:00
xuyue and Claude Opus 5.5
09030240b8
fix(AI): 流式调用加空闲超时、客户端断开即停上游、错误原因不下发
...
- streamChat 加 45 秒空闲超时(每收到一块重新计时,不掐长回答),原来 provider
卡住这条请求就永远挂着
- 加 cancel():客户端断开时 abort 上游 fetch,原来会把 provider 的回答读完白烧 token,
还把「客户端走了」记成 AI 失败;现在留痕写「客户端已断开」
- 推给前端的 error 事件改成固定中文文案(缺 AI_KEY 时是「AI 功能未开启」)。原来
error.message 原样下发,里面可能是 provider 的 HTTP 响应体或数据库报错;
真实原因只进 console.error 和 onError 留痕
用假 provider 实跑核过:正常流式、客户端 1 秒断开时上游被取消、provider 500 时
客户端只拿到固定文案(响应体只在日志里)、provider 挂住 45 秒后收到 error + end。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 15:01:43 +08:00
xuyue and Claude Opus 5.5
fb240d4254
feat(限流): 登录、注册、比赛密码防爆破
...
三处原来都不限次数。学生初始密码弱,/classes/:name/usernames 又能公开列出整班
用户名,等于现成的撞库名单。
services/throttling.ts 加固定窗口计数(INCR + EXPIRE NX,锁定时长就是剩余 TTL),
何时计数由调用方决定:
- 登录按用户名 15 分钟 10 次失败,成功清零;按 IP 15 分钟 100 次失败,成功不清零
(否则拿自己的号登一次就能重置)。机房一个班共用出口 IP,所以 IP 那道放得宽
- 注册按 IP 每小时 100 个成功注册
- 比赛密码按「人 × 比赛」10 分钟 10 次失败
- IP 取 X-Real-IP:api 端口不对外,Caddy 用 {client_ip} 覆盖写入,可信
前端登录、注册、比赛密码三处按新错误码给中文提示。
本机实跑核过:第 11 次错误登录被拦、成功清零(用户名大小写不敏感)、同 IP 换
100 个用户名被拦且别的 IP 不受影响、比赛密码第 11 次被拦。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-09-27 14:51:52 +08:00
xuyue and Claude Opus 5.5
9d271942aa
feat(AI 提示): 题面补上输入输出说明和样例,HTML 去标签
...
Deploy / deploy (push) Canceled after 0s
两段(诊断 / 生成提示)原来只喂 description,而且是 HTML 原文:模型拿不到输出格式
要求,「输出格式不对」这类最常见的 WA 基本看不出来。
- problemBrief:描述 + 输入说明 + 输出说明 + 最多 3 组样例,末尾点明判题逐字比对。
全是学生看得到的内容,两段都放;标准答案和测试点仍只进诊断
- plainText:去标签、段落 / <br> 换行、表格单元格补空格、解码命名和数字实体
(' 这类带前导零的也认),图片留「[图片]」占位;样例原样不去空白
- 阶梯 prompt 版本 3 / 4 → 5 / 6,旧常量标停用保留;编译失败那一档(1 / 2)不动
用 2026-09-14 的生产备份核过:962 道题的题面无残留标签 / 实体、无裸 `<`。
另有 341 道题面带图片、模型看不到,本次未处理。
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com >
2026-09-27 14:28:21 +08:00
xuyue and Claude Opus 5.5
22fc8d63e2
feat(AI 提示): 阶梯延到 L3 思路 / L4 示例
...
Deploy / deploy (push) Canceled after 0s
- 契约 HINT_LEVELS 加两级,HINT_MAX_LEVEL 2 → 4。前端的等级标签和「再多一点提示」
按钮本来就跟着契约和 canEscalate 走,不用改
- L3 分步骤讲思路(中文伪代码),沿用 L0~L2 的零代码规则
- L4 是阶梯上唯一放开代码的一级,上限两行:过滤不再「出现代码就拦」,而是把代码块
非空行、摊平进正文的类代码行、过长的行内代码合起来数,超过 KEY_LINES_MAX 才拦;
没闭合的围栏按开到结尾算。重生成措辞和兜底话术按级补齐
- prompt 版本沿用 3 / 4:LEVEL_COMMON 拆成三句再拼回,L0~L2 的 system 逐字未变
(已对照改前逐字核过),新两级靠 ai_hint.level 区分。不需要迁移
老师按班级 / 作业设最高等级拆到 2e,未做。
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com >
2026-09-23 09:20:24 +08:00
xuyue and Claude Opus 5
954797a9a5
feat(AI 提示): 提示分级(L0 反问 / L1 定位 / L2 概念)与输出后过滤
...
Deploy / deploy (push) Canceled after 0s
提示不再一上来就把话说完。等级记在「学生 × 题目」上,没有单独的表 —— 它就是
ai_hint.level 的历史,当前等级 = 这道题上(最近一次 AC 之后)给过的最高一级。
阶梯(services/hint-level.ts)
- 只有学生点「再多一点提示」才升级(请求带 more),不带就按当前等级再生成一次
- 升一级要先再交一次:锚点是「这一级是**什么时候**开出来的」,也就是这一级最早那条
提示的 ai_hint.create_time,必须有比这个时刻更新的提交才准 +1。锚点不能用提示所在
那条提交的时间 —— 端点谁的提交 id 都认(只校验归属),拿一条老提交去要提示,锚点
就退回到那条老提交的时间,连点两下 more 就能从 L0 爬到 L2,一次新提交都不用交
- AC 之后清零;编译失败自成一档(level = -1),既不消耗也不推进阶梯
- HINT_MIN_FAILURES 3 → 1:门槛的活由阶梯接走了,第一次失败只开放 L0,而 L0 只反问、
什么都不泄露,拦着它没有意义
- canEscalate 由后端算好在 done 事件里给,前端不自己推阶梯
输出后过滤(services/hint-filter.ts)
- 「不要给代码」写在 prompt 里只是软约束,模型忍不住一次就把这一级的意义废掉了。
所以整段生成、过滤通过才推给前端,逐字显示改由前端模拟 —— 边流式边过滤做不到,
发现违规时内容已经在学生屏幕上了
- 判定只用客观、低误报的信号:代码块、过长的行内代码、整行不含中文的类代码行、
和标准答案重合 3 行以上、L0 一句问句都没有
- 违规就重生成一次,只重一次,再不过发写死的兜底话术。重试措辞按档分叉:编译档本来
就允许给片段,对它说「不要出现任何代码」等于用阶梯的标准把这一档也砍了
- 两次都留痕(filter_attempt / filter_blocked / filter_reason),7.5 的输出过滤触发率
就是从这三列出来的
services/ai.ts 加 streamWhole:事件形状和 streamChat 一样,前端不分叉。produce 期间
每 15 秒发一行 SSE 注释当心跳 —— 这条流中间有一大段静默(诊断 20s + 生成 60s +
重生成 60s,最坏 140 秒),而 NPM / nginx 的 proxy_read_timeout 默认 60 秒,超了学生
看到「请求失败」,后端却还在烧第二次调用,那条提示照样落库、照样把等级推上去。
prompt 版本另开 3 / 4(阶梯上每一级都换了 system),编译档仍走 1 / 2 的单段式基线,
两批数据不混在一起。迁移 0021 给 ai_hint 加四列,都可空、不带默认值,已有的行留 null
表示「分级上线前」。
实跑
- 阶梯:在 dev 库上用真实行驱动 decideHintLevel。正常路径 S1→S2→S3 走出 L0→L1→L2,
同级连点 more 不升,AC 之后回 L0,编译档给 L-1 且不推进阶梯。把旧锚点规则复刻出来
跑同一组数据做对照:只拿最老那条提交反复 POST,旧规则 L0→L1→L2(零新提交),
新规则钉死在 L0,正常路径两者行为一致
- 过滤:起假 AI 服务端走完整条链路。L1 摊平代码→重生成后合规(attempt 2 / 未拦),
L0 两次都甩代码块→兜底话术(blocked,reason 两条相连),编译档抄标程→命中「和标准
答案重合 3 行」且追加的是分叉后的措辞
- streamWhole:produce 拖 16.5 秒收到 1 条心跳;同一份流喂给前端 consumeJSONEventStream
只解析出 delta + done(注释行被静默跳过,前端零改动);中途 cancel 断开后 produce
跑完不抛
- api / web typecheck、check:routes、fmt 全过
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com >
2026-09-21 19:03:21 -06:00
xuyue and Claude Opus 5
06ad6745b7
feat(AI 提示): 两段式提示——先诊断出错误标签,再生成提示
...
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的 2b。标准答案能让提示准得多,但不能进生成提示的 prompt
(学生代码里一段注释就能把它套走),所以拆成两段:
- 诊断:看得到标准答案、第一个没过的测试点、带行号的学生代码;出参只允许
{ tag, lines, confidence },safeParse 过闸、多余字段剥掉,没有自由文本通道
- 生成提示:看不到标准答案和测试点原文,只多一句「问题定位:X,大约在第 a 行」
- 诊断失败(20 秒超时、不是 JSON、校验不过)退回单段式;同一条提交复用诊断;
编译失败不诊断
- 契约新增 HINT_ERROR_TAGS(13 个,落库值,只增不改)与 hintDiagnosisSchema
- 迁移 0018:ai_hint 加 diagnosis / diagnosis_error 两列
- 单段式 prompt 原样搬进 services/hint-diagnosis.ts,记版本 1;两段式记版本 2
- completeChat 支持 JSON 模式和自定义超时,现有调用不受影响
- 开关 AI_HINT_DIAGNOSE 默认关(2a 的基线还在攒),两套生产 compose 透传
实跑(一次性库 + 本地假 LLM):开关关时请求与 2a 逐字节相同;开时正常 /
复用 / 坏 JSON / 非法标签 / 行号越界 / 超时 / 编译失败七种场景符合预期;
12 次模型请求里诊断全都带标准答案和测试点,生成全都不带。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-09-19 06:56:38 -06:00
xuyue and Claude Opus 5
47d8f46bdb
feat(AI 提示): 提示落库并收集学生评价,落进 ai_hint
...
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的 2a:先把现有提示记下来,为后面的分级和两段式诊断攒对照数据。
提示本身的行为不变,发给模型的 prompt 一字未改。
- 迁移 0017 建 ai_hint:模型、prompt 版本、内容、错误、耗时、学生评价;
成功和失败都记(失败率是 AI 悄悄变差时最先动的数)。挂在 submission 上 CASCADE
- streamChat 第三个参数改成 { onComplete, onError }:onComplete 的返回值并进
done 事件,提示 id 由此带回前端;班级学情分析那处调用同步改写,行为不变
- 落库失败只记日志,不影响学生拿到提示
- 新增 POST /ai/hint/:id/feedback:只能评自己的提示(别人的一律 404),可以改票
- 前端提示生成完出「有帮助 / 没帮助」,选中的高亮;后端没存上时不出按钮
- 实跑:本地假 LLM 验证成功 / provider 断开 / 缺 AI_KEY 三条路径的落库,
评价接口六种请求,浏览器里按钮出现、改票、重复点不发请求
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-09-19 06:00:10 -06:00
xuyue and Claude Opus 5
ed56a209ea
chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
...
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。
- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
(它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-09-16 08:27:34 -06:00
xuyue and Claude Opus 5
5eee13fe81
chore: 删掉两个跑完的一次性订正脚本,recount 留着
...
Deploy / deploy (push) Has been cancelled
fix-achievement-hours 订正的是时区丢失那两周误发的「夜猫子」「早起的鸟儿」,
2026-09-14 已跑完(修正 148 行 · 撤回 60 条),根因修在代码里之后不会再产生;
backfill-problemsets 补的三笔题单历史欠账也已结清,进度记账现在在判题这一路。
两个子命令、两条 npm 脚本一并去掉,services/problemset.ts 的 badgeHolderDiff
只服务于补发脚本,删完 0 处引用,一起清掉。
recount 留着 —— 反范式计数列会被重判、删提交带偏,是会复发的漂移,
补一条 npm 脚本(原来只有子命令)。
docs/timezone.md 里那节改成「账已平、脚本已删」,但保住那条教训:
unlockAchievements 是纯阈值比较,只删 user_achievement 不修 metrics 的话,
学生下次提交就把同一个成就原样再发一次。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-09-16 08:03:57 -06:00
xuyue and Claude Opus 5
3921c496cb
fix(成就): 后台补发成就后重算已解锁数并接着判「奖杯收藏家」;recount 订正存量
...
rescanAchievement 只插 user_achievement、加 unlock_count,不重算
achievement_unlocked_count,也不做元成就的第二轮判定(旧 rescan_achievement
原样如此)。判题结算只在「这次有新解锁」时才重算,所以被补发的人计数会一直停在
旧值。2026-09-07 一次补发之后 269 人少算,其中 10 人实际够 15 个却没拿到
「奖杯收藏家」。
- 新增 refreshUnlockedCount:一条 SQL 按 user_achievement 重算,只 jsonb_set
这一个键、只写值变了的行。rescanAchievement 补发非白金成就后调用它,再补发元成就。
- recount 同时核对已解锁数与元成就漏发,--apply 先改计数再补发,复核同一份口径。
用 09-14 生产备份实跑:recount 订正 269 人、补发 10 条,复核通过、重跑无差异,
其余指标 0 行被动;模拟调低阈值补发 1504 条,「奖杯收藏家」随之 65 → 95,
与预先算出的跨线人数一致。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:38 -06:00
xuyue and Claude Opus 5
4c0c38445c
fix(时区): 日历口径收回东八区,读出的时刻统一成 ISO 并保留微秒
...
旧栈 Django 按 Asia/Shanghai 算日历,OJ2 重写时这个锚点丢了:容器和数据库会话
都是 UTC,于是「今日提交」在北京时间 0–8 点是空的,「凌晨/早起提交次数」整体偏
8 小时,热力图、AC 趋势年份、近两年活跃人数也各按进程时区切。
- 新增 apps/api/src/time.ts 作为唯一锚点(固定 +8 偏移,不依赖进程 TZ / tzdata),
todayStart、成就小时/日期键、热力图、月份平移、年份夹逼全部改走它;
SQL 里按日历切的一律显式 at time zone。
- db/index.ts:连接会话时区设为东八区(兜底);给 timestamptz(1184) 挂 parser,
读出统一成 ISO 8601 UTC,撤掉为拿 PG 文本形状写的 ::text。parser 保留微秒 ——
生产库 12.3 万条提交几乎全带微秒,截成毫秒会让翻页分界行和班级 AC 排名的
<= min(create_time) 把自己排除(翻页每页丢一条、排名少 1)。
- 前端 parseTime/zonedParts/zonedYear 按 Asia/Shanghai 渲染,n-date-picker 做
toPickerValue/fromPickerValue 平移,站内不再按浏览器时区取时间部件。
- Dockerfile 设 TZ=Asia/Shanghai 作为第二道兜底。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
2026-09-14 05:55:17 -06:00
xuyue and Claude Opus 5
79bfd28b07
chore: 删掉 369 行没有任何调用方的代码
...
Deploy / deploy (push) Has been cancelled
## utils/functions.ts 砍掉一半(622 → 291 行)
- trickOrTreat():317 行、八种页面恶搞效果(中文乱码、页面翻转、去掉鼠标……),
**全仓零调用**,占这个共享工具文件的一半;
- 文件末尾注释掉的 getChromeVersion / isLowVersion / protocol 六行。
## 其余没有调用方的导出
- services/achievement-metrics.ts 的 RARITIES / OPERATORS —— 和契约的
achievementRaritySchema / achievementOperatorSchema 取值逐字相同,是没人用的第二份;
- collab/state.ts 的 hasRequest;以及 resetCollabState,它的注释写着「仅供进程退出或
测试用」,而本仓库不写测试(项目约定),进程退出那条路径也没调过;
- contract/language.ts 的 JUDGE_LANGUAGES,注释说「前端用它排语言 tab」——前端并没有,
它排 tab 用的是 constants.ts 里以 ProblemLanguage 为键的 SOURCES / LANGUAGE_SHOW_VALUE
(那组映射的完整性 tsc 已经在管);
- web 的 useSimplePagination(usePagination 的一层空壳包装)和 updateProblemSetProgress。
扫描口径:三个包全部 .ts / .vue 里逐个导出符号数出现次数,只在定义处出现的算无引用
(.vue 模板里的引用也计入)。剩下 56 个仍无引用的全部是契约里 `z.infer` 派生的一行
类型(46 个请求类型 + 10 个领域类型)—— 它们是在用的 schema 的类型另一半,成体系的
1:1 镜像,删一部分只会让那个文件变得随意,所以不动。
## 留给你定的一件事
`updateProblemSetProgress` 删掉之后,后端 `PUT /problem-set-progress`(routes/problemset.ts:249)
就没有任何调用方了。题单进度实际是判完之后由 services/problemset.ts 的 recordSolvedProblem
在服务端记账的(schema.ts 里那条注释写明了),这个端点是一条客户端自报进度的平行路径。
没顺手删:删端点是行为变化,而且不能排除有脚本在打它。
tsc、vue-tsc、vite build、单二进制编译、check:routes、check:ast 均通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:38:16 -06:00
xuyue and Claude Opus 5
ab47e71d6f
refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
...
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
(error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
problemDisplayId 收到 undefined,改动后同样两个响应均通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:14:05 -06:00
xuyue and Claude Opus 5
7d15e6aeaa
refactor(契约): 判题产物退回不校验,练一练的形状闸挪到写入侧,运行时闸门收回三处
...
前四轮把契约当成运行时闸门铺开,复盘下来三块里只有一块是赚的:类型收拢成一份
(语言联合、Problem/Message/ContestRank 的重复派生)留着;另外两块退回来。
## 判题产物:读出侧不再校验
judgeCaseResultSchema 按采样键集收紧的结果,用根目录那份生产备份全量跑了一遍:
124192 条提交里 9163 条对不上,**RE 8480/8480、TLE 338/338+26、MLE 1/1 全中**,
另有 270 条 WA、47 条 AC。原因不是键集合,是空值和 SQL 链路:
- 沙箱在非正常退出的测试点上写 `output_md5: null`,契约写的是 z.string();
- SQL 判题(judge/sql/engine.ts 的 CaseResult)根本没有 `output` 键;
- SQL 通过的测试点 `error_message` 是 null,契约写的是 z.string().optional()。
更糟的是失败方式:`info` 是 `union([完整形状, z.object({})])`,对不上的一律落进
第二支被剥成 `{}` 且 parse 成功 —— 管理员详情页的测试点表格**静默消失**,无日志。
JSONB 的形状真相在写入侧(判题机),读出侧再校验一遍只会在两边分叉时丢数据。
所以 `info` 回到 z.unknown(),形状改用 JudgeInfo / JudgeCaseResult 两个 TS 类型
描述(按判题机实际写的形状,不是采样出来的),取值处由 submissionCaseResults()
做唯一需要的运行时判断:有没有 data 数组。statisticInfo 换成 looseObject ——
所有键可选、不剥未知键,对任何对象都不会失败,它的作用是给类型不是当闸门。
## 练一练:形状闸从读路径挪到写路径
exerciseSchema 的 superRefine 挂在读路径上,而这个 schema 后端也在 parse
(routes/content.ts),等于一行脏数据就能让整条学生练习列表 500。同时写入侧的
exerciseDataError **一次都没查过 question**,两边严紧度不一致,脏数据进得来出不去。
exerciseDataByType 保留,改由 exerciseDataError 在写入前查,错误信息按字段翻成
中文给老师看;读路径回到不校验。
## 运行时闸门收回三处
contract() 从 41 个端点收回到题目详情 / 提交详情 / 用户资料 —— 原本就写了
.parse() 的那三条。留着的理由是「别抛错」(原来 parse 抛 ZodError 会白屏、
后面的 as 又让校验白做),不是校验:前后端同仓、共享同一份 schema,字段漂移
tsc 已经抓了。闸门本身也瘦掉了没人读的 window.__OJ2_CONTRACT_DRIFT__ 那套簿记。
## 验证
- 生产备份全量:124192 条提交过 submissionDetailSchema / submissionListItemSchema
零失败,其中 112144 条能拿到测试点明细(另外 12048 条本来就是 data:null);
151 道练习读路径 151/151、写入闸 151/151(老师改旧题不会被新闸挡);
- 反向验证写入闸:缺题干的排序题被拒并给出「题干的格式不对」;
- 本地实跑:种一条生产形状的 RE 提交(output_md5: null),管理员详情接口原样
返回 info.data(改之前是 {});库里塞一行没有 options 的 mcq,学生端练习列表
照常返回两条而不是 500;
- vue-tsc / tsc -p apps/api 均 exit 0,vite build 通过,check:routes 无遮蔽。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 04:53:28 -06:00
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
5d60bb15bb
perf(redis): 热路径合并往返,限流脚本改走 EVALSHA
...
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
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
498fc1ceca
fix(后台): 会话吊销、导入查重、练习题校验,以及一批后台页面的小毛病
...
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
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
f9354b0df1
fix(题单): 进度重算漏了分数和完成状态,奖章没人补发
...
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
xuyue and Claude Opus 5
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
xuyue and Claude Opus 5
a75c70c82d
perf(后端): 干掉 14 处 N+1 查询
...
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
xuyue and Claude Opus 5
9653f1541a
refactor(契约): SQL 展示数据进契约,topReaction 收成枚举
...
d3b05b8 把 SQLDisplay* 列进「保留不动的窄化」,理由是契约那边就是
Record<string, unknown>。这次把那个碗底扫了 —— 因为「契约说不知道形状、
前端手抄一份来渲染」正是那个 commit 修的五处分歧的同一个模子。
SQL 题两个 JSONB 列现在有精确 schema:sqlConfigSchema、sqlDisplaySchema、
sqlDisplayTableSchema、sqlDisplayColumnSchema。键名保持 snake_case,是
problem.sql_config / sql_display 的原文,回滚时旧后端要读同一份。前端
utils/types.ts 里手抄的 SQLConfig / SQLDisplay / SQLDisplayTable /
SQLDisplayColumn 共 30 行删掉改成 re-export,Problem 和 AdminProblem 的
Omit 列表各短两项。手抄那份把 SQLDisplayColumn.type 写成了可选,后端一直
是必有的空串。
后端跟着收紧:commonChecks / generateSqlDisplay 的 Record<string, unknown>
换成 SqlConfig,`sqlConfig.mode === "modify" ? "modify" : "query"` 这句防御
删了 —— 现在类型上就只有那两个值。请求侧 createProblemRequestSchema.sqlConfig
也不再是 record:mode 是枚举、order_sensitive 缺省补 false,正好是旧后端
SQLConfigSerializer 的口径(新后端之前反而比旧的松,什么都收)。
顺带修 adminProblemListItemSchema.topReaction 的注释:写着「当前后端恒传
null,get_top_reactions 没跟着迁过来」,但 admin/problem.ts:257 早就在调了,
只有比赛题列表恒 null。这条注释会骗人去补一个已经存在的实现。类型同时从
z.string() 收成 reactionKeySchema —— getTopReactions 本来就把库里认不出的
类型滤掉了,只是类型上没体现,前端因此得在 transforms.ts 写
`as AdminProblemFiltered["topReaction"]`,现在那个强转和 `?? null` 一起没了。
服务层里的 `as ReactionKey` 换成 isReactionKey 类型守卫。
**收紧 JSONB 的 schema 会把「存量数据形状不对」从静默降级变成 500,所以实打了:**
- 从生产库备份捞出 9 道 SQL 题的 sql_config / sql_display 原文,逐条过新
schema,9/9 通过;键集与旧后端 judge/sql_runner.py:build_display 的产出
逐字一致(columns/name/rows/total_rows/truncated,expected 两形态)。
- 把其中 query 形态、modify 形态各一条种进本地库,起 API 打 oj 详情和后台
详情共四个端点,全 200,expected 两种分支都正确解析。
- topReaction:插两条并列票,按 reactionKeySchema 顺序正确取到 confusing;
再插一条库里已下掉的类型,被守卫滤掉返回 null。
另:`bunx vue-tsc --noEmit` 不带 -p 是**无效的**,根 tsconfig.json 是
"files": [],塞个类型错误进去照样 exit 0。要跑 `bun run type-check`
(-p tsconfig.app.json),这次的结论出自它。tsc(apps/api) 0 error、
check:routes 168 条无遮蔽、vite build 通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 19:02:25 -06:00