Commit Graph

24 Commits

Author SHA1 Message Date
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
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
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
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
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
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
553c084579 feat(阶段4): 比赛管理 + 克隆 + ACM 赛后核查
GET/POST          admin/contests
  GET/PUT           admin/contests/:id
  POST              admin/contests/:id/clone
  GET/PUT           admin/contests/:id/acm-helper

克隆是深拷贝:新比赛从 10 分钟后开始、时长与原比赛相同、一律不可见(时间是拍脑袋定的,
直接开放会让学生看到一场没准备好的赛),比赛题目连同标签一起复制,
提交数/通过数/statistic_info 归零 —— 克隆的是题面不是历史战绩。
实测:源题 99/55 两个标签,克隆出来 0/0 两个标签俱在。

两处比旧后端更严:

- **ACM 核查的 rank 必须属于本场比赛**。旧后端只按 rank_id 取,不校验归属,
  带上任意 rank_id 就能改别的比赛的核查标记。
- 比赛详情/编辑越权时报「不存在」而不是「无权限」,不泄露「有这么个东西但你看不到」。

其余对齐:非超管只看得到自己建的比赛;空串密码归一成 null(否则 contestType 会把
「密码是空字符串」当成密码保护赛);CIDR 按 ip_network(strict=False) 的口径校验,
允许主机位非零。

核查页的 realName 是**有意下发**的:这个页面就是老师对着名单确认谁抄了,
接口已由 requireTeacher + 归属校验双重把关。

实测:学生 403;结束早于开始 400、非法 CIDR 400 且文案带具体网段;创建空串密码
→ Public、改密码后 → Password Protected 且后台能读到密码原文;克隆时长一致且不可见;
不可见比赛的核查页 404;rank 不属于本场 404。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:02:28 -06:00
bc54fbff98 feat(阶段4): 站点配置 / 判题机 / 孤儿用例 / 概览 / 图片上传
GET/POST          admin/website
  GET               admin/judge-servers
  PUT               admin/judge-servers/:id
  DELETE            admin/judge-servers/:hostname
  GET/DELETE        admin/orphan-test-cases
  GET               admin/dashboard
  GET               admin/random-usernames
  POST              admin/upload-image

顺带补上配置广播:旧后端改配置会经 WebSocket 推给所有开着页面的人,改完立刻生效。
新后端只服务 /ws/submissions,前端的 ConfigWebSocket 还连着旧 Django Channels。
现在加了 /ws/config 通道(同一个 Bun.serve 只能挂一个 handler,用 socket data 上的
kind 区分),前端 ConfigWebSocket 改走 /ws2/config。

几处判断:

- **判活不能比字符串**。库里 timestamptz 形如 `2026-08-07 13:42:50+00`(空格分隔),
  toISOString() 是 `...T13:42:44.000Z`(T 分隔),字典序空格 < 'T',同一天的心跳永远
  小于阈值 —— 所有判题机都会显示离线。实测确实复现(dashboard 说 1 台在线、列表却
  两台全 abnormal),已改为 Date.parse 后比较。
- 删指定的孤儿用例时**先确认它确实是孤儿**。旧后端不校验,一个手抖的 id 就能删掉在用
  题目的测试数据,而测试数据没有别处备份。
- 图片上传的文件名完全由服务端生成,不带用户提供的任何一段;另加 10MB 上限 ——
  旧后端靠 nginx 兜,但机房那台机器盘写满之后判题也会一起挂。
- 停用判题机后不再 process_pending_task():任务在 BullMQ 里排着,worker 恢复自己接着
  消费,不存在旧自研分发器那种「没有新提交就一直 waiting」的问题。
- dashboard 不再下发 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过。

实测:学生 403;配置读写回读 + oj 侧 /site 同步生效 + 还原;判题机列表带 token、
状态判定正确(一台 normal 一台 abnormal,与 dashboard 计数一致);删不存在 404;
删非孤儿用例 404;随机点名缺班级号 400。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:43:38 -06:00
7e1a782747 feat(阶段4): 用户管理 + 成就管理;补上 rescan 与 contest_joined
GET/POST/DELETE   admin/users            (DELETE 走 body 传 ids)
  GET/PUT           admin/users/:id
  POST              admin/users/:id/reset-password
  GET               admin/achievement-metrics
  GET/POST          admin/achievements
  GET/PUT/DELETE    admin/achievements/:id

顺带补了新后端缺失的两块(不补的话成就后台建出来的东西是坏的):

