Commit Graph

11 Commits

Author SHA1 Message Date
2c4d56b29a refactor(后端): 清掉 Django 遗留的死列与手工级联,角色字符串收成一份
三条迁移,一次部署(0008/0009 含 DROP COLUMN,需要 OJ2_ALLOW_DESTRUCTIVE=1):

- 0008 删 IP 相关:比赛 IP 白名单(前端本来就没有输入框,detail.vue 无条件置空)、
  submission.ip(前端从未显示过)、以及一次都没被调用过的 IP 限流桶。
  judge_server.ip 是运维数据,保留。
- 0009 删九个只有 Django 时代写过、OJ2 一次都没读过的列:user 的 auth_token /
  open_api / open_api_appkey / session_keys,user_profile 的 blog / github /
  school / major / language。open_api 后台连开关都没有,那段「已经开着就不重置
  appkey」的逻辑从上线起没进过 if。判据是「全仓零读取」而不是「看着没用」——
  raw_password 同样刺眼却是在用的,别一起清掉。
- 0010 给 17 条外键补上删除动作,不再是 Django 留下的一律 NO ACTION。父行消失后
  必然无意义、且不构成学生留痕的走 CASCADE(中间表、题单/教程/成就的组成部分、
  user_profile 与 user_stat);需要人看见的继续拦着——submission.problem_id、
  以及 user 的绝大多数外键,删用户撞外键会被 handler 翻译成「请改为禁用账号」,
  这是有意的:全 CASCADE 会静默抹掉成就与进度,而 submission.user_id 压根没有
  外键,结果是一半删一半留。六处手工级联随之删掉。

角色字符串收进 packages/contract/src/roles.ts:原先 ADMIN_ROLES / TEACHER_ROLES
在两个文件各抄一份、学生口径在四个文件各写一遍、前端 USER_TYPE 是第三份副本。
AuthUser.adminType 与 drizzle 的列都收窄成联合类型,二十多处 `=== "Super Admin"`
从此受编译器管着($type 是纯 TS 层的,generate 确认不产生任何 SQL 变更)。

顺带删掉 db/relations.ts —— drizzle-kit pull 的产物,全仓零引用。

一处行为变化:后台用户列表传非法的 ?type= 回 400,不再静默返回空列表;界面上的
下拉只有合法值,打不到这条。

验证:tsc / vue-tsc / vite build / check:routes 全过;三条迁移在 dev 库执行,
并逐条建 fixture 走 HTTP 接口验过删除连坐与拦截(题单五张子表连坐、user_badge
二级连坐、删有提交的题目仍 409、删有表情的用户仍 409)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
2026-09-02 22:57:45 -06:00
e624c62502 feat(提交): 列表标出来自题单的提交
Some checks failed
Deploy / deploy (push) Has been cancelled
submission 新增 problemset_id,学生从 /problemset/:id/problem/:pid 入口提交时
前端带上、后端落库,提交列表在题目后面挂一个「题单 xxx」的标签,点了进题单。

只是来源标记:题单进度和奖章仍由判完之后的 recordSolvedProblem 按「已加入且含
这道题的所有题单」记账,和从哪个入口进来无关,所以这个字段带错顶多标签不准,
不影响成绩。校验只确认这道题在那个题单里 —— 不查 visible / status、也不查有没有
加入,藏起来的题单里还困着已加入的学生;对不上就当没带,提交照收。

外键 ON DELETE SET NULL:删题单不该带走提交,清掉标记就行。索引建成部分索引
(WHERE problemset_id IS NOT NULL),绝大多数提交不来自题单,全列索引是给 12 万行
白建一遍;谓词能被 problemset_id = $1 蕴含,删题单时的外键检查也用得上它。

列表按页单独查一次题单标题,没有把 problemset join 进那条调过的深翻页查询。
比赛提交恒为 null:题单只收非比赛题。

历史回填从 problemset_submission 取「当年首次 AC 那条」,其余老提交无从判断入口,
一律留空。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7AsqceaUfC81k7UCDcSk2
2026-09-02 22:06:29 -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
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
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
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
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
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