Commit Graph

228 Commits

Author SHA1 Message Date
8f08ed03a0 fix(流程图): 学生能翻出全班评分、AI 调用没有限流
## 列表漏了一道门

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

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

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

## 提交与重判没有限流

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

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

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

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

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

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

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

## 重连

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

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

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

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

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

## 订阅重放

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

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

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

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

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

## 会话

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

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

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

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

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

## 开销与安全

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

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

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

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

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

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

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

## 没动的

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

## 验证

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 05:07:26 -06:00
47a43b9880 fix(提交列表): 转圈圈延迟 300ms 出现,快请求不再闪一下
Some checks failed
Deploy / deploy (push) Has been cancelled
翻页、切筛选大多一百毫秒内就回来了,转圈圈闪一下再切数据比直接切更晃眼。
给 loading 加一层延迟:只有真的慢到 300ms 以上才显示。

是**单向**延迟 —— 只推迟「显示」,请求一结束立刻收掉,不留尾巴(用
refDebounced 之类的双向去抖会在数据到了之后还多转 300ms)。

没有用 n-spin 的 delay:n-data-table 压根不走 n-spin,它渲染的是内部的
_internal/loading,直接由 loading 控制、没有 delay 参数。要用内置的就得把表格
包进 <n-spin :show :delay> 再去掉 :loading —— 那会换掉加载样式,还会丢掉
loading 时锁住排序/分页交互(DataTable 里那个 `disabled: this.loading`)。
这几行就是 n-spin 内部同样的写法,理由写在注释里了。

验证:headless chromium 走 CDP,只给 /api/submissions 加延迟(给全站加延迟的话
dev 模式几百个模块请求全被拖慢,页面根本起不来,第一次就是这么测出假结果的),
每 120ms 采一次 DOM:

    API 不加延迟   转圈圈全程没出现,表格直接 0 行 → 5 行
    API +800ms     表格 291ms 挂上 → 654ms 转圈圈出现(挂载后约 300ms)
                   → 1138ms 数据到位 → 1381ms 收掉

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:53:40 -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
bc0b782443 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-08-27 00:42:39 -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
8600f8143a fix(MaxKB): 「启用 MaxKB」开关在生产构建下拦不住脚本
vite 的 inject-maxkb 插件把 `<script src>` 写进 index.html 的 head,于是构建
产物在 Vue 启动之前就把第三方挂件拉下来执行了。App.vue 的 useMaxKB() 那条运行时
路径只能事后把标签和 `[id^="maxkb-"]` 的 DOM 删掉 —— 代码早跑完了。表现就是后台
把开关关掉、刷新,挂件的副作用照旧(其中之一是它自带的性能上报在每个页面抛
`Cannot read properties of undefined (reading 'startTime')`,堆栈全是 <anonymous>
因为它 bootstrap 时又 eval 了一层),而且关不掉。

改成只留运行时那一条路:

- 删掉 inject-maxkb 插件,index.html 不再带这个 script 标签。原地留注释说明
  为什么不能加回去。
- config store 的 enableMaxkb 默认值从 true 改成 false。默认 true 的话
  getConfig() 还没回来挂件就已经加载了,服务端说「关」依然晚一步。代价是配置
  拿不到时挂件不出现 —— 第三方脚本 fail closed 是对的方向。
- URL 为空的兜底从 vite 插件挪进 loadMaxKBScript,否则会拿 undefined 当 src。

验证:headless chromium 走 CDP 记录网络请求。

- dev(5173):enable_maxkb=false → 0 个请求、0 个 script 标签;改成 true →
  发出 embed 请求、head 里 1 个标签。
- 生产构建(bun run build 后把 dist 静态跑起来、/api 转发到后端):同样两种
  配置下分别是 0 和 1。构建出来的 index.html 里已经没有 maxkb 的 script 标签,
  只剩原本就在源文件里的那行 maxkbMaskTip。

MaxKB 脚本自身那个 startTime 报错是它自己的 bug,本次只保证「关得掉」,没去
动第三方代码。

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

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

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

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

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

用户可见的列表:

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

后台批处理:

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

