Commit Graph
12 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 ed56a209ea chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00
xuyueandClaude Opus 5 ab47e71d6f refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies

出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:

- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
  的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
  z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
  submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
  problem;列表为空时才碰巧不炸,所以一直没人报。

两个都是读出侧校验自己造出来的故障,不是它拦住的故障。

## 校验责任挪回写入侧

- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
  .language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
  .sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
  tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
  problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
  不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
  problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
  difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
  会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
  在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
  报文是真边界,失败返回 null 而不是 500。

顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。

## 文档

CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。

## 验证

- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
  数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
  前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
  建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
  (error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
  后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
  problemDisplayId 收到 undefined,改动后同样两个响应均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 18:14:05 -06:00
xuyueandClaude Opus 5 5222e012e1 fix(比赛): 修四处 —— 倒计时两倍速、排名不自动刷新、题目状态恒空、比赛隐藏后审核页取不到代码
查比赛功能时实跑出来的四个问题,都在这一条里修掉:

**倒计时两倍速**(store/contest.ts)。init() 里 setInterval 之前不清旧表,而
detail.vue 在「未开始 → 进行中」那一刻会再 init 一次(为了捞开赛后才拿得到的题),
于是两个 interval 一起给 now 加 1000。学生赛前挂着页面就会中招:一场 60 分钟的
比赛,真过了 30 分钟页面就显示「已结束」、倒计时归零,而服务端还在正常收提交。
ojnext 里就有,是原样搬过来的。

**排名页「开启自动刷新」开着但不刷新**(contest/pages/rank.vue)。useIntervalFn
传的是 immediate: false,而 watch(autoRefresh) 只在开关变化时才 resume ——
开关初值就是 true、进页面不产生变化,表从没启动过,得手动关一次再开。改成
watchEffect,由「开关 + 比赛进行中」共同驱动,顺带不再在赛后空转轮询。同样来自
ojnext。

**比赛题的 myStatus 恒为 null**(routes/contest.ts)。判题其实把状态记进了
user_profile 的 acm_problems_status.contest_problems,只是这两条路由硬编码下发
空值,于是题目页的「状态」列永远是「未做」,赛后也不恢复。旧后端在赛后/管理员
视角是给的,这是回归。不按赛中赛后分档:这是学生自己的判题结果,不泄露别人任何
信息(旧后端赛中不给,只是因为它整条路换了个 serializer)。

**比赛一隐藏,审核页的「查看代码」必 404**(services/contest.ts)。acm-helper
故意不卡 visible(赛后核查恰恰发生在比赛收起来之后),它调的比赛提交列表却卡着,
两边对不上。findVisibleContest 换成 findAccessibleContest:公开的谁都取得到,
隐藏的只有比赛管理员取得到,学生看隐藏比赛照旧 404。

实跑验证(dev 全栈,判题走临时 worker 绕开本机 token 不一致):

- 浏览器跨过开赛时刻挂着不动 —— 墙钟 20.0 秒,倒计时正好减 20 秒(原来会减 40)。
- 排名页停在「无数据」,另一账号提交一发 AC,5 秒内表格自己长出
  `1 student2 1/3 0:00:42`,没刷新页面。
- 学生 AC 后列表和详情都回 myStatus: 0,没做过的另一个学生仍是 null,匿名照旧 401。
- 比赛隐藏 + 已结束:出题人 detail / problems / rank / submissions / acm-helper
  全 200,学生这 5 条全 404,提交也 404,隐藏比赛不进公开列表。
- 排名记账口径未受影响:10 次提交(8 编译失败 + 2 AC)落库 submission_number=9、
  accepted_number=1、total_time=231=ac_time、is_first_ac=true。

tsc / vue-tsc / check:routes(175 条无遮蔽)均干净,测试数据已清库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:19:29 -06:00
xuyueandClaude Opus 5 25b86ec17e refactor(提交): 删掉从来没用起来的「提交互相可见」
Deploy / deploy (push) Has been cancelled
problem.share_submission(题目级)和 submission.shared(单条)两个开关,连同
判定分支一起删掉。

生产备份(2026-08-07)里的实际用量:956 道题只有 2 道开过 share_submission,
还都是 contest_id=1 的比赛题 —— 比赛未结束时那条分支根本走不到,等于一天都没
生效过。123140 条提交里 shared=true 的有 40 条,39 条在 2022 年、1 条在
2023-03-30,之后近三年零使用;出题页从来没给过题目级开关,单条分享的入口在更早
那版前端上,ojnext 和 OJ2 都没搬过来,OJ2 里 PUT /submissions/:id 一个调用方
都没有。

canViewSubmission 剩下三条:本人 / 管理员(比赛中的 Student Admin 除外)/ 本题
作者,其余一律看不到。结尾的 `shareSubmission || shared` 没了;它上面那条「比赛
未结束一律不给」也一并去掉 —— 走到那里的必然不是这三种人,现在无论比赛与否都是
false,留着是重复的。allowShared 参数随之消失,它唯一的用途就是分享开关的归属
校验。

一并删掉:PUT /submissions/:id、响应里的 shared / canUnshare(列表、详情、站内
信内嵌三处)、题目详情与后台题目里的 shareSubmission、shareSubmissionRequestSchema,
以及新建提交时那句 shared: false(列上本来就有 default false)。

两个数据库列保留不删,只在 schema.ts 上注明已停用:删列是破坏性迁移,而留着不读
不写零成本,还留着那 40 条的历史痕迹。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
2026-09-07 07:18:41 -06:00
xuyueandClaude Opus 5 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
xuyueandClaude Opus 5 cf1eea0810 fix(比赛): 学生管理员开卷、导出只剩十行、排行榜翻页错位等四处
Deploy / deploy (push) Has been cancelled
## 学生管理员在比赛进行中能看所有人的代码

canViewSubmission 里 isAdminRole(user) 是无条件放行的捷径,而 ADMIN_ROLES 含
Student Admin —— 同时 contest.ts 的排行榜又把这个角色算作参赛者。既在榜上、
又能读别人的提交,就是开卷。比赛未结束(未开始 + 进行中)时不再吃这条捷径。

旧后端这里写的是 `not user.is_regular_user()`,学生管理员同样放行,所以这条是
相对旧栈**收紧**的一处,不是修回归。只掐角色捷径,不掐 problem.createdById ——
那是这道题的作者本人,他早就知道答案,挡他没有意义。老师和超管不受影响,他们不参赛。

实跑(造了一场进行中、一场已结束的比赛,各一条别人的提交):学生管理员在进行中的
比赛里列表 showLink=false、详情 404,比赛结束后恢复 200;超管两种状态都是 200;
学生管理员读**自己**比赛中的提交仍然 200,没有把他自己的记录一起挡掉。

同族的 flowchart.ts canView 有同样的角色捷径,但流程图提交入库时从不写 contest_id
(永远是 null),那条路上不存在「比赛中的别人的提交」,不用跟着改。

## 获奖名单导出,参赛超过 250 人时只导出十个人

rank.vue 用 `limit: total.value || 10000` 想一次拉全量,但 queryInteger 对超出
上限的值是**静默回落到默认的 10**,不是截断到 250。于是 300 人的比赛导出 10 行,
而一二三等奖的分档仍按真实总人数算 —— 老师拿到一份十个人、等级全错的名单,还不报错。
改成按 250 一页循环拉,两个出口都留:拿不满一页说明到底了,比对 total 是防着最后
一页正好整除。班级赛(≤250)碰不到这个坑,全校赛会。

实跑:造 20 条排名,limit=300 后端确实只返回 10 行,limit=250 返回 20,
新逻辑拉回 20。

## 排行榜翻页没有全序

只按 acceptedNumber desc, totalTime asc 排,同分同罚时行序不稳定,而这条列表是
limit/offset 翻页的 —— 同一个人可能在第 2 页出现两次,另一个人从此消失。末尾补
asc(id) 兜全序,id 不参与名次,只保证同分的人每次按同一顺序排。

实跑:20 个同 AC 数同罚时的选手,每页 5 条翻 4 页,共 20 行去重后仍是 20 人,
连翻两轮顺序完全一致。

## acm-helper 两个接口对 visible 的口径不一致

GET 要求 visible = true,PUT 不要求。比赛结束后收起来,核查页就打不开了,
而标记接口还能用。赛后核查恰恰常发生在比赛已经收起来之后,按 PUT 的口径放开。

## 删掉 contest_announcement

旧 Django 栈有「比赛公告」,OJ2 从头到尾没搬:没有路由、没有契约、没有前端页面,
表建在那里纯粹是 introspect 0000 时一起拉进来的。确认不需要,连表带 schema 定义
一起删。

删除前核实:没有任何表外键指向它,序列由本表 owned 会随 DROP TABLE 一并消失,
生产快照(db_backup_2026_08_07)里只有 1 行 —— 2022 年 4 月挂在 contest 1 上的
一条测试公告。迁移不写 CASCADE,同 0002:真有别的东西引用了,宁可报错也别被悄悄
级联掉。

**上生产要显式放行**:migrate.ts 的破坏性闸会拦下 DROP TABLE,得
`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`,并按它说的先备份。本机已实跑:
不带环境变量确实被拦,带上之后表和序列都没了,重跑是「没有待执行的迁移」,
db:generate 报 No schema changes(snapshot 与 schema.ts 对齐,不给下一条迁移留假 diff)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
2026-09-02 20:38:04 -06:00
xuyueandClaude Opus 5 4b6242ba70 fix(比赛): 比赛进行中,学生打开比赛题必 500
contest.ts 两处把「藏起来的难度」表示成空串,而 problemDifficultySchema
是严格的三值枚举,parse 直接抛 ZodError。contestDetailsAllowed 在
「比赛进行中 + 看的人不是管理员」时就是假 —— 也就是正常学生在正常比赛里,
比赛题列表和详情页一开就挂。dev 库 contest 表一直是空的,这条路径没被跑到过。

改成下发 null,契约里给这两个字段单独起了 maskedProblemDifficultySchema。
语义上对齐旧 Django:ProblemSafeSerializer 把 difficulty 放在 exclude 里,
字段整个不下发,而不是给一个假值。

端上顺着 null 补了四处:ProblemInfo 的「难度」项整条不渲染(和旧栈表现一致),
题目列表、题单列表同理,transforms 的 ProblemFiltered.difficulty 也放开为可空。
这几处是 vue-tsc 逐个揪出来的,正是严格枚举该起的作用。

实测(学生 / 超管 × 比赛题 / 普通题 四种组合):学生看比赛题详情与列表
从 500 变 200 且 difficulty 为 null,超管仍拿到真值 Low,普通题不受影响;
页面上「难度」整项消失,统计面板其余内容正常。

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

用户可见的列表:

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

后台批处理:

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

写入路径:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:43:57 -06:00
xuyueandClaude Opus 5 3f6b4102c9 feat(题目): 学生题目页恢复「要求」展示,走新的 astRequirements 字段
Deploy / deploy (push) Has been cancelled
旧后端的 ProblemSerializer.Meta.exclude 没排掉 ast_rules,学生拿到的是规则原文,
题目页上那块「要求」(「if 条件 出现 2 次」之类)是显示的。阶段 3 泄露评审刻意
收掉了它,只留 hasAstRules 布尔值 —— 报告里当时就写了:

  「新的更安全,但如果 ojnext 有地方读 ast_rules 的具体内容(比如提示"必须用
    for 循环"),需要补个专门的字段。」

前端确实有(ProblemContent.vue 的 astRulesForDisplay + 整块渲染),但那个字段
一直没补。结果是这块在 OJ2 上**永远拿不到数据**,代码还在、没人报错。12 道题
受影响;学生只有提交失败后才能从 statistic_info.ast_results 里看到要求。

现在按评审自己的建议补上:oj 侧题目详情(含比赛题)多一个 astRequirements,
**只有渲染要用的两个字段**:

    { description: "if 条件 出现 2 次", kind: "count" }

文案由后端生成,engine / target 这些内部字段不出现在响应里 —— 收紧保留,
展示恢复。实测响应里搜不到 "engine" 也搜不到 "if_statement"。

## 顺带合掉一堆重复

描述文案原来有两份几乎一样的实现:判题机 ast.ts 里一份(写进 ast_results)、
ProblemContent.vue 里一份(题目页用)。现在统一成 ast.ts 的 describeAstRule,
两边共用。差异只在 min/max 同时给出时的措辞(生产库里没有这种规则,实际输出
逐字不变),另外判题机现在也能用上节点类型的中文名 —— 没写 label 的规则以前
判题结果里显示 `必须使用 function_definition`,现在是 `必须使用 函数定义`。

节点类型中文名那 15 条原来在 AstRulesEditor.vue(下拉 options)和
ProblemContent.vue(NODE_TARGET_LABELS)各手抄一份,收进契约的
AST_NODE_TARGET_LABELS,编辑器的下拉现在从它生成。

前端删掉 NODE_TARGET_LABELS / ruleDescription / ruleTagType 共 ~80 行,
`Problem` 类型里那个 oj 侧根本不下发的 astRules 幽灵字段也去掉了。顺带修掉
一处潜在重复渲染:原来 message 非空时 ruleDescription 返回 message、模板里
又单独渲染一次 message(生产库 message 全是空串,所以没露出来)。

## 一个坑:契约里差点搞出循环引用

astRequirements 一开始放在 admin.ts,problem.ts 去 import 它 —— 而 admin.ts
本来就 import problem.ts。**tsc 一声不吭地过了**,运行时才炸:

    ReferenceError: Cannot access 'astRequirementsSchema' before initialization

所以整块 AST schema 从 admin.ts 挪到了 problem.ts(本来也是题目域的东西),
admin.ts 反过来从那边取。这类环 tsc 抓不到,加跨文件 schema 引用时得实跑一次。

## 验证

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

- 把生产库那条规则种进本地库,匿名请求 /api/problems/1001:astRequirements 是
  `{"Python3":[{"description":"if 条件 出现 2 次","kind":"count"}, ...]}`,
  响应里没有 astRules、没有 engine、没有 if_statement。
- 浏览器打开题目页,「要求」那块活了:`要求 | if 条件 出现 2 次 | else 子句 出现 2 次`。
- 后台编辑页展开「代码规则检查」,两条规则正常渲染成
  `出现次数 | if 条件 | 精确`,下拉选项(现在从契约生成)正确。
- 直接调 describeAstRule / checkAst / astRequirements:三种 kind 分类正确,
  engine 认不出的规则被丢掉,null 和非对象都回落成 null。
- oj 侧 3 页 + 后台 4 页走查无重定向、console 无报错。

冒烟改动已还原(problem 2 的 ast_rules 复位成 null)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:27:40 -06:00
xuyueandClaude Opus 5 20ecc05f63 fix(Minor M2): 比赛权限加中间件兜底
旧后端用 @check_contest_permission 装饰器,漏挂一眼看得出来;新后端手工在 handler
里调 canAccessContest,漏调一次就是静默放行,而且这类路由挂的是 optionalAuth
(本身不拦人),从路由注册那一行完全看不出它受保护。

新增 requireContestAccess(checkType, paramName),把「取比赛 → 404 → 鉴权 →
401/403」四步收进注册行。手工调用点 5 → 2:

- GET /contests/:id/access —— 报告权限而非强制,不能 403,留手工
- POST /submissions —— 比赛 id 来自请求体,中间件跑时 body 还没解析,留手工

两处都就地写了说明。canAccessContest 改成泛型,因为 Hono 的 Context 在
Variables 上逆变,写死 Context<AppEnv> 与 ContestEnv 不兼容。

实测拦截行为不变:匿名 401、登录 200、密码赛未过密码 403。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:00:58 -06:00
xuyueandClaude Opus 5 b4b61af6b0 fix(阶段3): 匿名不可读用户档案,真名改为默认不下发
F1:GET /profiles/:username 只挂了 optionalAuth、handler 内无登录判断,
匿名可读 email、adminType、className、lastLogin。用户名又能经 /rankings/users
公开枚举,等于可以无 cookie 批量收集全校学生的邮箱与最后登录时间。
handler 开头补上未登录即返回空,对齐旧后端 account/views/oj.py 的
UserProfileAPI.get 首行 `if not user.is_authenticated: return self.success()`。

F2:旧后端把「是否下发真名」做成 UsernameSerializer(need_real_name=False)
的默认关闭开关,全仓 11 处调用只有比赛榜单一处显式打开;新后端没搬这一层,
真名随用户对象无条件下发,13 个下发点里 8 个匿名可达。

这里补回同一层:helpers.ts 新增 sampleUser(),realName 默认不下发,
需要的地方显式传 { includeRealName: true }。12 个下发点改为走这个函数,
只有比赛榜单一处打开(对齐 contest/serializers.py:84 的 is_contest_admin)。
没有逐处删字段 —— 那样下次新增端点还会重犯。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:59:05 -06:00
xuyueandClaude Opus 5 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