Commit Graph

14 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
cf1eea0810 fix(比赛): 学生管理员开卷、导出只剩十行、排行榜翻页错位等四处
Some checks failed
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
ff31f7abd1 fix(题单): 补回加入题单前旧提交的遮挡
旧栈的 SubmissionListSerializer.get_show_link 有一条防作弊规则:学生加入含某道题的
题单后,他在加入之前留下的 AC 代码就摆在提交列表里,复制粘贴即可过关,所以要把这段
旧提交藏起来。重写时整条丢了 —— canViewSubmission 里 grep 不到一个 problemset,
自己的提交第一行就无条件 return true。

前端反而把 UI 留着了:ProblemSubmission.vue 那个锁图标的 tooltip 一直写着「这道题在
你已经加入的题单中,只有在题单中完成此题,代码才可见」,而后端永远不会给出
showLink: false,图标一次都没亮过 —— 提示语本身成了操作指引。

题单的 end_time 也因此成了孤儿字段:它不是「截止后不能提交」,是这道闸门的时间边界。

影响面不是边角:8-07 的快照里 1734 人次、188 名学生在加入题单之前就已经 AC 过题单
里的题,占全部已解题次的 22.5%。

补回来的同时改了三处旧栈的做法:

- 判断放进 canViewSubmission,列表和详情一起挡。旧栈只挡列表链接,
  SubmissionAPI.get 光走 check_user_permission,知道 submission id 直接访问照样
  拿得到代码,遮挡是虚的。
- 一题落在多个已加入题单里时取 max(join_time),「存在任一题单要求遮挡就遮挡」等价于
  「早于最晚的那次加入」。旧栈用 .first() 取任意一条,行为不确定。
- 比赛提交列表不挂闸门。题单里的题必定是非比赛题(加题时卡了 contestId IS NULL),
  而那条列表只出比赛提交,交集恒空,挂上去就是每页白跑一次查询 —— 而比赛进行中它是
  被刷得最狠的。旧栈 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。

闸门只挡「看代码」这一路,不挡 allowShared=false 那一路 —— 后者是分享开关的归属校验,
与作弊无关,挡了学生连自己旧提交的分享都动不了。管理员不受限,对齐旧栈的
is_regular_user() 前提。

解锁三条路实跑验证过:在题单里做出该题、题单过 end_time、题单归档。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:23:13 -06:00
6c2381ad2c perf(提交列表): 越早的页越慢,最后一页要等 1.2 秒
Some checks failed
Deploy / deploy (push) Has been cancelled
翻页用的是 LIMIT n OFFSET m,而 Postgres 对 OFFSET 没有捷径:前 m 行必须真的
产出再丢掉,丢弃又发生在 join 之后,每一行都白回了一次表。生产快照(10.4 万条
公开提交)实测最后一页 1258ms、碰了 95347 个 buffer。最早那几页平时没人翻,
数据页从来不在缓存里,全是冷读,所以感受上比新的几页慢得多。

改成两步:先只 select create_time / id 数到第 m 行拿游标——这两列正好是部分索引
的全部内容,跳过 m 行走 Index Only Scan,Heap Fetches 为 0,纯在索引页里数数;
再拿这一行做 keyset 回查,只回表取 limit 行。同一页降到约 9ms、885 个 buffer,
端到端 HTTP 10.7ms。代价变成 O(m) 个索引条目而不是堆页,按快照密度外推,
涨到 100 万条时最深一页仍在几十毫秒量级。

接口签名和前端都没动,页码跳转照旧。offset 为 0、以及按题号筛选时(条件在
problem 表上,第一步得跟着 join,index-only 就没了)退回普通 offset。

部分索引从 (create_time) 换成 (create_time, id):create_time 由
new Date().toISOString() 生成,只有毫秒精度,不是全序,游标用 <= 回查时同毫秒的
上一页末行会重复出现在下一页页首。加 id 之后两步走同一个顺序。索引 2.3MB → 6.9MB。

索引两列都建成默认 ASC,靠 Index Only Scan Backward 反着扫。别照着 ORDER BY
写成 (create_time DESC, id DESC):ORDER BY 的 DESC 默认 NULLS FIRST,索引的 DESC
默认 NULLS LAST,规划器认为出不了序,会退化成 external merge sort(5.2MB 落盘),
比不加索引还糟。这一条已写进 schema.ts 和迁移文件的注释。

正确性:在快照上把新旧写法返回的 id 序列逐页比对,14 个 offset × 4 个 limit
共 56 组全部一致,含末尾残页与越界。