写入路径:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:43:57 -06:00
f00c941ede perf(构建): builder 分开拷源码,只改后端不再陪编一次前端
Some checks failed
Deploy / deploy (push) Has been cancelled
builder 阶段是 `COPY . .` 一把梭,而 docker 的层缓存是链式的:那一层的校验和
覆盖整个仓库,改任何一个文件都会把它作废,后面 vite build 和 bun compile 全部
重跑。前端那 160s 一次都躲不掉,改一篇 markdown 也要陪着编。

按各自的输入分开拷,慢的那条放前面(缓存链上越靠前越不容易被碰到):

  只改 apps/api           前端两层命中缓存,只重编后端       ~1s
  只改 apps/web           前端重编,后端被链条带着            ~160s
  改 packages/contract    两边都重编(本来就都依赖它,是对的)
  改 docs / 根上的杂项    builder 根本看不见                  0

代价是 builder 只看得见这里点名拷进来的四样,新加顶层目录要同步加 COPY。

顺带堵掉两个同源的洞:

· apps/web/docs、tests、**/CLAUDE.md 进了 .dockerignore。它们在 apps/web 里面,
  整个目录拷进去的话,改一篇文档也会让 vite build 那层失效。

· .dockerignore 末尾为 --prebuilt 开的例外(!dist/oj2-api、!apps/web/dist)让本地
  产物照样进构建上下文 —— 这是比 `COPY . .` 更隐蔽的一条:在本机编一次后端,
  80MB 的 dist/oj2-api 变了,rsync 上去,服务器就重编一次前端,源码一行没动。
  dist/oj2-api 现在不再被 builder 拷贝;apps/web/dist 仍在 COPY apps/web 里面,
  所以 deploy.sh 头上那条手动 rsync 补了 --exclude dist。CI 走 --prebuilt,
  builder 整个不进构建图,不受影响。

验证:用老 Dockerfile 和新 Dockerfile 各构建一次 --target artifacts 导出对比,
后端二进制 sha256 相同,前端 dist 500 个文件逐个 sha256 一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:25:15 -06:00
1d9686b929 feat(后台用户): 管理员密码默认打码,点「显示」才展开
Some checks failed
Deploy / deploy (push) Has been cancelled
后台用户列表把所有账号的明文密码直接铺在表格里。学生密码是有意保留的运营需求
(老师要能查),但管理员账号的密码跟着一起明文显示,投屏或者旁人路过就看到了。

现在学生管理员/教师管理员/超管三类账号的密码默认渲染成 •••••• 加一个「显示」
按钮,点一下该行展开成明文(仍然是 TextCopy,点击复制照旧)。普通用户不变。
展开状态按 user id 记在 list.vue 里,listUsers() 里清空,所以翻页、搜索、换筛选、
换排序之后一律回到打码;不做定时自动隐藏,也不记进 localStorage。

只改前端。rawPassword 照旧随列表下发——/users* 每条路由本来就是 requireSuperAdmin,
非超管连列表都拿不到,要防的是屏幕被看到,不是拿到响应的人。改成「按需取单个密码」
反而要多开一个下发明文的端点,把面扩大。

rawPassword 为空时不打码:给一个点了什么也不显示的按钮没意义,直接走原来的 TextCopy。

顺带把角色标签缩短(学生管理员→生管、教师管理员→师管)。getUserRole 只有同一张表的
用户名列在用,密码列变宽之后那一列需要让出横向空间。

实跑验证(本机 dev 栈 + 超管登录):管理员行打码、学生行明文照旧、点「显示」后展开
且仍是可复制的按钮、搜索触发重新拉列表后回到打码。vue-tsc 与 build 均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:08:30 -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
1f9f33aa3b docs: 0002 迁移已在生产库执行完毕
Some checks failed
Deploy / deploy (push) Has been cancelled
`docker exec oj-api oj2-api migrate` 回「没有待执行的迁移」,确认
0002_drop_django_leftovers 已经跑过,7 张 Django 框架表已从生产库删除。

