前四轮把契约当成运行时闸门铺开,复盘下来三块里只有一块是赚的:类型收拢成一份
(语言联合、Problem/Message/ContestRank 的重复派生)留着;另外两块退回来。
## 判题产物:读出侧不再校验
judgeCaseResultSchema 按采样键集收紧的结果,用根目录那份生产备份全量跑了一遍:
124192 条提交里 9163 条对不上,**RE 8480/8480、TLE 338/338+26、MLE 1/1 全中**,
另有 270 条 WA、47 条 AC。原因不是键集合,是空值和 SQL 链路:
- 沙箱在非正常退出的测试点上写 `output_md5: null`,契约写的是 z.string();
- SQL 判题(judge/sql/engine.ts 的 CaseResult)根本没有 `output` 键;
- SQL 通过的测试点 `error_message` 是 null,契约写的是 z.string().optional()。
更糟的是失败方式:`info` 是 `union([完整形状, z.object({})])`,对不上的一律落进
第二支被剥成 `{}` 且 parse 成功 —— 管理员详情页的测试点表格**静默消失**,无日志。
JSONB 的形状真相在写入侧(判题机),读出侧再校验一遍只会在两边分叉时丢数据。
所以 `info` 回到 z.unknown(),形状改用 JudgeInfo / JudgeCaseResult 两个 TS 类型
描述(按判题机实际写的形状,不是采样出来的),取值处由 submissionCaseResults()
做唯一需要的运行时判断:有没有 data 数组。statisticInfo 换成 looseObject ——
所有键可选、不剥未知键,对任何对象都不会失败,它的作用是给类型不是当闸门。
## 练一练:形状闸从读路径挪到写路径
exerciseSchema 的 superRefine 挂在读路径上,而这个 schema 后端也在 parse
(routes/content.ts),等于一行脏数据就能让整条学生练习列表 500。同时写入侧的
exerciseDataError **一次都没查过 question**,两边严紧度不一致,脏数据进得来出不去。
exerciseDataByType 保留,改由 exerciseDataError 在写入前查,错误信息按字段翻成
中文给老师看;读路径回到不校验。
## 运行时闸门收回三处
contract() 从 41 个端点收回到题目详情 / 提交详情 / 用户资料 —— 原本就写了
.parse() 的那三条。留着的理由是「别抛错」(原来 parse 抛 ZodError 会白屏、
后面的 as 又让校验白做),不是校验:前后端同仓、共享同一份 schema,字段漂移
tsc 已经抓了。闸门本身也瘦掉了没人读的 window.__OJ2_CONTRACT_DRIFT__ 那套簿记。
## 验证
- 生产备份全量:124192 条提交过 submissionDetailSchema / submissionListItemSchema
零失败,其中 112144 条能拿到测试点明细(另外 12048 条本来就是 data:null);
151 道练习读路径 151/151、写入闸 151/151(老师改旧题不会被新闸挡);
- 反向验证写入闸:缺题干的排序题被拒并给出「题干的格式不对」;
- 本地实跑:种一条生产形状的 RE 提交(output_md5: null),管理员详情接口原样
返回 info.data(改之前是 {});库里塞一行没有 options 的 mcq,学生端练习列表
照常返回两条而不是 500;
- vue-tsc / tsc -p apps/api 均 exit 0,vite build 通过,check:routes 无遮蔽。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
6.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
OJ2 的前端(OJ2/apps/web),代码从上一代 ojnext/ 原样搬来、只替换了 API 层
(ojnext 与 ../OnlineJudge 都已下线且完全冻结,一行都不改)。Vue 3 + TypeScript,
Vite(Rolldown 内核)、Naive UI、Pinia、Vue Router。
要兼容机房的老 Chrome(< 94):vite.config.ts 的 legacy 配置与
mermaid-legacy 等 fallback 依赖不能动,理由写在该文件的注释里。
Commands
npm start # Start dev server on port 5173
npm run build # Production build
npm run build:staging # Staging build
npm run build:test # Test build
npm fmt # Format with Prettier
No test suite is configured. Linting is via Prettier only.
Architecture
Directory Structure
src/
├── shared/ # Cross-cutting concerns: layout, stores, composables, API
├── oj/ # User-facing features (problems, submissions, contests, etc.)
├── admin/ # Admin panel features
├── utils/ # Constants, types, HTTP client, helpers
├── routes.ts # Route definitions (two top-level: ojs, admins)
├── main.ts # App entry point
└── App.vue # Root component with Naive UI theme setup
Module Pattern
Each feature module (under oj/ or admin/) typically has:
views/— page-level Vue componentscomponents/— feature-specific componentsapi.ts— API calls specific to the feature
Shared logic lives in shared/:
store/— Pinia stores:user(auth/roles),config(site-wide settings),authModal(login/signup form state),screenMode(problem split-screen layout),loginSummary(AI activity summary),collab(help-request queue + collab room)composables/—pagination(URL-synced),websocket(reconnect + heartbeat),collabDoc(Yjs binding for the collab channel),configUpdate(WS-pushed config sync),useMermaid(lazy Mermaid render),breakpoints,maxkblayout/—default.vueandadmin.vuelayout wrappersapi.ts— shared API calls (auth, profile, tags, captcha)
Auto-Imports
Configured via unplugin-auto-import and unplugin-vue-components. You do not need to manually import:
- Vue APIs (
ref,computed,watch, etc.) - Vue Router (
useRouter,useRoute) - Pinia (
defineStore,storeToRefs) - VueUse composables
- Naive UI composables (
useDialog,useMessage,useNotification,useLoadingBar) - Naive UI components (all
N*components) - Naive UI types (
DataTableColumn,FormRules,FormItemRule,SelectOption,UploadCustomRequestOptions,UploadFileInfo,MenuOption,DropdownOption)
Generated type declaration files: src/auto-imports.d.ts, src/components.d.ts.
Path Aliases
utils → ./src/utils
oj → ./src/oj
admin → ./src/admin
shared → ./src/shared
HTTP Client
utils/api.ts — Axios instance with interceptors (baseURL: "/api",
withCredentials). It unwraps both the axios envelope and the backend's
{ data } envelope, so callers get the payload directly. All API calls proxy
through the dev server (see vite.config.ts).
Contract guard (utils/contract.ts)
@oj2/contract 的 zod schema 是前后端唯一的形状来源,utils/types.ts 只做
z.infer 派生与少量前端专有的收窄(都写了理由)。
运行时闸门只挂三处:题目详情、提交详情、shared/api.ts 的用户资料 ——
原本就写了 .parse() 的那三条。留着它们的理由是别抛错,不是校验:
// 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
// 而 parse 抛错会让整个题目页白屏
return contract("GET /problems/:id", problemDetailSchema, value)
失败时记一条控制台日志再放行原始数据,页面照常渲染。
不要把它铺到更多端点上。 试过一次(41 个),收益是 41 次 safeParse 加一条
没人读的 console.error:前后端同仓、共享同一份 schema,「后端改字段前端不知道」
tsc 已经抓了。
什么该收紧,什么不该
JSONB 原文(submission.info / statistic_info / exercise.data)不在读出侧
校验。 它们的形状真相在写入侧 —— 判题机、services/exercise.ts。在读出侧再收
一遍的结果实测过两次:
info按采样键集收紧后,124192 条提交里 9163 条(RE、TLE、MLE 全中)对不上, 被 union 的空对象分支静默剥成{},管理员的测试点表格无声消失;exercise.data按题型收紧后,后端读路径(routes/content.ts硬 parse)变成 一道闸,一行脏数据能让整条练习列表 500。
所以:同一个 schema 后端也在 parse(submissionDetailSchema /
exerciseSchema / contestRankItemSchema 都是),收紧任何字段之前,拿根目录
那份生产备份把全量数据跑一遍,尤其要看空值而不只是键集合。
Key Utilities
utils/constants.ts— Judge status codes, language IDs, difficulty levels, contest typesutils/types.ts— 契约类型的派生与前端专有收窄(不是手写的一份平行类型)utils/contract.ts— 运行时契约闸门,见上utils/judge.ts— Judge-related utilitiesutils/renders.ts— Table column render helpers for Naive UI DataTable
Environment Variables
Variables prefixed with PUBLIC_ are injected at build time. Env files: .env, .env.staging, .env.test.
| Variable | Purpose |
|---|---|
PUBLIC_OJ_URL |
Backend REST API base URL |
PUBLIC_WS_URL |
WebSocket server URL |
PUBLIC_ENV |
Environment name (dev/staging/production) |
PUBLIC_CODE_URL |
Code execution service |
PUBLIC_JUDGE0_URL |
Judge0 API |
PUBLIC_MAXKB_URL |
Knowledge base service |
PUBLIC_ICONIFY_URL |
Iconify icon CDN |
Routing
Routes are defined in src/routes.ts with two root routes: ojs (user-facing) and admins (admin panel). Route meta fields used:
requiresAuth— redirect to login if not authenticatedrequiresSuperAdmin— super admin onlyrequiresProblemPermission— problem management access
Real-time Features
- WebSocket via composable in
shared/composables/for submission status updates - Yjs over the
/ws/collabchannel for classroom help requests and collaborative code editing (students raise a hand, teachers join their editor). The server is a dumb relay — it authenticates, assigns rooms, and forwards frames without parsing them. Seedocs/specs/2026-08-28-collab-help-request-design.md.
Related Repository
后端就在同一个仓库的 ../api(Bun + Hono + Drizzle,编译成单二进制),
契约在 ../../packages/contract。不要再去看 OnlineJudge/ —— 那是已下线的
Django 后端,只作参照、完全冻结。详见 ../CLAUDE.md。