比赛提交列表暂不改:单场比赛撑死几千条,offset 不构成问题。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:13:38 -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
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
cafa92a102 fix(阶段4评审收尾): 清掉三条 Minor,顺带一个真 bug
## M4 禁用账号会把学生卡在登录死循环里(唯一学生会撞上的)

`getSessionUser` 对禁用用户返回 null,于是落到 401 `login-required`,
而前端拦截器见到这个码就弹登录框 —— 一个上课上到一半被禁用的学生会陷入
「弹登录框 → 登进去 → 又被弹」,完全看不出发生了什么。

会话解析改成返回 `{ user } | { user: null, reason: "anonymous" | "disabled" }`,
禁用报 403 `account-disabled`(凭证有效、是账号不让用了,和 login 接口对禁用
账号的回法一致)。会话照删,禁用立即生效。前端补一支:清登录态 + 明确提示,
**不弹登录框**。

实测:会话中途 UPDATE is_disabled=true → 同一会话下一个请求
403 `account-disabled`「账号已被禁用,请联系老师」。

## M3 三个端点的守卫写在 handler 体内

submissions/statistics、submissions/:id/rejudge、flowcharts/statistics 的档位
本来就是对的,但写成 handler 里的 if,违背了「守卫要从注册行上看得出来」的约定,
下一个人加同类端点容易漏掉那个 if。改用 requireTeacher / requireSuperAdmin。

实测档位没变:普通学生三个都 403;教师统计接口 200、重判仍 403。

## M2 from-public 的错误码构成比赛存在性预言机

比赛不存在回 `not-found`、存在但不属于你回 `contest-not-found`,带一个已知
有效的 problemId 就能靠错误码枚举出哪些 contestId 真实存在。统一成
`contest-not-found`,和全仓其余跨租户路径一致。

实测:两种情况现在都是 404 contest-not-found。

## 顺带:比赛里的 SQL 题看不到示例数据

改 M2 时 tsc 报 `sqlDisplay` 声明了没用到 —— 查下去是真 bug:
`POST /contests/:id/problems` 把展示数据算出来了,却往库里写死 null
(公开题那两条路径都是对的)。于是比赛里的 SQL 题打开后没有示例数据表和期望结果。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:23:23 -06:00
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
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
f548aef9f0 fix(阶段3): 补齐 contestId 脱敏与仓库根 .env 加载
两处是首轮六条修复的收尾,均由验证过程暴露:

1. contestId 未脱敏 —— 旧后端 SubmissionSafeModelSerializer 排除的是
   info/contest/ip 三个字段,首轮只处理了 info 与 ip。

2. 根 .env 读不到 —— Bun 只自动加载 cwd 下的 .env,而启动方式
   (bun run --filter '@oj2/api' dev) 会把 cwd 切到 apps/api/,
   于是 .env.example 教人写在仓库根的 JUDGE_SERVER_TOKEN 静默失效,
   后端降级成随机 token,判题全部卡在 PENDING。config 改为显式补读
   根 .env,且只填充未设置的键(真实环境变量与 cwd 下的 .env 优先)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:12:31 -06:00
8237909d00 fix(阶段3): 提交可见性守卫补上匿名,详情脱敏,提交接口加限流
F3:`isRegularUser(user)` 对匿名用户(user 为 null)返回 false,
`submission_list_show_all` 关闭时守卫整体短路 —— 匿名能看到全部提交,
权限反而大于登录学生。实测开关关闭时匿名 total=23、登录学生 total=0。
而关闭这个开关的典型场景正是考试。改为 `!isAdminRole(user)`(非管理员即受限),
并删掉 isRegularUser(全仓只有这一处调用,留着就是下一个坑)。

F4:提交详情把 info(含每个测试点的 test_case 编号与 output_md5)与 ip
按「是不是自己的提交」下发。旧后端 submission/views/oj.py 把关的是角色:
is_admin_role() 决定用 SubmissionModelSerializer 还是
SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))。改为仅管理员可见。

F6:旧后端 SubmissionAPI.throttling 的 TokenBucket 没搬过来。
按旧参数重建(options/options.py:120 的 user 桶:capacity 20、fill_rate 0.03、
default_capacity 10,实际值仍以数据库 throttling 配置项为准),
落在 redis 上并改用 Lua 脚本做成原子操作 —— 旧实现自己注明「不是线程安全的」,
而限流要挡的正是并发突发。挂点位置与旧后端一致:比赛权限校验之后、取题目之前。

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