CLAUDE.md 顶部原本写「生产库尚未执行」,切换手册的「回滚保证」写「即将作废」,
两处都过期了。旧栈现在是真的起不来,回滚只剩从备份恢复这一条路,措辞相应改成
既成事实。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:18:53 -06:00
7793a2fe4c 清理旧后端下线后的残留
Some checks failed
Deploy / deploy (push) Has been cancelled
旧 Django 后端 2026-08-26 下线、回滚路径作废,代码里还留着几处以它为前提的
死代码和过期注释。

- PUBLIC_WS_URL / PUBLIC_OJ_URL 全部删除。BaseWebSocket 的
  `${PUBLIC_WS_URL}/${path}/` fallback 是 Channels 时代的写法,而三个子类
  早就各自传 url,等于永不执行;config.vue 里 PUBLIC_OJ_URL 只是个会被
  getWebsiteConfig() 立刻覆盖、且指着 :8000 的假初值。WebSocketConfig.path
  随之去掉,url 改成必填。
- vite.config:删掉 /api2、/ws2 的迁移期注释,换成还成立的约束(代理这三段
  要和 docker/Caddyfile 同步);newBackend 改名 backend。
- 六处 “回滚时旧后端还要读” 换成真实理由:snake_case 保留的结论不变,但因为
  判题机按这套键名写、存量 JSONB 就是这形状。
- CLAUDE.md:数据库一节开头「不写迁移 + 先想清楚回滚」与下一节的 drizzle
  migration 自相矛盾,改掉;判题状态码不再要求同步到 OnlineJudge,理由换成
  历史 submission.result 和沙箱用的就是这套编码。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:09:26 -06:00
64cb7b62ef ci(部署): 手动触发可放行破坏性迁移,push 触发永远不放行
Some checks failed
Deploy / deploy (push) Has been cancelled
deploy.sh 现在会在起栈前跑迁移,但 CI 没法传 OJ2_ALLOW_DESTRUCTIVE,
于是任何破坏性迁移都会让自动部署一直红着,且没有出口。

给 workflow_dispatch 加一个 allow_destructive 布尔 input,经 env: 传到
ssh 命令里。push 触发拿不到 inputs,值恒为空 —— 也就是说**自动部署永远
不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把工作流判红。这是有意的:
那种改动得有人先确认备份,不该由一次 git push 顺带完成。

日常的加索引 / 加列属于非破坏性,CI 部署会照常自动执行,无需干预。

留了一条注释标明必须用 `inputs.` 而不是 `github.event.inputs.`:后者会把
布尔字符串化,没勾选时拿到字符串 "false",那是非空字符串、在 `&&` 里为真,
等于每次手动部署都无条件放行。

验证:本机 YAML 解析通过,确认 inputs 定义挂在 workflow_dispatch 下。
表达式三种情况的语义(勾选 → '1'、未勾选 → ''、push 事件 inputs 为空 → '')
是 GitHub 表达式的既定行为,要真的 push 到 github 才跑得起来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:38:47 -06:00
5f04f3283f fix(部署): 迁移那步的服务名写成了 api,实际是 oj-api
Some checks failed
Deploy / deploy (push) Has been cancelled
`docker compose run ... api oj2-api migrate` 直接报 `no such service: api`。
compose 里六个服务全带 oj- 前缀(oj-api / oj-worker / oj-web / oj-judge /
oj-postgres / oj-redis),没有叫 api 的。

顺带修掉失败提示:原来不管迁移因为什么失败,都说「破坏性迁移需要
OJ2_ALLOW_DESTRUCTIVE=1 显式放行」——而这次的真实原因是服务名不存在,
提示把人往完全错误的方向带。现在改成让人去看 migrate 自己的输出
(它对每种情况都打印了具体该做什么),只把三种常见原因列成线索。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:32:19 -06:00
19054718d5 fix(部署): 迁移那步去掉 --no-deps,否则自带数据形态下起不来库
Some checks failed
Deploy / deploy (push) Has been cancelled
上一个提交让 deploy.sh 支持「自带数据」形态,但迁移那步还留着 --no-deps。
`run --rm --no-deps api oj2-api migrate` 不会拉起 oj-postgres,于是这一步
直接连不上库 —— 而它正好排在 `up -d` 之前,postgres 这时候还没起。

oj-api 的依赖恰好就是 oj-postgres / oj-redis,两种形态都是对的:

- 自带数据:靠 run 把 postgres 起来,depends_on 的 condition: service_healthy
  还顺带保证库真的就绪了才开始迁移
- 外接:那两个服务不在启用的 profile 里,depends_on 上的 required: false 让
  compose 跳过,不会多起东西

worker / web 不是 api 的依赖,两种形态下都不会被带起来,去掉 --no-deps
不会有副作用。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:25:17 -06:00
e6c8c3c155 feat(部署): deploy.sh 识别「自带数据」形态,不再写死试跑假设
Some checks failed
Deploy / deploy (push) Has been cancelled
旧 Django 后端下线后要把 postgres / redis 收回 OJ2 自己的 compose 管。
compose 那边本来就备好了(oj-postgres / oj-redis 挂在 local-data profile 下,
镜像和旧栈同版本),但 deploy.sh 三处都写死了「并行试跑」的假设,会直接挡住:

- COMPOSE 没有 --profile local-data,自带的两个容器永远不会被启动
- 自检② 见到 DATABASE_URL 指向 oj-postgres 就 die —— 而合并后它必然指向那里
- 自检④ 要求已经有 oj-postgres 在跑 —— 旧的停了、新的还没起,正好卡死

现在按 DB_HOST 是否为空自动判形态。判据用 DB_HOST 而不是另加开关,是因为它本来
就决定了 DATABASE_URL 指向谁;两个变量各说各话迟早会出现「起了自带的库、却连到
别处」这种自相矛盾的状态。

自检② 改成两个方向都查:外接形态下不许指向 oj-postgres,自带形态下必须指向
`oj-postgres:5432`。连端口一起查是因为清了 DB_HOST 却忘了清 DB_PORT 会拼出
`oj-postgres:5445` —— 那是宿主机映射端口,容器网络里不通。

新增自检②b:自带形态下检查 $DATA_DIR/postgres/PG_VERSION 存在且是 16。
这是整个部署里唯一会**静默**走歪的地方 —— DATA_DIR 不对的话 postgres 当成全新
部署,在空目录上初始化一个空库,站点起得来、能注册能登录,就是一道题都没有。

验证:用模拟的 env 跑 `docker compose config` 确认

  --profile local-data  → 服务多出 oj-postgres / oj-redis
  DATABASE_URL          → postgres://onlinejudge:...@oj-postgres:5432/onlinejudge
  REDIS_URL             → redis://oj-redis:6379
  卷                    → /root/OJDeploy/data/{postgres,redis}(旧栈同一批目录)

并验证了「清了 DB_HOST 但忘清 DB_PORT」会被自检②拦下、正常配置会放行。
形态判定对空值 / 非空值 / 缺失三种情况都试过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:19:28 -06:00
66d325d460 ci(部署): rsync 加 --no-owner --no-group,别把 runner 的 uid 搬到服务器
Some checks failed
Deploy / deploy (push) Has been cancelled
服务器上 `git pull` 报「检测到可疑的仓库所有权」:

    drwxr-xr-x 9 1001 1001  /root/OJDeploy/OJ2
    drwxr-xr-x 8    0    0  /root/OJDeploy/OJ2/.git

原因是 `rsync -a` 展开含 `-o -g`(保留属主/属组),接收端又是 root,
所以源端的 uid 会被真写上去 —— GitHub 托管 runner 的 runner 用户是 uid 1001。
而 `.git` 在 --exclude 里,一直是当初手动 clone 时的 root。两者对不上,
git 2.35.2 起的 safe.directory 检查就把服务器上所有 git 操作挡下了。

加 --no-owner --no-group 之后文件落地归 ssh 登录用户(root)所有,不再错位。

注意这只防复发,**不修复已经错位的目录**:rsync 不带 -o/-g 时不会去改已存在
文件的属主。服务器上还要手动跑一次

    chown root:root /root/OJDeploy/OJ2

只改这一个目录,**不要 -R** —— git 的属主检查不递归,而 OJ2/data 下可能是
「自带数据」形态的整个 postgres 数据目录和判题沙箱以别的 uid 建的运行目录,
递归 chown 会让它们起不来。

