6c2381ad2c
perf(提交列表): 越早的页越慢,最后一页要等 1.2 秒
...
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(流程图统计): 给词云的分词量封顶
...
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): 断线不重连、评分结果会丢、登出后连接还活着
...
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 那支返回裸数组,页面每次打开都白屏
...
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 查询
...
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 残留
...
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
7b6bfeb6fb
fix(标签): /problem-tags 计数漏过滤隐藏题和赛题
...
Deploy / deploy (push) Has been cancelled
标签列表按题目数 > 0 过滤,但计数没有像 /problems 那样限制
visible=true 且非赛题,导致标签在首页出现、点进去却一道题都没有。
同时把旧仓库冻结政策收紧写进 CLAUDE.md:从今天起旧仓库零改动,
不再有内部小修的例外,所有后续工作只落在 OJ2。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com >
2026-08-26 00:22:05 -06:00
75dd9bfa42
fix(提交): 独立的 /submission/:id 页面上「复制回到题目」抛 problemID
...
## 复现与根因
在 `/submission/<提交id>` 这个独立页面点「复制回到题目」,必抛:
Error: Missing required param "problemID"
`detail.vue` 的 `problemID` 是**可选** prop,但 `copyToProblem()` 无条件把它塞
进 `router.push({ params: { problemID } })`。三个组件调用方(提交列表弹框、
题目页提交弹框、后台 ACM 助手)都显式传了这个 prop,所以弹框里一直是好的;
而路由 `submission/:submissionID` 配的是 `props: true`,只喂得进
`submissionID` —— 那条路上这个 prop 恒为 undefined。
组件想自己兜底也兜不了:`submissionDetailSchema` 只有 `problemId`(内部数字
id),没有拼路由要用的 display id(`problem._id`)。
ojnext 里是**一模一样**的代码,不是这次重写引入的。
## 改法
后端补 `problemDisplayId`:`submissionDetail()` 本来就 join 了 problem 表,
不额外查库。前端 `props.problemID ?? submission.problemDisplayId`。
## ⚠️ 还剩一条边没关
非管理员看到的 `contestId` 是被抹掉的(`full ? contestId : null`,对齐旧后端
`SubmissionSafeModelSerializer` 的 exclude,把关的是角色不是归属)。所以**学生
自己的比赛提交**从这个独立 URL 打开时,组件判断不出它属于比赛,会跳到公开题
路由 `/problem/<显示编号>`,那里查的是 `contestId is null`,落到「题目不存在」。
改之前这条路是抛异常,改之后是跳错地方 —— 都不对,但主路径(非比赛提交)现在
是对的。要彻底关掉得让后端把 `contestId` 也发给**提交本人**(不只是管理员),
那是在动一条刻意对齐旧后端的决定,留给你定。另外这条路由**全站没有任何入口
链接**(查过了),只有直接输 URL 或外部链接才会到。
## 验证
真起服务、真点按钮:
- 修之前:点击不跳转,控制台 `Missing required param "problemID"`,
Vue 警告里能看到 `<Detail submissionID="..." >` 确实没有 problemID。
- 修之后:无异常,跳到 `/problem/1004`(那条提交对应的题),
编辑器里是这条提交的代码(`n = int(input())…`),语言也带过去了。
- 回归:走提交列表弹框(有传 prop 的那条路)照样无异常、照样跳 `/problem/1004`。
- tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 23:59:46 -06:00
dd6a6a04eb
refactor(密码): 删掉 PASSWORD_HASH_UPGRADE,无条件写 argon2
...
Deploy / deploy (push) Has been cancelled
上一个 commit 把五个写入点收进 hashPassword,顺手把开关默认值翻成了 true 并
留下 false 分支当退路。这个退路是假的,删掉。
**开关只能拦住将来,修不了已经发生的。** 等真到了要把旧站拉回来的那天,活跃
账号早就都被登录/重置密码升成 argon2 了,这时候设 `=false` 一个也救不回来。
正经退路是切换手册「万一已经改坏了」那节的脚本 —— 拿 `raw_password` 重算
Django 的 `make_password`,能修已经变成 argon2 的账号。留着一个永远轮不到它
上场的开关,只是给后来人多一件要读懂的东西。
删掉的:`config.passwordHashUpgrade`、`hashDjangoPbkdf2` 和它那套 Django 参数
(1200000 迭代 / 22 位 salt / RANDOM_STRING_CHARS)、两个 compose 里的
`PASSWORD_HASH_UPGRADE` env。auth.ts 的登录升级变成无条件。
保留的:
- `hashPassword()` —— 虽然现在只是一行 argon2,但「只有一个地方写密码」正是上
一个 commit 的重点。五处各写各的才是当初那个 bug 的根。
- `verifyPassword` 的 **pbkdf2 分支** —— 生产库 1710 个账号全是 Django 写的
pbkdf2(迭代次数 120000~1200000),只会在各自下次登录时才迁移。删了就是
全站登不上。代码注释里写死了这句话。
- 切换手册里的 `raw_password` 恢复脚本,以及回滚流程里新加的「密码要额外处理」
那一步。
## 验证
改完重跑了一遍关键路径(临时造一个 Django 生成的 120000 迭代老哈希):
- 存量账号登录 200 → 哈希变成 argon2 → 再登 200 → 错密码 401
- 后台重置密码 → argon2 → 新密码能登录
- 注册 → argon2 → 能登录
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过、
两个 compose `config -q` 通过。测试用户已删干净。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 23:50:55 -06:00
694e147a21
fix(密码): 五个写入点统一走 hashPassword,默认切 argon2
...
## 那道「单向门」其实一直开着
`PASSWORD_HASH_UPGRADE` 是并行试跑第一天撞出来的补丁:新后端把 Django 的
pbkdf2 升级成 argon2 之后,旧后端验不了,登录过新站的学生回滚就登不上
(phase5 切换手册里记着这件事)。当时的修法是给**登录时的自动升级**加个开关,
默认关闭。
但写密码的地方有五个,开关只管住了一个:
POST /users 注册
PUT /admin/users/:id 管理员改密码
POST /admin/users 批量导入用户
POST /admin/users/:id/reset-password 重置密码 ← 老师天天在用
登录成功后的自动升级 ← 只有这一处受开关管
后面四处无条件 `Bun.password.hash(argon2id)`。也就是说开关关得好好的,
**老师给学生点一次「重置密码」,那个账号就回不去旧站了** —— 而「老师帮学生
查/改密码」正是这套系统的日常功能,`raw_password` 那一列存在的理由就是它。
所以那半年里「默认关闭 = 回滚安全」是个假象。
## 改法
五处统一走 `auth/password.ts` 的 `hashPassword()`,开关两个方向都变成真的:
- `true`:五处全写 argon2id,登录时把存量 pbkdf2 顺手升级掉。
- `false`:五处全写 Django 格式的 `pbkdf2_sha256$1200000$<22位salt>$<base64>`,
登录时也不动存量哈希 —— 两边都验得了。
新加的 `hashDjangoPbkdf2` 逐项对齐 Django 6 的 `PBKDF2PasswordHasher`:
1200000 迭代、22 位 salt、`RANDOM_STRING_CHARS` 字符集、sha256/32 字节。
**默认值定成 `true`** —— 旧站今天下线了,没有回滚路径要照顾。要把旧站拉回来
就先设 `PASSWORD_HASH_UPGRADE=false`,切换手册三处说明都改了。
⚠️ `verifyPassword` 的 pbkdf2 分支**永远不能删**,代码和注释里都写了:生产库
1710 个账号全是 Django 写的 pbkdf2(迭代次数 120000~1200000,跨了好几个
Django 版本),它们只会在各自下次登录时才升级成 argon2。
## 顺带:dev seed 只有学生号
`seed:dev` 只建 `student`(普通用户),本机想测后台得手工往库里塞 email 和
user_profile —— 而缺 user_profile 的表现极其隐蔽:`/api/me` 返回
profile-not-found → 前端 getMyProfile 抛异常 → localStorage 的 authed 存不进去
→ **所有 /admin 路由被守卫静默弹回首页**,不报错。我在这上面卡了很久。
现在 seed 同时建学生和超管(`devadmin` / `devadmin123`),profile 和 email 一起
建好,并且写密码也走 hashPassword。另外加了一道防呆:DATABASE_URL 不是本机时
直接拒绝执行 —— 这个脚本会重置密码并把明文写进 raw_password,其中一个还是超管,
对着生产库跑一次就是把超管密码改掉。要绕过设 `OJ2_SEED_FORCE=true`。
## 验证
全程拿**旧后端那个真的 Django venv** 对打,不是照着文档推:
- 事实核对:Bun 写的 `$argon2id$…` 在 Django 里 `identify_hasher` 抛
`Unknown password hashing algorithm ''`;换成 Django 格式 `argon2$argon2id$…`
能识别,但 `verify()` 抛 `Couldn't load 'Argon2PasswordHasher' algorithm
library: No module named 'argon2'`(旧后端确实没装 argon2-cffi)。
原注释说的「格式对了也验不了」结论对,机制略有出入。
- 双向互验:OJ2 写的哈希 Django `check_password` 通过、错密码不通过;
Django `make_password` 写的哈希 OJ2 `verifyPassword` 也认。
- 默认(argon2):拿 Django 现造一个 120000 迭代的老哈希塞进库,登录 200 →
哈希变成 argon2 → 再登一次仍 200。
- 退路(`=false`):同一个老哈希登录 200 且**哈希不变**;走后台「重置密码」
之后落库是 `pbkdf2_sha256$1200000$…`,**旧站的 Django 验这个新密码通过**。
- 一次 1200000 迭代约 107ms(写密码时的开销;验旧哈希的开销本来就在)。
- tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
测试用户(pwtest1 / legacyuser)已删干净,本机库用新 seed 复位。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 23:47:02 -06:00
36a4663193
chore(前端): 清掉四处零引用的死代码
...
Deploy / deploy (push) Has been cancelled
按名字扫了一遍 api 层导出和 utils 常量,四处全仓零引用:
**`getContestProblem`**(admin/api.ts)—— 和紧挨在它上面的 `getProblem` 逐字
相同,同一个 URL、同一个类型。旧后端 admin 侧比赛题和公开题是两个端点,合并
之后这个壳留下来了。
**`createMessage`**(oj/api.ts)—— 两代前端都只有定义没有调用。参数名还是旧
后端那套(recipient / submission),函数体里再映射成契约的
recipientId / submissionId,等于给一个不存在的调用方写了个兼容层。
d3b05b8 删掉了它的类型 CreateMessage,函数漏了。
后端 `POST /messages` 是完整实现的,只是后台那个页面
(admin/communication/messages.vue)两代都是一句「未完待续」的占位。
在端点上加了注释说明这件事,免得下次扫「无人调用的端点」时被当成可删。
**`CONTEST_TYPE`**(constants.ts)—— 和上面 30 行处的 `ContestType` 枚举
一模一样。枚举那份有 8 处在用,这个对象零引用。
**`LANGUAGE_ID`**(constants.ts)—— Judge0 的语言 id。真正在用的那份在
utils/judge.ts 的 JUDGE0_LANGUAGE_ID,而且**只有那份是对的**:死掉的这份把
Golang / JavaScript / Python2 全写成了 0,谁要是拿它去调 Judge0,这三种语言
的在线试运行会静默发出 `language_id: 0`。
顺带确认过没有 `constants["LANGUAGE_ID"]` 这类动态取值。
验证:vue-tsc 0 error、vite build 通过、tsc(apps/api) 0 error、
check:routes 168 条无遮蔽。四处都是零文本引用的纯删除。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 23:32:50 -06:00
3f6b4102c9
feat(题目): 学生题目页恢复「要求」展示,走新的 astRequirements 字段
...
Deploy / deploy (push) Has been cancelled
旧后端的 ProblemSerializer.Meta.exclude 没排掉 ast_rules,学生拿到的是规则原文,
题目页上那块「要求」(「if 条件 出现 2 次」之类)是显示的。阶段 3 泄露评审刻意
收掉了它,只留 hasAstRules 布尔值 —— 报告里当时就写了:
「新的更安全,但如果 ojnext 有地方读 ast_rules 的具体内容(比如提示"必须用
for 循环"),需要补个专门的字段。」
前端确实有(ProblemContent.vue 的 astRulesForDisplay + 整块渲染),但那个字段
一直没补。结果是这块在 OJ2 上**永远拿不到数据**,代码还在、没人报错。12 道题
受影响;学生只有提交失败后才能从 statistic_info.ast_results 里看到要求。
现在按评审自己的建议补上:oj 侧题目详情(含比赛题)多一个 astRequirements,
**只有渲染要用的两个字段**:
{ description: "if 条件 出现 2 次", kind: "count" }
文案由后端生成,engine / target 这些内部字段不出现在响应里 —— 收紧保留,
展示恢复。实测响应里搜不到 "engine" 也搜不到 "if_statement"。
## 顺带合掉一堆重复
描述文案原来有两份几乎一样的实现:判题机 ast.ts 里一份(写进 ast_results)、
ProblemContent.vue 里一份(题目页用)。现在统一成 ast.ts 的 describeAstRule,
两边共用。差异只在 min/max 同时给出时的措辞(生产库里没有这种规则,实际输出
逐字不变),另外判题机现在也能用上节点类型的中文名 —— 没写 label 的规则以前
判题结果里显示 `必须使用 function_definition`,现在是 `必须使用 函数定义`。
节点类型中文名那 15 条原来在 AstRulesEditor.vue(下拉 options)和
ProblemContent.vue(NODE_TARGET_LABELS)各手抄一份,收进契约的
AST_NODE_TARGET_LABELS,编辑器的下拉现在从它生成。
前端删掉 NODE_TARGET_LABELS / ruleDescription / ruleTagType 共 ~80 行,
`Problem` 类型里那个 oj 侧根本不下发的 astRules 幽灵字段也去掉了。顺带修掉
一处潜在重复渲染:原来 message 非空时 ruleDescription 返回 message、模板里
又单独渲染一次 message(生产库 message 全是空串,所以没露出来)。
## 一个坑:契约里差点搞出循环引用
astRequirements 一开始放在 admin.ts,problem.ts 去 import 它 —— 而 admin.ts
本来就 import problem.ts。**tsc 一声不吭地过了**,运行时才炸:
ReferenceError: Cannot access 'astRequirementsSchema' before initialization
所以整块 AST schema 从 admin.ts 挪到了 problem.ts(本来也是题目域的东西),
admin.ts 反过来从那边取。这类环 tsc 抓不到,加跨文件 schema 引用时得实跑一次。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:
- 把生产库那条规则种进本地库,匿名请求 /api/problems/1001:astRequirements 是
`{"Python3":[{"description":"if 条件 出现 2 次","kind":"count"}, ...]}`,
响应里没有 astRules、没有 engine、没有 if_statement。
- 浏览器打开题目页,「要求」那块活了:`要求 | if 条件 出现 2 次 | else 子句 出现 2 次`。
- 后台编辑页展开「代码规则检查」,两条规则正常渲染成
`出现次数 | if 条件 | 精确`,下拉选项(现在从契约生成)正确。
- 直接调 describeAstRule / checkAst / astRequirements:三种 kind 分类正确,
engine 认不出的规则被丢掉,null 和非对象都回落成 null。
- oj 侧 3 页 + 后台 4 页走查无重定向、console 无报错。
冒烟改动已还原(problem 2 的 ast_rules 复位成 null)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 23:27:40 -06:00
f919d41199
refactor(契约): AST 代码要求收进契约,三份各写各的合成一份
...
同一个形状原来在三个地方各定义了一份,三份都不一样:
apps/api/src/judge/ast.ts engine target outer inner label message exact min max
apps/web/src/utils/types.ts engine target message min max
AstRulesEditor.vue(本地) engine target label message exact min max
ProblemContent.vue(本地) engine target label message exact min max
判题机那份是真的(evaluateRule 按 engine 分支读哪几个字段),契约里则压根没有,
`astRules: z.unknown()`。后果是后台编辑器写得出 label 和 exact、判题机也认,
但 utils/types.ts 那个类型描述不了它们 —— 生产库 12 道带 AST 规则的题里,
15 条规则带 label、6 条带 exact,全都在类型之外。
现在契约里一份 astRuleSchema,四处都指向它。engine 收成枚举,列全判题机
实现的十种。**存量数据核过**:12 道题逐条过新 schema,12/12 通过。
顺带三件:
- `astRules` 的响应和请求 schema 从 z.unknown() 换成 astRulesSchema。之前
engine 写错一个字母能存进去,判题机 evaluateRule 走 `default: return null`
静默跳过 —— 老师设了规则、规则不生效、没有任何提示。现在保存时就 400,
错误信息把十个合法值列出来。
- judge/run.ts 的 astRulesForLanguage 原来是整片 `rules as AstRule[]` 硬转,
改成逐条 safeParse:认不出的丢掉那一条,行为和 evaluateRule 的 default 分支
一致,只是提前到读取处,也不再骗类型系统。
- 判题机实现了 must_have_nesting,但后台编辑器没有对应选项,目前只能手工造
数据才用得上。枚举里留着并加了注释,没有顺手去补 UI(那是加功能不是清理)。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:
- 把生产库那条带 label/exact 的规则种进本地库,后台题目详情 200、字段齐全;
原样 PUT 回去 200,库里 label 和 exact 都在(旧类型描述不了的那两个)。
- engine 传 "must_do_magic" → 400,错误信息列出十个合法值。
- 直接调 checkAst:三条规则(两条合法 + 一条 engine 认不出)进去,判题机收下
两条;两个 if/else 的代码 passed=true,一个的 passed=false,描述文案
「if 条件 出现 2 次」正确用上了 label。
冒烟改动已还原(problem 2 的 ast_rules 复位成 null,测试标签删掉)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 23:15:52 -06:00
5c772319e9
refactor(契约): 公告两侧形状分开,判题状态码从契约派生
...
Deploy / deploy (push) Has been cancelled
## 公告:一个 schema 兼两种形态,兼出两处谎
前端 utils/types.ts 只手抄了**后台那份**公告形状,oj 侧也拿它当类型用。可
apps/api/src/routes/content.ts 的 `/announcements` 列表既不下发 content 也不
下发 visible —— 于是类型声称列表行有这两个键、运行时都是 undefined。今天没炸
只是因为组件恰好没读(正文是点开后另拉一次详情)。
契约那边则是另一头:announcementSchema 把 content 写成 `.optional()`,让一个
schema 同时兼列表和详情。代价是**详情**拿到的 content 类型也成了
`string | undefined`,组件只能 ?? 兜底。
现在两边都按后台侧早就在用的套路拆开:content 必填,列表用
`.omit({ content: true })` 派生。四个类型各归各位:
oj 列表 AnnouncementListItem 无 content、无 visible
oj 详情 Announcement 有 content、无 visible
后台列表 AdminAnnouncementListItem 无 content、有 visible
后台详情 AdminAnnouncement 有 content、有 visible
前端手抄的 Announcement / AnnouncementEdit / AnnouncementListItem 全部删掉,
AnnouncementEdit 改成从请求体派生(`CreateAnnouncementRequest & { id: number }`)。
后端 content.ts 的列表端点跟着换成 announcementListItemSchema —— 它本来就没
传 content,输出一字不变。
## SUBMISSION_RESULT 不再手抄
`-2 | -1 | 0 | ... | 10` 这 11 个码是从后端抄的,改成 `JudgeStatus | 9`:
后端那部分从契约派生,9 是前端本地的「正在提交」伪状态(后端永远不下发,
所以契约里没有,见 constants.ts 的 SubmissionStatus.submitting)。
这样 CLAUDE.md 说的「三处同步」才真的有人守:**实测过**,往
judgeStatusSchema 加一个 `z.literal(11)`,constants.ts 的 JUDGE_STATUS 立刻
报 TS2741 缺 '11' 的映射,加不上标签就编译不过。
顺带 useSubmissionMonitor.ts 里两处裸 `9`(各带一句 `// 9 = submitting`)
换成 SubmissionStatus.submitting,注释就不用写了。
## 顺手
TestcaseUploadedReturns 这个改名 re-export 去掉,直接用契约的
UploadTestCaseResponse(全仓 2 处引用)。
**没动 transforms.ts。** 之前把它记成「旧前端字段名的化石」,看下来判断有误:
filterResult 里 difficulty 要查 DIFFICULTY 映射表转中文、rate 要 getACRate 算、
status 要把 myStatus 翻成 passed/failed/not_test —— 是实打实的视图模型,不是
单纯改名。改它只会把计算逻辑挪个地方。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、vite build
通过。因为动了后端响应 schema,起服务实跑了公告的四条路径:
- curl 三个端点逐个核对键集:oj 列表无 content/visible 且只出可见的那条、
oj 详情有 content、后台列表有 visible 无 content 且两条都在。
- 浏览器里 oj 公告列表渲染正常、点开正文能出来;后台列表两行齐全;
编辑页表单载入正确;改标题保存 PUT 200,库里 title 变了、visible/top 没被
带歪,保存后跳回列表并重新拉取。
(agent-browser 的 `find text 保存 click` 打不到 naive-ui 的按钮 handler ——
不发请求也不报错,一开始误判成保存坏了。改用 DOM 上直接 .click() 就正常,
是自动化的坑,不是应用的问题。)
冒烟用的两条公告已删干净。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 22:54:01 -06:00
0b7b08f2cc
refactor(前端): 去掉 { error, data } 信封,api2 改名 api
...
Deploy / deploy (push) Has been cancelled
信封是 Django 时代的形状:拦截器手工造一个**恒为 null** 的 error 字段,再把
真正的载荷塞进 data。后端 http.ts 的 success 其实只返回 { data },那个 error
从头到尾没人用 —— 全站成功路径读 res.error 的只有 admin/api.ts 的
resetPassword 一处,而它自己就是个把信封拆开再重新包一遍的 shim。
代价是每个调用点都要 .data 一次:47 个组件、3 个 api 层文件、200 多处。
现在拦截器直接返回 response.data.data,ApiResponse<T> 退化成 T,文件末尾那句
`as unknown as Api2Client` 的类型谎言也少了一层。失败路径不动,仍然 reject
`{ error: 错误码, data: 文案 }` —— 和成功路径不对称是故意的,成功没有错误码
可言,接口注释里写清楚了。
顺带把 api2 改回 api:utils/ 下早就没有 api.ts 了,"2" 是迁移期用来和旧
client 区分的,现在只剩下让人多想一秒的作用。
## 怎么改的
**没有全局 sed。** 先把客户端的返回类型从 Promise<ApiResponse<T>> 改成
Promise<T>,让 vue-tsc 把每一处报出来(210 条),再按它给的 file:line:col
精确删 `.data`(192 处),剩下的手工处理:
- 6 处 `const { data } = await ...` 解构 → `const data = await ...`
- 3 个 api 层函数(getProfile / getProblem / getSubmission)自己手工造信封,
改成直接返回值;getProfile 的返回类型跟着从 ApiResponse<Profile|null>
变成 Profile|null
**类型检查抓不到的,人工把剩下的每一处 `.data` 过了一遍** —— 载荷本身带
data 字段、或者载荷是索引签名时,`res.data` 照样过类型。这一遍捞出三条真 bug:
- `getTutorialList` 的载荷是 `{ [key: string]: TutorialListItem[] }`(按
python / c 分组)。索引签名让 `res.data` 编译通过、运行时是 undefined ——
改完信封之后教程列表会**两个 tab 全空且不报错**。实跑确认过修好了。
- `createExercise` / `updateExercise` 返回 `res.data`,而 Exercise 自己有
data 字段(练习内容)。两个调用方都不看返回值,所以类型和运行时都不响。
- `getSimilarProblems` 的 `.then(r => ({ ...r, data: r.data.map(...) }))`
删掉 .data 之后变成往对象里摊一个数组,能跑但形状是错的。
另外两处是**对的**,加了注释免得下次被"顺手清理"掉:
StatisticsPanel 的 `res.data` 是契约 submissionStatisticsSchema 自己的 data
字段(每个学生一行);download.ts 是独立 axios 实例,`res.data` 是 axios 的
响应体(zip 二进制,不走信封)。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、vite build
通过。**因为这改动碰的是每一个请求,静态检查不够,起了全套服务用浏览器实跑:**
- oj 侧 12 个页面 + 后台 13 个页面逐个打开,断言没有重定向、console 无报错。
- 关键页面进一步断言渲染出了真数据(后台用户列表 3 行、题目列表 10 行、
站点配置表单三个输入框有值、教程列表分组正确)。
- 三条写路径实打:重置密码(库里 student123 → 531554,表格当场刷新)、
公告可见性开关(走 getAnnouncement + editAnnouncement,就是手改解构那处,
库里 visible t → f)、提交代码(POST → 判题机真跑出 -2 → 提交列表和详情页
都正确渲染状态、语言、代码)。
- /rank 有一条 `{error: "class-missing"}` 的未捕获 reject,stash 掉本次改动
复现同样报错,**是既有问题**,不在本次范围内。
本地 dev 库为了打通后台测试改了三处,都只影响本机:devadmin 补了 email 和
user_profile 行(原来缺这两样,getProfile 报 profile-not-found,AUTHED 存不
进去,所有 /admin 路由被守卫弹回首页)、密码重置成 devpass123。冒烟用的教程/
公告/提交三条测试数据已删干净,题目和用户的提交计数也回滚了。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 22:42:20 -06:00
66d51dfd83
chore(账号): 删掉没人调的 refreshUserProblemDisplayIds
...
Deploy / deploy (push) Has been cancelled
oj/api.ts 里那句 `// TODO: 这个API有问题` 是从 ojnext 原样搬过来的,**说的是
旧后端的 bug,移植时已经修掉了**。旧的 ProfileProblemDisplayIDRefreshAPI:
ids = list(acm_problems.keys())
display_ids = [... filter(id__in=ids, visible=True).values_list("_id")]
id_map = dict(zip(ids, display_ids))
zip 把「dict 键顺序」和「查询返回顺序」硬凑成对,题目一旦被隐藏或删除
display_ids 就比 ids 短 —— 轻则编号张冠李戴写进库,重则 id_map[k] KeyError。
account.ts 那版是按 id 建 Map、查不到就不动,是对的。留着这条 TODO 只会让
下一个人去查一个不存在的 bug。
顺带查出来:这个函数**两代前端都只有定义、没有任何调用点**。端点清单把
/api/profile/fresh_display_id 标成「前端有调用」是提取脚本匹配到了函数定义
里的 http.get 字面量,不是调用,属于假阳性。前端这个死导出删掉。
后端端点保留 —— 教师改了题目编号之后,学生 acm_problems_status 里缓存的
_id 只有它能刷,是这份缓存唯一的入口。加注释写清它是干嘛的、为什么现在没人
调(要接 UI 从这儿开始)、以及旧后端那版错在哪。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 19:02:37 -06:00
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
78a2fb4fce
feat(排行榜): 重写全服 Top100,补上「我的排名」,后台那条挪进 admin
...
起因是 Top100 的「已解决」「提交数」两列一直空白:列的 key 还是
snake_case(accepted_number / submission_number),而数据早在 c2a8120 拆掉
转换层后就是 camelCase 了。naive-ui 按 row[key] 取值,取到 undefined 就渲染
空白、不报错 —— 和 d3348f9 是同一个病根。
顺着这条线把整个端点重写了:
**上限不再由调用方传。** `top` 参数原来有三个调用方各传各的(100 / 10 / 0),
而它会覆盖 limit 与 offset、total 却按全量算,正是 36e4ac2 那个「每页都是同样
100 条」的成因。现在 100 写死在服务端,参数只剩 limit / offset。
「全服 Top10」不需要另一个上限,它就是这个榜的第一页。
**排序补了第三档 asc(user.id)。** 前两个键完全相同的学生在真实数据里成片存在
(都是 0/0),没有稳定兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复
或漏掉的人。老代码缺这一档。
**新增 me(我的全服名次)。** 名次 = 排在我前面的人数 + 1,三个排序键逐级比较,
与列表的 orderBy 逐字对应 —— 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」。
榜上高亮我那一行,名次超出 100 时在 footer 单独给一行。未登录、教师/超管返回 null。
**后台那条搬去 /api/admin/rankings/users**(requireSuperAdmin,无上限)。
原来它走的是公开端点的 top=0 分支,也就是任何匿名请求都能 ?top=0&limit=250
翻走全校学生名单和个性签名 —— 而 /profiles/:username 恰恰为了收紧枚举面才做了
「匿名一律返回空」,注释里还专门点了 /rankings/users 的名。这条页面本来就是
requiresSuperAdmin,它调的另外两个接口也都是 requireSuperAdmin,守卫对得上。
顺手去掉恒真条件 gte(acceptedNumber, 0):该列是 notNull default 0。
同一次扫了全仓 218 个表格列定义,筛出 70 个没有 render 的(只有这些才靠 key
直接取值),比对全部类型定义里的字段名 —— 除这两处外没有漏网的。`_id` 和
`test_case` 是真字段名,不能改。另外收掉两处同类的雷:
admin/setting/config.vue 手写的 `interface Testcase` 字段名和类型都是错的
(真实数据是 createTime: number,不是 create_time: string),改用契约的
OrphanTestCase;serverColumns 里 last_heartbeat / create_time 两个残留 key
有 render 兜着没出事,一并改正。
实测(造 120 个探针用户,含 3 个 AC 与提交数完全相同的并列,验完已清库):
122 人时 total=100;offset=95 末页 5 条;offset=100 越界返回空且不发 SQL;
并列三人稳定占据前三;student(ac=2) 拿到 rank=121 走「不在榜上」分支;
把 ac 调到 450 时 rank=4 且表格第 4 行正是 student(名次与行号对得上);
升成超管后 me 变 null;后台端点 total=121 无上限、keyword=probe_01 命中 10 条、
未登录 401;传 top=1 已被忽略。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 17:57:55 -06:00
6e905085a1
chore(依赖): 后端升到 TypeScript 7,前端被 vue-tsc 挡住留在 5.9
...
apps/api 单独嵌套 typescript ^7.0.2,`bun run --filter '@oj2/api' typecheck`
从此跑的是原生版;根和 apps/web 留在 5.9.3。api 在 TS 7 下 0 error,
另外塞了个故意写错的文件复检过,确认它真在报错、不是静默跳过。
前端升不了:vue-tsc 启动时要 require.resolve("typescript/lib/tsc"),
而 TS 7 的 exports 里已经没有这个子路径,直接
ERR_PACKAGE_PATH_NOT_EXPORTED。vue-tsc@latest 就是仓库里的 3.3.11,
还没有支持 TS 7 的版本 —— 它 peer 写的是 typescript >=5.0.0,范围太松,
装上去不报冲突、一跑就炸。读源码看到 resolveTscPath 里唯一的适配分支是认
@typescript/typescript6,可见 Vue 侧目前给的路只到 6。硬上的代价是丢掉
刚清零的前端类型门禁,不值得。
顺带清掉一处假配置:apps/web 原本声明着 "typescript": "^7.0.2",是
ae1fb32 从 ojnext 原样搬过来的,**从来没生效过** —— vue-tsc 被 hoist 在根
node_modules/,解析 typescript 时看的是根上那份 5.9.3,压根看不见
apps/web 里嵌套的 7.0.2。留着它有两个坏处:白拉 20 个
@typescript/typescript-* 平台包;哪天 bun 换个 hoist 策略把它提到根上,
vue-tsc 就会以那个看不懂的 ERR_PACKAGE_PATH_NOT_EXPORTED 挂掉。改成显式
跟随根版本。
所以现在的分工是:**vue-tsc 把根上的 typescript 钉死在 5.x**,谁要升根
版本谁先解决 vue-tsc。等 vue-tsc 支持 TS 7,把 apps/api 那行删掉、
根上升到 7 即可。
验证(删空 node_modules 重装后):apps/api tsc 7.0.2 下 0 error、
check:routes 167 条无遮蔽、apps/web vue-tsc 0 error、vite build 通过、
drizzle-kit v0.31.10 正常。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 17:39:50 -06:00
0e1e024fa4
chore(依赖): 升一批 patch/minor,锁死 mermaid-legacy 和 TypeScript 大版本
...
后端:@node-rs/jieba 2.0.1→2.0.2(含精确 pin 的 -linux-x64-gnu)、
bullmq 6.0.9→6.2.2、postgres→3.4.9、sql.js→1.14.2、web-tree-sitter→0.26.13;
hono ^4.0.0→^4.13.4 与 zod ^4.0.0→^4.4.3 只是把范围收紧到实装版本。
前端:@codemirror/view→6.43.9、highlight.js→11.12.0、mermaid→11.17.2、
naive-ui→2.45.2、pinia→4.0.3、y-codemirror.next→0.3.6、vite→8.2.2、
@types/node→26.3.0。
刻意没动两个:
- mermaid-legacy(npm:mermaid@^9.4.3)保持 9.4.3,vite 的 build target
也没碰 —— 机房 Chrome < 94 的兜底,legacy polyfill 产物照常生成。
- typescript 停在 5.9.3(只把根上的范围从 ^5.7.0 收到 ^5.9.3)。
7.0.2 是 Go 原生重写版,另开一个 commit 处理。
踩到的坑记一笔:`bun install` 不清理旧的嵌套副本。升完 @codemirror/view 后
md-editor-v3/node_modules/ 下留着一份 6.43.8,vue-tsc 立刻报
dispatchTransactions 私有属性冲突 —— 看起来像版本不兼容,其实
md-editor-v3 要的是 ^6.38.2,6.43.9 完全满足。当时全仓有十几处这种残留。
**升完依赖必须 rm -rf node_modules && bun install 再验类型**,否则会
对着一堆假的类型冲突查半天。
验证(删空 node_modules 重装后):apps/api tsc 0 error、check:routes 167 条
无遮蔽、apps/web vue-tsc 0 error、vite build 通过、单二进制在仓库外起得来
并打库返回真数据、jieba 原生模块 dev 与编译两种形态都验过、BullMQ 两个
worker 正常 ready。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 17:39:06 -06:00
25c6e99c54
feat(后台): 补回「最高票评价」列,重写时漏掉了
...
管理端题目列表的「反馈」列一直是空的:前端渲染逻辑还在,后端两处
硬编码 topReaction: null —— 旧后端的 reaction/services.py:get_top_reactions
没有跟着迁过来,阶段 0 的清单也没抓到。
新增 services/reaction.ts,口径逐条对齐旧实现:
- 按 (problem_id, type) 分组计数,取票数最高的那个;
- 并列时按 reactionKeySchema 的定义顺序取靠前的,与前端 REACTIONS 的顺序
一致(旧后端是 TYPE_ORDER,已核对两边七个 key 的顺序与内容完全相同);
- 只返回有评价的题目,没有的题调用方兜 null。
比库里可能残留、但前端已经下掉的类型多了一道跳过:不跳的话一个不再展示的
类型会以并列最优的身份把真正的最高票挤掉。
只有公开题列表下发,比赛题列表不下发 —— 与旧后端一致(旧的 top_reaction
只出现在 ProblemAPI,ContestProblemAPI 没有),前端那一列的注释也是这么写的。
实测:造 3 票 learned + 1 票 too_easy 取到 learned/3;interesting 与
too_hard 各 1 票时取到 too_hard(定义顺序在前);插一条 obsolete_kind
不影响结果;无评价的题返回 null。探针数据已清理。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 14:08:09 -06:00
c2a81209d7
refactor(前端): 拆掉 camelCase→snake_case 转换层,契约成为唯一真相
...
utils/legacy.ts 是迁移期的临时层:新后端一律 camelCase,而组件读的还是
旧 Django 的 snake_case,于是在 api 层做一次递归键名重写。它自己的注释就
写了「迁移完成后这一层应当整体拆掉」。现在拆了。
代价不只是那 96 处包装:每个响应都要递归遍历整个对象重写一遍键名,而且
utils/types.ts 和 packages/contract 是两份真相 —— 手抄的那份还抄歪了好几处。
做法是按域推进,每域都用 vue-tsc 相对基线做差,确认零新增错误后再往下走。
前端的类型现在一律以契约为准,只在必要处窄化(比如 languages/template 的键
窄化成 LANGUAGE),删掉的重复定义包括 WebsiteConfig、LoginSummary、
AchievementSummary、ProblemSet、Contest、User、Profile、AdminTag、
StuckProblem 等等,其中 ClassComparison 有两个组件各手抄了一份。
## 顺带修掉的真 bug
- 管理端公告列表的「可见」开关每次都 400:列表响应被契约 omit 掉了 content,
而更新接口要求 content 必填,toggleVisible 把列表行原样回传。而且是乐观
翻转、不 await 不 catch,管理员看到开关动了、实际没存也没有提示。
改成先 GET 整条再 PUT,加失败提示。
- 删有提交的题时只显示笼统的「删除失败」:前端还在 match 旧 Django 的英文
文案,而后端返回的是 problem-has-submissions + 中文。连同另外 8 处同类
匹配一起改成判错误码 —— 文案是后端随时能改的,match 文案改一个字就静默失效。
- SubmissionStatus.time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
memory_limit_exceeded 撞了同一个值。后端 judge/status.ts 里这是分开的
两个码,按后端拆成 cpu_/real_ 两项。当前没有代码读这两个成员,但
CLAUDE.md 明确要求判题状态码三处同步。
- 流程图历史翻到没有提交的那一页会直接抛:契约里 submission 是 nullable,
被 any 掩盖成看起来非空。补了 null 分支。
## 契约里被逼出来的三处不诚实
- grade 写成 z.string(),但 averageGrade() 在没有可用数据时返回空串,
前端三张图表拿它查 Record<Grade,...> 会查出 undefined。按实际收紧成
z.enum([...,""]),四个查表点都补了「无评级」分支。
- difficulty 写成 z.string()。核对过生产库 dump:956 道题只有
Low/Mid/High 三个值(761/149/46)。收紧成枚举。
- topReaction 写成 z.string(),既对不上前端渲染的 {type,count},也对不上
旧后端 get_top_reactions 下发的形状。改成正确形状并注明当前恒传 null。
## 明确保留 snake_case 的 54 处
判题沙箱原始输出(cpu_time/exit_code/output_md5/compile_output)、
statistic_info 内容(err_info/time_cost/ast_results)、submission_info
JSONB(is_ac/ac_time/error_number,回滚时旧后端还要读)、SQL 判题引擎的
total_rows/order_sensitive/changed_tables、WebSocket 的 submission_id、
以及数据库选项键 enable_maxkb。每一处都在类型定义旁写了为什么不能改。
language 没有跟着收紧契约 —— 它是配置项、随时可能加语言,收紧会让新语言
在后端 parse 时直接抛。改在 api 边界一处窄化。
## 另外
- utils/http.ts 整个模块已是死代码(四处引用全是 import type),删除。
- profile 的 blog/github/school/major/language 五个字段全链路空转,没有
任何组件读,从契约到类型一并摘除(数据库列不动)。
- admin/account.ts 往 user_profile 塞的 totalScore 是 OI 模式遗留,表里
没这一列。Drizzle 按表定义拼列名会把它静默丢弃,所以没出过错,是死代码。
验证:vue-tsc 143 → 54 条且无新增,apps/api tsc、check:routes、web build
全通过;各域响应形状逐条打接口核对过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-25 13:44:24 -06:00
604857b25e
fix(一言): 接回真数据集,之前线上返回的是三条硬编码
...
/quotes/random 在阶段 3 基线里只铺了个桩(三条写死的句子),读盘逻辑没移植,
所以 oj2.xuyue.cc 上一直是假数据——和 DATA_DIR 无关,哪儿部署都一样。
数据本来就在服务器上、位置也已经对上:旧栈挂 ./data/backend:/data,Django 的
HITOKOTO_DIR 就是它下面的 hitokoto/;OJ2 的 api 挂的是同一个目录。所以
Dockerfile 里跟着 TEST_CASE_DIRECTORY 写死 /data/hitokoto 即可,不用搬文件。
按分类懒加载并常驻,但只留前端用得上的 hitokoto/from 两个字段:原样缓存
7160 条要 2.5MB,裁完 1.4MB,而 api 容器只有 512m。读不到数据集时回落到
内置三条,不影响启动(本机 dev 默认就走这条)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-16 10:20:00 -06:00
36e4ac2f78
fix(排行榜): top 参数吃掉了分页,全服 Top100 每页都是同样 100 条
...
/rankings/users 里 top>0 时直接 limit(top).offset(0),limit 和 offset 全被
覆盖;total 又是全量人数,于是分页器算出几十页、页页内容相同。
改成 top 只当上限:total 按 top 截断,分页在这 N 条之内正常推进,越界页
返回空且不发 SQL。top=0(管理端)行为不变。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-16 10:08:43 -06:00
face92a5dc
fix(登录): 默认不再把 Django 的 pbkdf2 升级成 argon2 —— 这是道单向门
...
试跑第一天就出事了:在 oj2 上登录过的账号,回旧站 oj.xuyue.cc 登不上,WS 也连不上
(旧站的 Channels 认 session,没登录自然连不上,两个症状同一个根因)。
`routes/auth.ts` 原来在登录成功后把 pbkdf2 哈希重算成 argon2id 写回库。问题是:
- Bun 写出来的是 `$argon2id$v=19$…`,Django 存 argon2 是 `argon2$argon2id$v=19$…`
(多一个算法标签、没有开头的 `$`)。Django 按 `$` 切第一段当算法名,拿到空串,认不出。
- 更彻底的一点:旧后端的 pyproject.toml 里**根本没有 argon2-cffi**,
格式对了也验不了。
所以这不只是双跑的问题 —— 一次性切换后的回滚窗口内同样成立:学生登录过新站,
回滚之后就登不上了。演练时比对的是表结构「逐列一致」,比不出这种语义上的单向门。
改成 `config.passwordHashUpgrade` 控制,**默认关闭**,并在两份 compose 的
environment 里显式声明 `PASSWORD_HASH_UPGRADE`(原来根本没透传,想开也开不了)。
等确定不会再回滚了再打开。
## 验证(真库真容器,不是看代码)
schema.sql 起库 + 造一个 Django 格式的 pbkdf2 用户(密码已知),跑新栈实测:
- 默认形态:登录 200,库里的哈希**仍是** `pbkdf2_sha256$260000$…`,容器里
PASSWORD_HASH_UPGRADE 为空
- 对照组 PASSWORD_HASH_UPGRADE=true:登录 200,哈希变成 `$argon2id$v=19$m=655…`
—— 正是生产库现在的样子,锁死可复现
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-16 09:57:43 -06:00
95625d6711
refactor: 另外两处守卫也收到注册行上
...
走查题单进度时撞到 `/problem-sets/:id/user-progress` 对学生 403 —— 那是**正确**的
(它是给教师看全班进度的),但守卫又写在 handler 体内。评审的 M3 只列了 3 处,
按同一模式扫全仓,还有 2 处:
content.ts POST /messages isSuperAdmin
problemset.ts GET /problem-sets/:id/user-progress isTeacherOrAbove
改用 requireSuperAdmin / requireTeacher,理由同 M3:守卫要能从注册行上看出来,
不然下一个人加同类端点容易漏掉那个 if。
实测档位没变:学生两条都 403,教师看全班进度 200。
路由遮蔽检查仍然干净。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-08 06:28:44 -06:00
967e9ef7f7
feat: 路由遮蔽检查脚本;切换前把前后端接口逐条对了一遍
...
## 切换前核对:前端调的接口后端有没有漏
写脚本把前端 224 个调用点和后端注册的路由逐条比对。**零缺口** —— 唯一报出来的
一条是我的正则被嵌套括号截断了(`users/${encodeURIComponent(...)}/badges`),
后端那条路由是有的。「后端有、前端没调」那 25 条也逐个看过,全是脚本的假阳性
(把 `.get("user")` 这类非路由调用当成了路由)和假阴性(前端用三元表达式拼路径,
正则看不见,比如 `GET /me` 其实在 shared/api.ts 里被调)。
结论是没发现缺口,但这个脚本不够可靠、不足以证明"一定没有",所以没留进仓库。
## 路由遮蔽检查(留成常驻脚本)
比"有没有漏"更值得防的是遮蔽:**Hono 按注册顺序匹配,不是静态优先**。
`/problems/:id` 注册在 `/problems/random` 前面的话,后者永远进不去 ——
不报错、不警告,只是静默走进前一条的 handler。阶段 4 真实发生过一次,
两个教师用的分析端点被吃掉,一直到评审才发现。
全仓 167 条路由按真实注册顺序扫:**零遮蔽**。
这个结论敢下,是因为检测器本身也验了:
- 自检用例里放了阶段 4 那个历史真实案例,能抓到;边界(两边都是参数、
段数不同、不同前缀)不误报
- 核对了 24 个 router 全在扫描范围内,没有漏扫
- 反向验证:往 problem.ts 末尾加一条注册在 `:displayId` 之后的字面量路由,
脚本立刻报出来并 exit 1
未经验证的检测器报"没问题"是没有意义的 —— 这个教训今天已经吃过两次
(tree-sitter 那次、SQL 内存那次)。
脚本落在 apps/api/src/scripts/check-route-shadowing.ts,
`bun run --filter '@oj2/api' check:routes`,加完路由跑一下。
CLAUDE.md 里也写了。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-08 03:42:37 -06:00
f6bc42f534
docs: 给 OJ2 补一份自己的 CLAUDE.md;Dockerfile 拷上 bunfig.toml
...
## bunfig.toml 没拷进容器,才是那批「本地能过、容器过不了」的根因
bunfig.toml 里设了 `linker = "hoisted"`,但 Dockerfile 只拷了 package.json 和
bun.lock,于是容器里用的是默认的 isolated 布局(包都在 node_modules/.bun/ 下),
本地靠"提升"才解析得到的包在容器里一律 Could not resolve —— 而本地构建始终是好的,
只有镜像构建才炸。之前是一个一个补直接依赖补过去的(那是对的、该保留),
这里让两边布局一致,是第二道保险。
带上之后装 622 个包(isolated 是 1238),镜像重建通过,二进制在容器里
serve + healthcheck 正常。
顺手补了 main.ts 帮助文本里漏掉的 healthcheck 子命令(compose 里把 command
写错时,看到的就是这行)。
## OJ2/CLAUDE.md
OJ2 是独立仓库,之前没有自己的项目指引。写了一份,重点是几条「不知道就会踩」的:
- **本机 Docker 可用**,整套依赖和上线演练都能在本机跑 —— 别沿用上一代
"本机跑不起来后端"的旧假设
- 单二进制不能依赖 node_modules,`.node` 资源导入 dev 和编译两种形态行为不同,
**改完两种形态都要跑**
- SQL 判题 spawn 的是二进制自己,入口必须有 argv 分发,那道递归闸不能删
- 判题状态码三处同步、raw_password 要保留、比赛只有 ACM、前端要兼容老 Chrome
- 不写迁移:新旧后端跑同一套表结构,这是回滚能成立的前提
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-08 03:37:45 -06:00
c59ecd6dbb
fix(SQL 沙箱 Minor): 让题目的 memoryLimit 对学生真正生效
...
## M-1
题目写 64MB 也没意义 —— 唯一的硬顶是子进程那个固定 512MB 的 ulimit -d,
学生实际能吃到 8 倍。旧实现是 setlimit(SQLITE_LIMIT_LENGTH, memoryLimit)
把单值长度贴着题目内存限,但 sql.js 的 wasm 没导出 sqlite3_limit(已核对导出表),
复刻不了。
改成引擎侧按字节记账(ByteBudget):取行时累加,单值超限或结果集累计超限都按 MLE 拒。
max_page_count 管的是库文件页数,管不住「一个 SELECT 拼出一个巨大的值」,所以两者
不重复。查询题和增删改题(dumpTables)两条路都记账,受信脚本不记。
实测 6 条,关键是**同一句 SQL 在 4MB 的题上被拦、在 64MB 的题上放行**,
证明限制跟着题目走而不是一刀切。
第一版测试用的是 100MB 的值,结论是错的:wasm 堆触顶时的兜底和我的记账器报的是
同一句「单个数据值超出内存限制」,大值根本分不清是谁拦的。换成 8MB 才测得准。
这个教训写进注释了。
M-1 的第二半(max_page_count 学生可自行调大)在 I-1 拦 PRAGMA 时已一并修掉。
## M-3
阶段 5 的分阶段兜底超时顺带解决:出题人预览死循环从 25s 降到 13005ms 实测。
## M-2 不修
「强制两个测试点期望结果不同」会把合法出题也挡掉(刻意用两组数据验证同一边界、
结果恰好相同),代价是老师被一条看不懂的错误拦住。评审也确认与旧实现一致、非回归。
更合适的是提示而非拒绝,但要动契约和后台前端。理由写进评审文档,免得下次又当新发现。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-08 02:27:35 -06:00
cafa92a102
fix(阶段4评审收尾): 清掉三条 Minor,顺带一个真 bug
...
## M4 禁用账号会把学生卡在登录死循环里(唯一学生会撞上的)
`getSessionUser` 对禁用用户返回 null,于是落到 401 `login-required`,
而前端拦截器见到这个码就弹登录框 —— 一个上课上到一半被禁用的学生会陷入
「弹登录框 → 登进去 → 又被弹」,完全看不出发生了什么。
会话解析改成返回 `{ user } | { user: null, reason: "anonymous" | "disabled" }`,
禁用报 403 `account-disabled`(凭证有效、是账号不让用了,和 login 接口对禁用
账号的回法一致)。会话照删,禁用立即生效。前端补一支:清登录态 + 明确提示,
**不弹登录框**。
实测:会话中途 UPDATE is_disabled=true → 同一会话下一个请求
403 `account-disabled`「账号已被禁用,请联系老师」。
## M3 三个端点的守卫写在 handler 体内
submissions/statistics、submissions/:id/rejudge、flowcharts/statistics 的档位
本来就是对的,但写成 handler 里的 if,违背了「守卫要从注册行上看得出来」的约定,
下一个人加同类端点容易漏掉那个 if。改用 requireTeacher / requireSuperAdmin。
实测档位没变:普通学生三个都 403;教师统计接口 200、重判仍 403。
## M2 from-public 的错误码构成比赛存在性预言机
比赛不存在回 `not-found`、存在但不属于你回 `contest-not-found`,带一个已知
有效的 problemId 就能靠错误码枚举出哪些 contestId 真实存在。统一成
`contest-not-found`,和全仓其余跨租户路径一致。
实测:两种情况现在都是 404 contest-not-found。
## 顺带:比赛里的 SQL 题看不到示例数据
改 M2 时 tsc 报 `sqlDisplay` 声明了没用到 —— 查下去是真 bug:
`POST /contests/:id/problems` 把展示数据算出来了,却往库里写死 null
(公开题那两条路径都是对的)。于是比赛里的 SQL 题打开后没有示例数据表和期望结果。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-08 02:23:23 -06:00
ea521e7b0a
feat(阶段5): 镜像、三套 compose,前端切回 /api 与 /ws
...
## 镜像
一个 Dockerfile 两个 target:api(单二进制)和 web(Caddy + 前端产物)。
旧后端是一个容器里用 supervisord 跑 caddy+gunicorn+dramatiq,这里拆成
oj-web / oj-api / oj-worker 三个容器 —— Docker 本身就是进程管理器,
拆开之后 worker 挂了能单独重启、日志也分得开,少一层 supervisord 要维护。
同一个镜像换个子命令就是 worker,镜像里只有一份运行时。
新增 healthcheck 子命令:运行镜像是 debian-slim,没有 curl/wget,
让二进制自己打 /health(只打 /health 不碰库 —— 库挂了该由库的 healthcheck 报,
不该让 api 跟着被判不健康然后被重启)。
**数据目录照抄旧后端**(test_case、public/upload、public/avatar)。
不是审美问题:切换那天不用搬动任何文件,回滚时旧后端立刻能找到自己的数据。
少一次几十 GB 的 mv,就少一个在停机窗口里出错的机会。
构建路上踩到三个坑,都是「本地能过、容器里过不了」那一类:
- 构建上下文吸进了 data/,judge_server/run 是判题沙箱用别的 uid 建的,
docker 连 stat 都做不了,构建直接失败 → 补 .dockerignore
- mermaid@9.4.3(机房老 Chrome 的 legacy 依赖,不能砍)从容器里连
registry.npmjs.com 稳定失败,主机上没问题 → 换 npmmirror,并重试两次
- 容器里 bun 用 isolated 布局,本地是扁平的。靠「提升」才能解析到的包
在容器里一律解析不到:@node-rs/jieba-linux-x64-gnu(编译要 import 它的 .node)、
以及前端的 @codemirror/{language,state,view} 和 @lezer/highlight。
这些本来就是代码直接 import 的,补成直接依赖。顺手写了个脚本扫全仓,
确认只有这 4 个。
## 前端切回 /api、/ws
迁移期用 /api2、/ws2 指新后端,/api、/ws 还指着 Django。端点已全部搬完,
临时前缀去掉。改动只有三处(api2.ts 的 baseURL、websocket.ts 的两个 URL),
`api2` 那些 import 是模块名不是路径,不动。vite 代理同步收敛成三条。
## compose
- debian:全套(含 postgres,对外开 5445 给机房连)
- school:**没有 postgres**,连服务器的库;本地 Redis + 本地判题沙箱
- 两边共用一个库但各有各的队列和 WS 推送,和旧后端的 Dramatiq/Channels 拓扑一致
密钥一律走 env 且带 `:?`,没设置就直接报错退出,不静默用弱默认值。
COOKIE_SECURE 在机房必须是 false(http 直连 IP,带 Secure 的 Cookie 发不回来,
表现是「登录成功但立刻又变未登录」)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 23:41:20 -06:00
e2c6ee69da
fix(阶段5): jieba 资源导入不能用在 dev;补上 /public/upload 的伺服
...
## jieba:两种形态得走两条路
上一条 commit 把 jieba 的 .node 改成 `with { type: "file" }` 内嵌,只验证了
编译产物,没跑 `bun run` —— 结果 dev 直接起不来:
TypeError: To load Node-API modules, use require() or process.dlopen
instead of import.
`.node` 的资源导入只有打包器认,运行时不认。而且必须是**动态** import:
静态 import 在模块加载时就求值,拿 isCompiled 判断也来不及。
所以分两条路:dev 用包自己的入口(那条路 bun run 下是好的),编译形态才走
内嵌资源。两边都实测过了。.wasm 没这个毛病,两种形态都正常。
withBuiltinDict 因此变成 async,连带 buildWordFrequencies 也是。
jieba 实例改成缓存 Promise 而不是结果,并发进来两个请求只会建一次词典。
## /public/upload 之前根本没人伺服
后台上传图片存盘、返回 `/public/upload/<name>`,但没有任何路由处理这个前缀 ——
题面里插的图片一律 404。补上,并和头像共用一个 serveUpload:只取路径最后一段
且要求它和原样一致,`..`、子目录、编码斜杠都在这里被拒。
生产环境这些请求也走后端(Caddy 反代整段 /public),不让 Caddy 直接读盘,
这样开发和生产是同一条代码路径。
dev 模式 8 项实测:上传图片/头像/默认头像回退 200,不存在、`..`、编码穿越、
子目录、编码斜杠全 404。编译产物在干净目录里 7 项照旧全过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 23:20:12 -06:00
ce21a2bb8f
feat(阶段5): 让 bun build --compile 的产物真正自足
...
编译产物拿到没有 node_modules 的目录里跑,原来是一路崩的 —— 而且**在仓库
目录里跑时全都正常**,因为它顺着 cwd 找到了 node_modules,假装没事。
这类问题只会在服务器上第一次启动时暴露。逐个堵掉:
- `import.meta.dir` 在二进制里恒为 `/$bunfs/root`,往上三级就是文件系统根:
`data/test_case` 悄悄变成 `/data/test_case`,`.env` 去读 `/.env`。
新增 runtime.ts 显式分叉:编译后按 cwd,开发时按仓库根(后者不能改,
compose 挂给判题沙箱的是仓库根的 data/test_case)。
- sql.js / tree-sitter 的 wasm、jieba 的 .node 和 4.8MB 词典,
原来都靠运行时 require.resolve / Bun.resolveSync / __dirname 去 node_modules 里找。
全部改成 `with { type: "file" }` 内嵌成资源。jieba 尤其绕:它的 index.js
运行时探测平台再 require 子包,dict.js 用 __dirname 读 dict.txt,两条都
依赖磁盘布局,所以单独包了 vendor/jieba.ts 直接 require 内嵌的 .node。
- SQL 判题要 spawn 一个能被 SIGKILL 的子进程,原来 spawn 的是 child.ts 的路径,
编译后那个文件不存在。改成二进制自己按 argv 分发:新增 main.ts 作为唯一入口,
serve / worker / sql-child 三个子命令,镜像里只需要一份运行时。
## 顺带修掉一个能拖垮生产的坑
「spawn 自己」意味着只要 argv 分发这一环出问题(子命令改名没同步、compose 里
command 写错、拿别的入口编了二进制),「起自己」就变成「把整个程序再跑一遍」,
而那一遍又会 spawn 一个自己 —— 指数增长。
这不是假想:开发时用一个没有分发器的临时入口编了个二进制,一跑就递归 fork
105MB 的进程,几秒触发 global OOM,内核把开发机的终端杀了
(`selftest invoked oom-killer ... Killed process ... Alacritty`)。
同样的错误发生在服务器上就是判题机连同数据库一起拖死。
加了递归闸:父进程 spawn 时打 OJ2_SQL_CHILD 标记,带标记的进程一律拒绝再 spawn,
最多一层就停在一条明确的 SYSTEM_ERROR 上。
## 验证
产物拷进只有它自己一个文件的目录,2G 内存上限下跑,7 项全过:
jieba 分词(自定义词「循环结构」「死循环」命中)、tree-sitter Python3/C 各两条
(含一条**预期失败**的规则做对照,否则「语言没加载成功」和「规则通过」返回值
一模一样,分不出来)、SQL 判题 AC、SQL 只读防护仍拦住 PRAGMA。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 23:13:57 -06:00
0f94e7ecbd
fix(阶段4): 两份独立安全评审的 Critical/Important 全部修掉
...
## 权限边界评审(后台 86 个 handler)
守卫本身一个没漏,问题全在对象级归属校验:
- C1 跨题单删奖章:先按 (id, problemsetId) 校验奖章归属再删,
否则拿自己的题单 id + 别人的奖章 id 就能把别人的 user_badge 删掉
- C2 make-public 无归属校验:补 canEdit,越权者拿不到题面
- I1 两个分析端点被 `/problems/:id` 遮蔽 —— Hono 按注册顺序匹配,
不是静态优先。挪到 `/problem-analytics/*`
- I2 from-public 只校验目标比赛归属:源题也必须是公开题库题
- I3 克隆比赛回传原比赛明文密码:克隆一律 password: null
- I4 upload-image 守卫比旧后端严,教师写题面会 403:收回 requireAdmin
两个互不可见的教师账号实跑复验,六条全部拦住。
## SQL 判题沙箱评审
- I-1 查询题只读被一句 `PRAGMA query_only=0` 关掉,实测 DML 拿到 AC。
query_only 自己就是个 PRAGMA,旧实现靠 authorizer 把 SQLITE_PRAGMA
一律拒了才没这个洞。现在 runStudent 逐语句拦 PRAGMA(用
sqlite3_normalized_sql 判关键字,注释和大小写由 SQLite 抹平),
并在每条语句前重放 query_only 和 max_page_count 兜底。
顺带修掉 M-1 里 max_page_count 学生可自行调大的部分。
- I-2 单条语句进了 step() 就打断不了,只能等父进程 SIGKILL,
而兜底时限是整个作业一口价 25s —— 1s 限的题要 26s 才判 TLE,
判题池只有 2 个槽,几发死循环就能把所有人堵住。
改成分阶段:子进程用 stderr 报 prepare/student/display,
父进程边读边换表,一进学生 SQL 就把兜底收到「题目时限 + 2s」。
实测 26s → 3.06s。
归因也跟着修了:卡在受信脚本(出题人的初始化脚本、标准答案)
现在报 SYSTEM_ERROR,不再当成学生超时甩 TLE。
engine.ts 头部那张防护对照表按实测重写 —— 原来那版把 query_only
写成等价于 authorizer 白名单,是不成立的。另记一笔:stock sql.js 的
wasm 没导出 progress_handler / interrupt / set_authorizer / limit,
想要得自己编,别再去翻了。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 21:59:42 -06:00
e3faa689e7
feat(阶段2补课): SQL 判题链路 + 最后两个后台端点
...
新后端此前完全没有 SQL 判题(旧 judge/sql_runner.py 378 行 + sql_dispatcher.py
113 行无对应实现),阶段 2 纵切时漏了这条与沙箱完全不同的路径。
judge/sql/engine.ts 判题核心,移植自 sql_runner.py,判定口径逐条对齐
judge/sql/child.ts 子进程入口
judge/sql/index.ts 父进程:spawn + 硬超时
judge/run.ts language === "SQL" 时分流,不经判题沙箱
POST admin/sql-test-cases/preview 题目页展示数据预览
POST admin/sql-test-cases/generate AI 按标准答案倒推初始化脚本
题目保存时重新生成 sqlDisplay(对齐旧 generate_sql_display):取测试点 1 的初始化
脚本 + 标准答案跑一遍,失败一律拦下不让保存 —— 展示数据直接决定学生看到的表结构
与期望结果,宁可不保存也不能存错的。
## 防护换了实现,逐条实测
bun:sqlite 没有 authorizer / progress_handler / setlimit,且实测 Worker.terminate()
杀不掉跑飞的查询(原生代码占着线程)。改用「WASM 引擎 + 独立子进程」:
ATTACH → WASM 无宿主文件系统绑定,结构上够不到(比旧的 authorizer 更强)
查询题只读 → PRAGMA query_only=1
超时 → 子进程外部 SIGKILL
单值内存 → 子进程 ulimit -d
八条提交实测:正确→Accepted;列少一个/漏过滤→Wrong Answer;语法错误→Compile Error;
查询题里 INSERT→运行错误并说明;递归 CTE 死循环→CPU 超时;hex(zeroblob(2e8))→内存超限;
attach '/etc/passwd'→打不开。
## 踩到的两个坑(已写进 docs/specs/phase3-coverage.md)
1. ulimit 必须用 -d 不能用 -v。-v 限虚拟地址空间而 JS 引擎预留巨量地址,实测 -v 之下
Bun 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止,父进程读到空串
误判成超时 —— 6 次里坏 2 次。换 -d 后 12/12 稳定。
2. 子进程写完结果直接 SIGKILL 自己,不走 process.exit()——后者仍有清理会撞限额。
至此 admin/api.ts 已无任何指向旧后端的调用。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 17:03:41 -06:00
007ae8619b
feat(阶段4): SQL 测试点脚本回显
...
GET admin/problems/:id/sql-scripts
只读磁盘上的 N.sql,不需要 SQL 引擎 —— 同组的 sql-preview / sql-ai-gen 要跑 SQLite
生成展示数据,新后端还没有那条链路,那两个仍指向旧后端。
实测:SQL 题回显两个脚本内容正确;测试点不是 SQL 类型返回 409 并说明;题目不存在 404。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 16:45:15 -06:00
cd5dd16f3b
feat(阶段4): 测试用例压缩包上传与下载
...
POST admin/test-cases
GET admin/problems/:id/test-cases (返回 zip 二进制)
落盘格式必须与判题沙箱镜像的约定一致(沙箱直接读挂进去的目录),已用真判题验证:
上传 zip → 建题 → 提交 Python 解法 → 沙箱读到用例并判出 Accepted。
安全与健壮性上比旧后端多做的几件事:
- **zip slip 从设计上进不来**:不遍历压缩包条目,只按精确文件名(`N.in`/`N.out`/`N.sql`)
取内容,条目名一律不参与路径拼接。实测带 `../../etc/passwd` 条目的包能正常处理,
且只取到 1.in/1.out。
- 单文件 32MB、解压后总量 128MB、测试点数 500 的上限,防 zip bomb 与写满磁盘 ——
旧后端一概没有,机房那台机器盘写满之后判题也会一起挂。
- 坏 zip 返回 400 而不是 500。
对齐旧后端的细节:CRLF→LF 归一;`stripped_output_md5` 按 Python `bytes.rstrip()`
的口径只剥尾部 ASCII 空白后再算(实测与 hashlib.md5 结果一致);编号从 1 起连续、
遇缺口即停;SQL 包至少 2 个测试点(题目页会展示测试点 1 的期望结果,只有一个时
学生可以对照着硬编码 AC);目录 0710、文件 0640。
## 顺带修掉一个只在判题时才暴露的路径 bug
config 里的相对路径(data/test_case、data/avatar、data/upload)原先按进程 cwd 解析,
而起服务的方式会把 cwd 切到 apps/api/,于是测试点落在 apps/api/data/ 下 ——
但 docker/compose.dev.yml 把**仓库根**的 data/test_case 挂进判题沙箱。两边不是同一个
目录,新传的测试点判题时会「找不到测试数据」,且只在真正判题时才暴露。
改成一律按仓库根解析,实测沙箱能看到新传的目录。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 16:42:54 -06:00
e396d78a07
feat(阶段4): 题目管理 / 比赛题目 / 比赛题⇄公开题互转
...
GET/POST admin/problems
GET/PUT/DELETE admin/problems/:id
POST admin/problems/:id/make-public
GET/POST admin/contests/:contestId/problems
POST admin/contests/:contestId/problems/from-public
合并了旧后端拆开的两套路由:旧 admin/problem 与 admin/contest/problem 的
GET 详情、PUT、DELETE 都只按题目 id 取、比赛是从题目推导出来的,分开没有意义,
还逼前端多传一个它未必知道的 contestId。现在共用 /admin/problems/:id。
归属判断按旧后端的口径分开:**比赛题看比赛的创建者,公开题看题目自己的创建者**
(旧 ensure_created_by(problem.contest, user) vs ensure_created_by(problem, user))。
一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
题号唯一性的作用域也跟着走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一。
删题不删测试用例目录,与旧后端一致(它把 rmtree 注释掉了):删错了还能从磁盘捞回来,
而误删的测试数据没有别处备份;孤儿目录另有清理入口。有提交记录的题目拒绝删除。
## SQL 题目前只能编辑、不能新建
新后端**没有 SQL 判题链路** —— 旧 judge/sql_runner.py(378 行)+ sql_dispatcher.py
(113 行)无对应实现,languages.ts 里也只有 C/C++/Java/Golang/JavaScript/Python3 六种。
题目页给学生看的表结构与期望结果(sql_display)要跑 SQLite 现算,算不出来就没法建题。
因此:新建 SQL 题返回 501 并说明原因;**编辑已有 SQL 题时原样保留 sqlDisplay 不动** ——
不动它比生成一个错的安全,它直接决定学生看到的期望结果。SQL 的前置校验
(必须是唯一语言、要有 sql_config、要有 SQL 标准答案)照旧执行。
实测:学生 403;缺样例/缺输入描述/SQL 混语言三条校验文案与旧后端一致;
题号重复 409、跨比赛题号互不影响;公开题加入比赛后计数归零且标签带过来;
比赛题转公开后 visible=false、contestId=null、重复转 409;删除级联正常。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 16:37:20 -06:00
764e0d28cc
feat(阶段4): 标签管理 / 批量打标签 / 题目可见性 / 卡点与 AC 趋势 / 流程图 AI
...
GET/PUT/DELETE admin/problem-tags[/:id]
POST admin/problems/batch-tag
PUT admin/problems/:id/visibility
GET admin/problems/stuck
GET admin/problems/ac-trend
POST admin/problems/flowchart
要点:
- 后台标签列表用 leftJoin 且不加 having,能看到 problemCount=0 的标签 ——
那正是要清理的那些。oj 侧的 /problem-tags 才过滤 >0。
- 标签改名撞上已有标签视为合并:只给「还没挂目标标签」的题目补关系,
否则会撞 (problem_id, problemtag_id) 唯一约束。
- 批量打标签:add 时按需新建标签、remove 时只认已有标签 ——
否则「移除」会顺手造出一堆空标签。名字去重且大小写不敏感。
- **旧 ProblemVisibleAPI 的 `self.error(...)` 少写了 return**,题目不存在时会继续
往下跑并抛 AttributeError(500)。这里正常返回 404。
顺带记下一条阶段 5 的必做项(见 phase3-coverage.md 文末):本地库按显式 id 从生产
导入,序列没跟着走,第一次新建标签就撞 problem_tag_pkey。只要切换流程里有
「导出→导入到新库」这一步,就必须重置全部序列,否则读全正常、第一次写才炸。
实测:学生 403;大小写重复与空白名去重后 tagCount=1、重复 add 幂等、
remove 不存在的标签 404 且不新建;纯改名 merged=false、合并 merged=true
affectedCount=1 且只剩一个标签;可见性取反两次复原、不存在的题 404。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 16:30:48 -06:00
5e0229db12
feat(阶段4): 题单管理(题目 / 奖章 / 进度三层)
...
GET/POST admin/problem-sets
GET/PUT/DELETE admin/problem-sets/:id
PUT admin/problem-sets/:id/visibility (取反语义,与旧一致)
PUT admin/problem-sets/:id/status
GET/POST admin/problem-sets/:id/problems
PUT/DELETE admin/problem-sets/:id/problems/:itemId
GET/POST admin/problem-sets/:id/badges
PUT/DELETE admin/problem-sets/:id/badges/:badgeId
GET admin/problem-sets/:id/progress
DELETE admin/problem-sets/:id/progress/:userId
修掉旧后端三个问题:
1. **后台列表写死了 visible=True,可它同时又提供「切换可见性」的接口** ——
一旦把题单设成不可见,它就从后台列表消失,再也没法在界面上改回来。
新实现不按 visible 过滤,后台能看见自己管的全部题单。实测取反两次仍在列表里。
2. **加题/删题/改分值后不重算学生进度**。往题单里加一道题,学生那边的
totalProblemsCount 还是老数字,进度百分比因此偏高,甚至已「完成」的人分母变了
却还标着完成。新实现加了 resyncProgress,实测 2/2 加一道题后变成 2/3 = 66.67%。
只重算分母与百分比,不碰 completeTime —— 已完成过的事实不因加题而撤销。
3. 把人踢出题单时一并收回他基于这份题单拿到的奖章,否则奖章悬空。
奖章重算对齐旧 recalculate_user_badges(那边靠 post_save 信号,这里显式调):
只增删差集、保留已有记录的 earnedTime,否则每改一次条件所有人的获得时间都会
刷新成今天。实测 all_problems 建成时补发 1 人 → 加题后收回 → 改成
problem_count>=2 后重新发出 → 踢人后收回。
删题单要按序清五张子表(全是 NO ACTION 外键):user_badge 挂在 problemset_badge
上,得先于 badge 删。
实测:学生 403;重复加题 409、加不存在的题 404、野状态/野奖章条件 400;
级联删除后五张表全为 0。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-08-07 16:25:14 -06:00