1. **rescanAchievement**:旧后端 `rescan_achievement` 的对应实现。判定平时只在判题
   结算时发生,后台新建成就或调低阈值不会自动补发,必须显式扫一遍。补发标记
   backfilled=true —— 前端据此只显示「已获得」不显示日期,否则一次补发会给几百人
   盖同一个时间戳,把「最近获得」板块冲垮。
   触发判据包含 metric 与 visible 的变化,不只看 operator/threshold:换维度、
   以及从下架改上架(草稿期已达标的人)这两种都会漏。
2. **contest_joined 指标整个漏掉了**。旧 METRIC_REGISTRY 有 18 个指标,新后端只算
   17 个,配在这个指标上的成就永远解锁不了。已补上计算,并把注册表抽成
   services/achievement-metrics.ts 作为单一事实源 —— 后台下拉框和参数校验都读它,
   避免「下拉框里选得到但没人算」这种组合。

用户管理的几处要点:

- className 解析位数不对**直接报错不猜**。猜错会把 class_name 存歪,而剥前缀显示
  姓名、班级下拉、统计页都依赖它准确。
- problem_permission 按 admin_type 归一(超管恒 All、普通用户恒 None),否则把超管
  降级成普通用户后他还留着 All。
- 改用户名要同步 submission.username 这个冗余列,否则历史提交查不到。
- openApi 已开着就不重置 appkey,否则每次保存用户都把对方的 key 换掉。
- **删除用户不复刻 Django 的应用层级联硬删**。用户是被引用最广的一张表,改成让
  数据库外键拦下来:撞外键说明还有历史数据,应当禁用而不是删除,返回 409 并说明。

实测:学生 403;导入 2 人 / 重复 409 / 班级号位数报错文案正确;改名+降权后
permission 归一为 None、重名 409;重置密码 6 位无 0;删自己 400;
成就指标 18 项、新建后补发 unlockCount=2、野指标与野稀有度均 400。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:27:36 -06:00
af9cb796bd feat(阶段4): 教程 / 练习 / AI 学情报告的后台接口
GET/POST                admin/tutorials
  GET/PUT/DELETE          admin/tutorials/:id
  PUT                     admin/tutorials/:id/visibility
  GET                     admin/tutorials/:id/exercises
  POST                    admin/exercises
  PUT/DELETE              admin/exercises/:id
  GET                     admin/ai/reports          (?pinnedOnly=true 不分页)
  GET                     admin/ai/reports/:id
  POST                    admin/ai/reports/:id/pin

几处判断:

- 练习改成挂在教程下的嵌套路径,旧后端是 ?tutorial_id= 查询参数。本来就是一对多的
  从属关系,嵌套更贴事实,也省掉「忘了传 tutorial_id」这类错误。
- 删教程必须先删练习。Django 的 on_delete=CASCADE 是应用层实现的,库里外键实际是
  NO ACTION(核对过 pg_constraint.confdeltype='a'),直接删会撞外键变 500。
  已实测:带 2 个练习的教程能正常删掉且练习一并清除。**后台每个 DELETE 都要照此
  核一遍子表**,这是本阶段的通用陷阱。
- 改可见性不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉。
- AI 报告的 data / systemPrompt / userPrompt 一律不下发,里面是喂给模型的原始
  学情数据与提示词。列表只给 120 字摘要,与旧 AIAnalysisListSerializer 一致。

实测:匿名 401 / 学生 403 / 超管 200;教程练习增删改查、非法 type 400、
挂到不存在的教程 404、置顶互斥(同一学生至多一份)、再钉一次取消,全部符合预期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:10:34 -06:00
9a6c3ba225 feat(阶段4): 后台地基 + 公告管理
地基:
- auth/middleware.ts 加四个角色守卫 requireAdmin / requireTeacher /
  requireSuperAdmin / requireProblemPermission,对应旧 account/decorators.py 的
  四个装饰器。未登录 401 login-required、角色不够 403 permission-denied,
  与前端 api2 拦截器按 code 分流的两支对上。
- routes/admin/ 目录 + 总入口挂在 /api/admin。角色守卫由各子路由自己挂,
  不在总入口兜一层 —— 否则「这个接口要什么角色」从注册行看不出来,
  正是阶段 3 Minor M2 踩过的坑。
- packages/contract/src/admin.ts 独立放后台契约。同一张表两侧下发的字段集不同
  (后台要 visible,oj 侧连键都不该出现),混在一起迟早有人在 oj 侧复用后台那个。
- utils/legacy.ts:把 toLegacy / legacyResponse 从 oj/api.ts 抽出来共用。
  admin 侧组件同样读 snake_case,走同一层适配,组件不动。