这个问题一直都在,只是以前没在服务器上跑过 git 命令所以没撞上。

验证:只做了 YAML 语法校验 —— 这条工作流要真的 push 到 github 才跑得起来,
本机验不了。

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

## 1. 让 drizzle-kit generate 可用

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

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

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

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

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

## 2. 0001 提交列表索引

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

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

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

## 3. 0002 删掉 Django 残留

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

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

## 4. migrate 接进部署与 CI

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

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

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

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

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

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

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

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

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

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

**前端**:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:55:47 -06:00
a9332df6bd update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-08-26 00:28:18 -06:00
7b6bfeb6fb fix(标签): /problem-tags 计数漏过滤隐藏题和赛题
Some checks failed
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
d3365ecb32 ci(部署): actions/checkout v5→v7、actions/cache v4→v6
Some checks failed
Deploy / deploy (push) Has been cancelled
`oven-sh/setup-bun` 已经在最新大版本(v2.2.0 属于 v2 线),不动。

**actions/cache v4 → v6** 是这次的实际收益:v4 跑在 **node20** 上,GitHub 正在
退役这个 runtime;v6 是 node24,和另外两个 action 一致。v6.0.0 的改动是「迁到
ESM + 更新依赖」,没有输入输出上的破坏性变更。

  ⚠️ cache v5 起要求 Actions Runner >= 2.327.1。这里 `runs-on: ubuntu-latest`
  是 GitHub 托管的、始终满足;哪天换自建 runner 要留意这条(已写进 yml 注释)。

**actions/checkout v5 → v7**:v5 本来就跑在 node24,运行时没变。两条标了
BREAKING 的改动都打不到这个工作流:

- `allow-unsafe-pr-checkout`(更安全的 pull_request_target 默认值)已经反向
  移植到 v2 到 v6 所有线,v5 上本来就有;
- v7.0.0 的「block checking out fork pr」只影响 `pull_request_target` 和
  `workflow_run`。

本工作流的触发器只有 `push: main` 和 `workflow_dispatch`,两者都不涉及。

版本按仓库原有习惯钉大版本号,没钉到 patch。

验证:只做了 YAML 语法校验 —— **这条工作流要真的 push 到 github 才跑得起来,
本机验不了**。下次 `git push github main` 时留意一下 Actions 那边。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 00:01:28 -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
Some checks failed
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(前端): 清掉四处零引用的死代码
Some checks failed
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 字段
Some checks failed
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
3f55d231c3 refactor(契约): samples / answers / testCaseScore 补回旧后端的校验
Some checks failed
Deploy / deploy (push) Has been cancelled
旧后端这三个字段都是逐字段校验的(CreateSampleSerializer /
CreateAnswerSerializer / CreateTestCaseScoreSerializer),新后端一路写成
`z.record(z.string(), z.unknown())`,**比旧的松**。松出来的不只是少拦几个
错误请求,还有一处静默的落库形状漂移。

## test_case_score 会越存越胖,而且 score 变成字符串

上传接口的响应有六个键,前端 `{...entry, score}` 原样往回传。旧后端的 DRF
serializer 只认 input_name / output_name / score,多的直接丢,所以生产库
956 行 test_case_score **全部只有三个键**、7990 条 score 全部是 int
(前端算出来是 `(100/n).toFixed(0)` 这种字符串,IntegerField 收下时转了)。

新后端不做这件事,往后在 OJ2 上新建或编辑的题会存六个键、score 是字符串 ——
和旧后端写出来的不是一个形状。这列判题机不读,不影响判题,但它是回滚要
原样交回去的持久化数据,不该在这上面分叉。

现在三个精确 schema 顶上:problemSampleSchema / problemAnswerSchema /
problemTestCaseScoreSchema。zod 的 object 默认剥未知键,和 DRF 同一个行为;
score 用 `z.coerce.number().int().min(0)`,对齐 IntegerField 的收字符串转整数。

前端跟着改:`Testcase` 从 `TestCaseEntry & { score: string }`(六键+字符串分数)
换成契约的 ProblemTestCaseScore(三键+整数),三处构造点只挑落库要的键。
detail.vue 那处写成 `Number((100 / n).toFixed(0))` —— 取值和原来逐字相同,
只是不再包成字符串,分数算法一个字没动。AdminProblem 的 Omit 列表也短了两项
(samples / testCaseScore 现在契约里就是准的),只剩 answers 要把 language
收窄成 LANGUAGE。

## 顺带:一段被 @ts-ignore 压着的死代码

admin/problem/detail.vue 上传测试点那里有

    // @ts-ignore
    if (res.error) { ... }

—— 拿 { error, data } 信封当返回值判。上一个 commit 拆信封时正是因为
@ts-ignore 压着,vue-tsc 没报出来。res 现在是 UploadTestCaseResponse,
没有 error 这个键,这个分支永远进不去。失败本来就走 catch。

(全仓另外两处 @ts-ignore 查过了,是 skulpt 和 wangeditor 没类型定义,正常。)

## 生产数据依据

956 道题逐条扫过备份:samples 947 条 {input,output} + 9 条空数组(SQL 题没
样例);answers 268 条 {code,language} + 633 null + 55 空数组;
test_case_score 956 条全是三键,score 全 int。收紧不会打到任何存量行。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打了写路径:

- 后台题目详情 200,三个 schema 都 parse 得过存量数据。
- 故意造脏 PUT:score 传字符串 "20" + 塞进 stripped_output_md5 / input_size /
  output_size,落库是干净的 `{input_name, output_name, score: 20(int)}`;
  samples 和 answers 里塞的多余键同样被剥掉。
- 三个错误载荷都按预期 400:samples 缺 output、answers 缺 code、score 传负数。
- 浏览器里打开后台题目编辑页,点提交 → PUT 200 → 跳回列表,库里形状正确。

(顺带发现 tags 为空的题在编辑页点提交会被 `tags.min(1)` 挡下 400 —— 旧后端
`allow_empty=False` 也是这个行为,不在本次范围。)

冒烟改动已还原:problem 2 的 answers 复位成 [],测试用的「冒烟」标签删掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:04:41 -06:00
5c772319e9 refactor(契约): 公告两侧形状分开,判题状态码从契约派生
Some checks failed
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
Some checks failed
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
b35118f32e update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-08-25 21:21:20 -06:00
66d51dfd83 chore(账号): 删掉没人调的 refreshUserProblemDisplayIds
Some checks failed
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
ed9d9f65bf chore(部署): deploy.sh 结尾别再把 NPM 当待办事项
Some checks failed
Deploy / deploy (push) Has been cancelled
反代早就配好了,每次部署完还提示「还差 NPM 那一步」是噪音。改成只报站点
地址,把 Websockets Support / client_max_body_size 200M 降级成「别关」的
提醒 —— 只有改 WEB_PORT 时才需要回 NPM 动一下。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:36:08 -06:00
6356643cc9 ci(部署): 部署走 GitHub Actions,产物挪到 runner 上编
Some checks failed
Deploy / deploy (push) Has been cancelled
服务器性能差,镜像 builder 阶段(1238 个包的 bun install + bun compile +
vite build)首次约 5 分钟,光前端就 160s。改成在 runner 上编好 dist/oj2-api
和 apps/web/dist,rsync 过去,服务器那边只剩几条 COPY。

服务器自己编的能力没有砍掉。Dockerfile 顶部加了全局 ARG ARTIFACTS:

  build(默认)    builder 阶段自己编,只要有 docker 就能手动部署
  prebuilt         用构建上下文里现成的产物,builder 不进构建图

实现是两个 scratch 阶段归一成 /artifacts/ 布局,再用变量阶段名
FROM artifacts-${ARTIFACTS} 选一个,运行时阶段不关心产物是谁编的。

deploy.sh 加 --prebuilt:产物缺失在自检就 die;顺带检测 buildx —— 没装的话
compose 会退回 classic builder,那个不看依赖图、所有阶段挨个跑,产物白编
(结果正确但不省时间),黄字提醒不阻断。

.dockerignore 末尾放行两个产物路径,必须在 dist/ 那些规则之后,
dockerignore 是最后一条匹配说了算。