公告管理(旧 /api/admin/announcement 一个路径四个动词)拆成:
  GET/POST   admin/announcements
  GET/PUT/DELETE admin/announcements/:id

一处有意不对齐旧后端:删除不存在的公告,旧后端 filter().delete() 静默成功,
这里返回 404 —— 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。

实测:匿名 401 / 学生 403 / 超管 200;增删改查、列表不含 content、
空标题 400、不存在 404 全部符合预期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:06:22 -06:00
cebaa87b13 fix(Minor M-1): 站内信内嵌提交改用独立 schema,并修复题号链接
两件事:

1. 内嵌的 submission 之前复用 submissionDetailSchema 并把 info / ip 写死成空值,
   于是键仍留在响应里。旧 SubmissionSafeModelSerializer 是 exclude,这三个键
   根本不出现。改成独立的 embeddedSubmissionSchema —— 形状对上了,将来有人
   把空值改成真值也不会变成泄露,因为这里压根没有这三个字段。

2. 顺带修掉一个评审没覆盖的真回归:内嵌 submission 给的是 problemId(数字主键),
   而旧 serializer 的 problem 是 SlugRelatedField(slug_field="_id"),即展示用题号。
   oj/user/message.vue:20 拿它拼 /problem/<题号>,迁移后拼出的是 /problem/undefined,
   题号那一栏也是空的。改为下发 problem,实测返回 "1004"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:01:13 -06:00
61f74054f9 fix(Minor M3): blog / github 加回 URL 校验
旧 account/serializers.py:125,127 是 serializers.URLField,迁移时降级成了
z.string().max(256)。这两个字段会被前端渲染成可点击链接,放任自由字符串等于
允许写入 javascript: 一类伪协议。

只放行 http(s)://,空串表示清空。实测 javascript:alert(1) 与 not a url 均 400,
https://example.com/x 与空串 200。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:01:13 -06:00
44f6f27841 chore(阶段3): 删除阶段 1 的临时验证物
GET /api/dev/problems、dev-problems.vue、对应路由项,以及只服务于它的
problemSummarySchema。阶段 1 用来验证「能从真实库读出一道题」,已完成使命。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 06:48:20 -06:00
836d97847e feat(阶段3): 补齐用户侧依赖的三个 admin 端点
重判、提交统计、流程图统计这三条挂在旧后端的 admin 路由下,权限也确实是
teacher/super admin,但入口在用户侧页面里(提交列表页的重判按钮、两个统计面板)。
不做完,阶段 3 的出口标准「用户侧全部功能跑在新后端上」就不成立 —— 按 URL
前缀切阶段会漏掉它们。

- POST submissions/:id/rejudge     ← GET admin/submission/rejudge
- GET  submissions/statistics      ← GET admin/submission/statistics
- GET  flowcharts/statistics       ← GET admin/flowchart/statistics

几处对齐旧后端的细节:
- AST_CHECK_FAILED(10) 与 ACCEPTED(0) 同算通过
- 完成度先用原始花名册人数算、再修正 person_count,顺序照搬,兜住「学生已删号
  但提交记录还在」
- 有提交但零通过的学生,两个名单里都不出现(旧后端同样口径)
- 词云用 @node-rs/jieba,STOPWORDS 与 38 个自定义词逐词照搬;jieba@2 没有
  insertWord,改用 loadDict 加载用户词典
- avgScore 分母是有分数的条数,对齐 Django Avg() 跳过 NULL

rejudge 的 jobId 带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId
做 jobId 的话 BullMQ 会认为任务已存在,重判会静默变成空操作。

correctRate 改成数值不带 %,展示格式化交给前端。stripClassPrefix 用
startsWith+slice 而不是 replace,前缀对不上时不会从中间截出乱码;site.ts
原有的 replace 写法一并改掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 06:48:05 -06:00
8c00cdc947 feat(阶段3): oj 侧端点铺开(基线提交,未经评审)
由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线,
后续修复单独成 commit,便于区分与回退。

覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/
content/contest/flowchart/problemset/site)与对应 Zod 契约。

已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据;
judge 与 flowchart worker 均 ready。
未核验:权限边界与数据泄露,评审进行中。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:25:36 -06:00
ec274419c3 Build Phase 2 judge vertical slice 2026-08-06 22:42:39 -06:00
9bfe629cd7 feat(阶段1): monorepo 骨架与 @oj2/contract 契约包 2026-08-06 20:39:39 -06:00