顺手删掉 apps/web/.github/ —— 从 ojnext 抄来的残留,路径不对(GitHub 只认
仓库根的 .github/workflows/)从来没跑过,内容也是 ojnext 的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:30:22 -06:00
d3b05b8629 refactor(契约): 补齐 80 个类型导出,前端不再手抄形状
契约原来有 184 个 schema 但只导出了 104 个类型,缺的那 80 个前端只能照着
手抄一遍 —— 这是「表格列静默空白」那一类 bug 的根因(d3348f9、2edb8cf,
以及上一个 commit 修的 Top100 两列)。现在 186 个 schema 对 186 个类型,
一一对应,下次要用直接 import。

补导出是机械的(fooSchema → Foo),零命名冲突。真正有价值的是换的过程中
契约逼出来的 5 处分歧 —— 手抄那份在说谎,而 vue-tsc 拦不住,因为类型说它是对的:

- Tutorial.createdBy 手抄成了可选的 `User`(即 AdminUser,带 email、
  rawPassword),后端下发的是必有的 SampleUser。读 createdBy.email 会拿到
  undefined。列表页因此被迫写 `row.createdBy?.username` 和 `row.createdAt!`,
  换成契约类型后两处断言都不需要了。
- TutorialListItem 手抄成 `Omit<Tutorial, "content">`,但后端列表接口连 code
  一起省了 —— 类型声称 code 在。
- Testcase 手抄成 `{input_name, output_name, score}`:响应实际有 5 个字段,
  且**没有 score**。score 是上传完成后前端按测试点数量平分补上去的,手抄那份
  把本地字段说成了响应字段。现在写成 `TestCaseEntry & { score: string }`。
- Tag 手抄成 `{id, name}`,契约是 `{id, name, problemCount}` —— shared/api.ts
  只好用 `Tag & { problemCount: number }` 把丢掉的补回来。
- CreateMessage 是旧后端按名字投递的形状(sender/recipient/submission),
  契约要的是 recipientId/submissionId。全仓零引用,删掉。

同时删掉另外两个零引用的手写类型:LANGUAGE_SHOW_LABEL、UserAdminType;
本地重复的 SampleUser 换成契约的;oj/problem/list.vue 里本地第三份 Tag 改成
`ContractTag & { checked: boolean }`。

需要收窄的一律**从契约派生再收窄**,字段名跟着契约走,只有真正本地的那一两个
键是自己的:

    export type Exercise = Omit<AdminExercise, "data"> & { data: 七种题型的联合 }
    export type SubmitCodePayload =
      Omit<CreateSubmissionRequest, "language"> & { language: LANGUAGE }

保留不动的窄化:StatisticInfo / SubmissionInfo(判题 JSONB 原文,snake_case)、
SQLDisplay*、ProblemFiltered(视图模型)、Exercise*Data(契约里 data 就是
Record<string, unknown>,七种题型结构不同,后端本来也不校验)。
types.ts 的手写 interface 从 31 个降到 22 个。

顺带修的代码:admin/tutorial/detail.vue 新建教程的表单对象缺三个后端产出的
字段,加了 TutorialEdit(对齐 BlankProblem / BlankContest 的写法);三处测试点
上传原来是拿响应对象原地塞 score,改成 map 出新对象,分数算法一字未改。

验证:apps/api tsc(7.0.2) 0 error、check:routes 168 条无遮蔽、
apps/web vue-tsc 0 error、vite build 通过。改动绝大部分在类型层,运行时只有
测试点上传那三处(等价替换)—— **那条路径要传 zip 才能实跑,没有实打**,
只做了代码等价性核对。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:07:01 -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
a03e22e60f chore(前端): 删掉三个从 ojnext 搬来就没人用的文件
阶段 1 是「原样搬入、不改业务代码」,所以 ojnext 里已经死掉的东西也一起
搬了过来。这三个文件全仓零引用:

- utils/permissions.ts(usePermissions / checkRoutePermission)
- oj/ai/components/StreakStats.vue
- shared/components/IconButton.vue

确认方式:在 apps/web 全部 .ts / .vue / .json / .html / .js 里搜这五个名字
(排除文件自身)零命中;两个组件也不在自动生成的 components.d.ts 里,
permissions.ts 的两个导出同样不在 auto-imports.d.ts 里 —— 也就是说
它们连自动导入的注册都没拿到,不存在「模板里隐式用到」的可能。

permissions.ts 在 ojnext 里也是死的,不是搬运过程产生的。

vue-tsc 0 error,vite build 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:37:58 -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
1198895b54 refactor(前端): vue-tsc 清零,type-check 可以当 CI 门禁了
接上 vue-tsc 时有 143 条既有错误,上一批迁移带走 89 条,这里把剩下的清完。

大部分是噪音(未使用的回调参数、死变量、少数 unknown),但里面躺着两个真问题:

- 个人主页查不存在的用户会抛:getProfile 返回 null 时后面照样取
  .acmProblemsStatus,靠 `!` 压着。加了早返回。
- getProblemSetProgress 我上一批标错了类型:后台这个接口返回的是裸数组,
  不是分页信封(和 oj 侧的 /user-progress 不一样)。已改正并加注释区分。

其余处理:

- 未使用的回调参数按 TS 约定加 `_` 前缀(v-for 的项、供子类重写的空钩子、
  路由守卫的 from);确认无用的局部变量直接删(clickX/clickY 算了点击位置
  但飞出方向用的是随机角度,hasToday 下面已经用 lastDateOnly 判过,
  prefix 算了周/月但标签里没用上)。
- App.vue 的 highlight.js 注册:Promise.all([...]).then(m => m.map(x => x.default))
  会把元组塌成 (HLJSApi | LanguageFn)[],hljs 上就找不到 registerLanguage。
  改成逐个取 .default。
- 给一批 api 补上契约类型(getMetrics / getHitokoto / getTutorials /
  formatCode / getProblemBeatRate / getClassUsernames 等),契约相应补了
  8 个 z.infer 导出。
- ContestRank.submissionInfo 和 AcmHelperItem.acInfo 在 api 边界窄化成
  SubmissionInfo —— 契约里是 Record<string, unknown>(JSONB 原文)。
- FlowchartEditor 的 TS2589:vue-flow 的 Node 嵌套太深,ref<Node[]>([]) 的
  UnwrapRef 推导撞上实例化层级上限,改成 ref([]) as Ref<Node[]>。
- api2.ts 的响应拦截器**故意**不返回 AxiosResponse(要把 { data } 信封剥掉),
  类型上确实说不通,没硬掰,写注释说明为什么用断言。
- 题目列表的「随机」按钮在模板里一直是注释状态,对应的 getRandom 和
  getRandomProblemID 一并删除(后端 /problems/random 保留不动)。

验证:vue-tsc 0 条,apps/api tsc、check:routes、web build 全通过;
修过 key 的列都打接口确认过字段真实存在。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:04:10 -06:00
3d501e8b69 fix(试运行): 流程图/SQL 会往 Judge0 发出 language_id: undefined
Judge0 的语言 id 表里只有 C/C++/Java/Golang/JavaScript/Python2/Python3,
而 code.language 的类型包含 Flowchart 和 SQL —— 前者不是可执行代码,
后者由本站自己的 SQL 沙箱判,都走不到 Judge0。之前直接索引拿到 undefined
就发出去了。

改成 Partial<Record<LANGUAGE, number>>,取不到就提前返回提示。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:03:51 -06:00
d3348f994d fix(前端): 一批表格列静默空白,题单进度点「移除」还会抛
没有 render 的列,naive-ui 直接按 key 取 row[key]。上一批把响应字段改成
camelCase 之后,这些列的 key 还留着 snake_case,取不到值就渲染成空白 ——
排行榜、题单进度、AI 报告、后台首页共 13 处。

这类问题类型检查够不着(列 key 是字符串),是扫「没有 render 的列 key 里
还带下划线」的组合才找出来的。

同一张表还有两处更严重的:

- 「用户」列 key 写的是 user.username,但后台 /admin/problem-sets/:id/progress
  下发的是扁平的 username,没有嵌套 user。
- 点「移除」emit 的是 row.user.id —— row.user 是 undefined,直接抛,
  学生根本移不掉。

test_case 那两处保留:读的是判题机 info.data 的原始键。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:03:51 -06:00