Compare commits

..
17 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 f8b04af4ea chore: 加 oxlint,现存 19 条 warning 清零
Deploy / deploy (push) Canceled after 0s
原来只有 Prettier,没有任何 lint。根目录加 `bun run lint`(oxlint,覆盖后端、前端、
契约,--deny-warnings:有一条就算失败),CLAUDE.md 常用检查里加上。

现存 19 条顺手清掉:
- 11 处未使用的 import / catch 参数 / 回调参数
- oj/store/problem.ts:`...problem.value?.languages` 展开可能是 undefined
  (外层 if 已经保证非空,去掉 ?.)
- admin/problem/detail.vue:`contestId = contestId` 自赋值,删掉
- judge/sql/engine.ts:注释里的 `/*x*/` 示例原来靠零宽空格防止提前闭合注释,
  改成 `*\/`
- 其余:多余的 !!、new Array(n)、展开里多余的 `|| {}`

两边类型检查、前端构建、后端单二进制编译都过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:25:27 +08:00
xuyueandClaude Opus 5.5 83b9d23c68 perf(前端): zod 移出首屏包,首屏 JS 721KB → 594KB(gzip 237 → 199KB)
首屏包里带着整个 zod 运行时(~100KB),来路有三条,缺一条都挪不出去(逐条撤回实测过):

- 契约包没有 sideEffects: false,入口从桶里取任何一个值都会把所有 schema 模块带上。
  契约包里没有顶层副作用(核过),加上之后 rolldown 才能按导出摇树
- roles.ts 里的 USER_TYPE / toAdminType 是入口要的,同文件却定义了两个 z.enum。
  schema 挪到 admin.ts(唯一用处),roles.ts 文件头写明不许 import zod
- 启动就要调的 getWebsiteConfig / getAILoginSummary 在 oj/api.ts 里,而那个文件同步
  import 了题目详情、提交详情两个 schema。这两个函数挪到 shared/api.ts;那两道
  闸门保持同步 —— submissionDetailSchema 有 .catch(),放行原文和解析结果不等价
- /me 的契约闸门改成 contractDeferred:先放行原文,schema 按需加载后在后台核、只记
  日志。userProfileSchema 没有 default / transform,两者等价。动态 import 的目标是
  单独的 shared/profileSchema.ts —— 直接 import 契约桶会让 rolldown 重排共享 chunk,
  实测首屏反而涨到 1811KB

浏览器实跑核过:登录后 profile 正常加载、profileSchema 按需拉取、无控制台报错;
故意喂一份不符的数据,后台闸门照常打出 [契约] 分歧日志。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:23:31 +08:00
xuyueandClaude Opus 5.5 6b2da41882 refactor(题单后台): 添加 / 编辑弹窗两两合并成 ProblemModal、BadgeModal
AddBadgeModal / EditBadgeModal、AddProblemModal / EditProblemModal 各是两份八成相同
的组件,改一处表单项另一处总要漏。合并成一个组件,传了 badge / problem 就是编辑、
null 就是添加;两种模式分别 emit create / update,detail.vue 的四个 handler 不变。

表单项、校验、文案、提交的数据形状都照旧(题目弹窗添加时带 problemId、编辑时只显示
标题)。有一处刻意的行为变化:编辑弹窗原来只在换了一条记录时才重新填表,取消后再打开
同一条会看到上次没保存的改动;现在每次打开都按库里的值重新填。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:16:03 +08:00
xuyueandClaude Opus 5.5 f49834f19f refactor(班级 PK): 十个柱状图 computed 合成 barChart 工厂
十张对比图只差标题和取哪一列,原来各写一份 18 行的 computed。合成
barChart(label, key),key 限定为 ClassComparison 里值必为数字的字段,
渲染出的数据结构(labels / datasets / 配色 / borderWidth)逐字不变。
Chart.js 在本文件只注册了一次,没有重复。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:16:03 +08:00
xuyueandClaude Opus 5.5 e17da63fc6 fix(上传): 头像和题面插图按文件头认格式,不信扩展名
原来只看扩展名:改个后缀什么都能落盘,再由 /public/* 按扩展名配 content-type
发出去。线上 Caddy 有 nosniff 兜着,但不该把非图片存到盘上。

- services/image.ts:按 PNG / JPEG / GIF / BMP 的文件头识别,返回落盘用的扩展名 ——
  以内容为准,改名成 .png 的 JPEG 存成 .jpg,content-type 才对得上
- 头像上传、后台插图上传都走它;插图那边扩展名白名单保留当第一道快速筛
- serveUpload 自己也带 x-content-type-options: nosniff,不只靠 Caddy

实跑核过:真 PNG / GIF 正常;改名成 .png 的 JPEG 存成 .jpg;改名成 .png 的 HTML
两个入口都拒;/public/upload 回 image/png + nosniff。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:15:36 +08:00
xuyueandClaude Opus 5.5 b105fe3cbd fix(测试点): 解压前按条目头拦大小,防 zip bomb
原来 unzipSync 先把整包解进内存,再逐个查大小 —— 检查之前就已经解完了。
实测一个 300KB 的 zip(10 个 30MB 的全零文件)让进程 RSS 涨 455MB,
容器只有 512MB。

- 第一遍 unzipSync 的 filter 只读目录、恒返回 false,按条目头的 originalSize
  累计,单个超 32MB / 总计超 128MB 就抛;第二遍才真解,而且只解 N.in / N.out /
  N.sql(N ≤ 500),压缩包里别的文件一律不解
- originalSize 可以造假,但 fflate 按它分配输出缓冲、不扩容(解多了只截断),
  所以按它限额就是按实际内存限额

实测:同一个 zip bomb 被拦下时 RSS 只涨 1MB;单文件 300MB 的包按单文件上限拦;
正常测试点照常落盘、CRLF 照常归一、带 ../ 的条目名照常被忽略。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:14:12 +08:00
xuyueandClaude Opus 5.5 7a390323fe refactor(前端): 接口错误改成 ApiError 类,catch 不再写 any
拦截器原来 reject 一个裸对象 `{ error, data }`,没有类型也没有类型守卫,于是 46 处
`catch (err: any)` 直接读 `err.data` / `err.error` —— 代码 bug 抛出的 TypeError 也按
接口错误读,弹出来的是 undefined 或英文。

- utils/api.ts:reject 改成 `ApiError extends Error`,字段名沿用 error(错误码)/
  data(文案),存量读法语义不变;加 isApiError / errorCode / errorMessage。
  errorMessage 对非接口错误一律用 fallback,不把 TypeError 的英文甩给学生
- 46 处 catch 全部换掉 any(45 处接口错误 + pythonSyntaxCheck 里 Skulpt 的错误对象)
- 断网等没有响应体的情况,文案从英文 "Request failed" 换成「网络异常,请检查网络后重试」

浏览器实跑核过:登录密码错误仍显示「用户名或密码不正确」(错误码分支)、
接口错误 reject 出来是 ApiError、网络不通时拿到中文文案、非接口错误走 fallback。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:12:40 +08:00
xuyueandClaude Opus 5.5 6406794971 refactor(AI): 学情统计查询从 routes/ai.ts 挪到 services/learning-stats.ts
routes/ai.ts 1149 行里约六成是 buildSolved / buildDetail / buildDuration 这些查询
构造器,它们不碰 Context,只是查询。挪出去之后 route 文件剩 585 行,只管
「鉴权 → 取参 → 调统计 → 回包」和 AI 调用本身;targetUser / throttleAi 依赖
Context,留在 route 里。

纯搬移,函数体一行没改。实跑核过:同一个用户的 /ai/detail、/ai/duration(按月、
按周)、/ai/solved、/ai/heatmap 五个响应,改前改后逐字节一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:09:21 +08:00
xuyueandClaude Opus 5.5 925cecb389 refactor(题目): 新建 / 复制题目收成 validateProblem、insertProblem、copyProblem
admin/problem.ts 里四处 insert(schema.problem) 其实是两对重复:

- 新建公开题 / 新建比赛题:公共校验 + SQL 展示数据 + 插入 + 写标签,逐行一样,
  只差 contestId。编辑题目那条也抄了同一段校验。收成 validateProblem(校验 +
  展示数据)和 insertProblem(插入 + 标签,一个事务)。比赛题当初就是抄漏了
  sqlDisplay,现在三条路径只有一份
- 比赛题转公开 / 从公开题库拉进比赛:复制整行 + 统计清零 + 复制标签,收成 copyProblem

顺带 db/index.ts 加 DbOrTx 类型,四处 `tx as unknown as typeof db` 去掉。

唯一的行为差异:编辑题目时 SQL 展示数据的生成挪到了题号查重之前(和新建一致),
两项同时出错时报的是前一个。

实跑核过:新建公开题、编辑、重复题号 409、新建比赛题、比赛题转公开(不可见、
标签跟过去)、从公开题库拉进比赛(可见、标签跟过去),SQL 题测试点缺失时照样拦下。
测试数据已删。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:07:13 +08:00
xuyueandClaude Opus 5.5 f2ca34fd4b refactor(api): 请求体解析收成 readJson / parseBody
54 处 handler 都手写同一段:
  const parsed = X.safeParse(await c.req.json().catch(() => null))
  if (!parsed.success) return failure(c, 400, "invalid-request", ...)
漏写 .catch 就是一个畸形请求打出 500。

- http.ts 加 readJson(读 + 校验)和 parseBody(再加上 400)。parseBody 不给
  message 时回 zod 的第一条 issue —— 后台表单原来就是这么写的,fallback 文案
  「Invalid payload」/「参数错误」只在 issues 为空时才用得上,实际走不到
- 51 处换成 parseBody;另 3 处失败时带别的错误码或和路径参数一起判,换成 readJson
- 错误码、状态码、文案、判断顺序都不变

实跑核过:非 JSON 请求体回原来的固定文案,公告标题为空回 zod issue。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:04:19 +08:00
xuyueandClaude Opus 5.5 c43da01ff2 refactor(AI): 四份 SSE 请求代码合成 streamAI + useAIStream
学情分析、班级分析、班级 PK、AI 提示四处原来各有一份几乎一样的 fetch + SSE 解析,
错误处理各漏各的:

- 裸 fetch 绕过了 axios 拦截器,未登录时只显示「AI 分析生成失败」而不弹登录框。
  拦截器里的全站错误处理抽成 handleGlobalApiError,两条路共用
- 班级分析、班级 PK 离开页面不中止流。useAIStream 在作用域销毁时自动 abort
- AI 提示换了一次提交不掐上一条,旧提示会写进新提交的面板

utils/stream.ts 的 streamAI 负责请求 + 解析 + 错误翻译;组件用 useAIStream
(waiting = 第一个字还没到,running = 流没结束);学情分析在 store 里直接用 streamAI。

浏览器实跑核过:学情分析逐字流出、provider 500 时显示后端的固定中文文案、
未登录调用弹出登录框。班级 PK / 班级分析卸载即中止这条没有在浏览器里点过
(本机库里没有班级数据),逻辑在 useAIStream 的 onScopeDispose。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:01:43 +08:00
xuyueandClaude Opus 5.5 09030240b8 fix(AI): 流式调用加空闲超时、客户端断开即停上游、错误原因不下发
- streamChat 加 45 秒空闲超时(每收到一块重新计时,不掐长回答),原来 provider
  卡住这条请求就永远挂着
- 加 cancel():客户端断开时 abort 上游 fetch,原来会把 provider 的回答读完白烧 token,
  还把「客户端走了」记成 AI 失败;现在留痕写「客户端已断开」
- 推给前端的 error 事件改成固定中文文案(缺 AI_KEY 时是「AI 功能未开启」)。原来
  error.message 原样下发,里面可能是 provider 的 HTTP 响应体或数据库报错;
  真实原因只进 console.error 和 onError 留痕

用假 provider 实跑核过:正常流式、客户端 1 秒断开时上游被取消、provider 500 时
客户端只拿到固定文案(响应体只在日志里)、provider 挂住 45 秒后收到 error + end。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:01:43 +08:00
xuyueandClaude Opus 5.5 04c0ccd304 fix(AI): Bun.serve 空闲超时调到 60 秒,AI 流不再在 10 秒静默后被掐断
Bun.serve 默认 idleTimeout 是 10 秒:连接上 10 秒没有字节往来就直接断开,不报任何错。
AI 的两条 SSE 都会静默超过 10 秒 —— streamWhole(AI 提示)在 produce 期间每 15 秒
才发一次心跳,一次生成加过滤重生成常常超过 10 秒;streamChat 等首 token 也常过 10 秒。
学生看到的就是「AI 提示生成失败」。

对齐反代链上最短的一环(NPM proxy_read_timeout 60s)。实测:provider 挂住时原来
10 秒整连接被断、客户端收不到 error 事件;改后按 streamChat 自己的 45 秒空闲超时收尾。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:01:43 +08:00
xuyueandClaude Opus 5.5 b38ad8ccee feat(配置): 启动时用 zod 一次解析环境变量,有错拒绝启动
原来是 `Number(env ?? 默认值)` 散写:JUDGE_CONCURRENCY=two 得到 NaN 照样起;
DATABASE_URL 漏配就静默连上开发库的默认账号;AI_KEY 缺了要等第一次 AI 调用才报错。

- config.ts 一张 envSchema 覆盖全部变量,失败时列出所有问题后 exit 1
- 编译形态(线上)下 DATABASE_URL / REDIS_URL / JUDGE_SERVER_TOKEN 必须显式给,
  dev 默认值指向本机 5433 / 6380,放进容器里只会连不上或连错
- 空字符串按未设置处理(compose 的 ${X:-} 展开就是空串)
- AI_KEY 为空启动时告警(AI 是可选功能,不退出)
- db/index.ts 改读 config.databaseUrl;flowchart/run.ts 的 provider / model 改读 config

实跑核过:非法值被列出并拒绝启动;编译产物在仓库外无 env 时报三项必填,
给齐后 /health 正常;dev 的 serve / worker 照常起。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:53:51 +08:00
xuyueandClaude Opus 5.5 ca0dbbf0c5 fix(登录态): 退出和换人登录时整页重载,不留上一个人的数据
signOut 只清了 profile 和 localStorage,AI 分析、学情小结等 store 还在内存里。
机房一台机器轮着用,下一个学生登进来就能看到上一个人的 AI 分析。

- 主动退出:router.replace 换成 window.location.replace("/")
- 被动下线(会话过期、别的标签页登出)仍然原地弹登录框,学生正在写的代码不丢;
  getMyProfile 记住本页最后一个登录的用户,登进来的换了人就整页重载

挨个 store 去 reset 迟早漏一个,重载一次最彻底。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:51:52 +08:00
xuyueandClaude Opus 5.5 fb240d4254 feat(限流): 登录、注册、比赛密码防爆破
三处原来都不限次数。学生初始密码弱,/classes/:name/usernames 又能公开列出整班
用户名,等于现成的撞库名单。

services/throttling.ts 加固定窗口计数(INCR + EXPIRE NX,锁定时长就是剩余 TTL),
何时计数由调用方决定:

- 登录按用户名 15 分钟 10 次失败,成功清零;按 IP 15 分钟 100 次失败,成功不清零
  (否则拿自己的号登一次就能重置)。机房一个班共用出口 IP,所以 IP 那道放得宽
- 注册按 IP 每小时 100 个成功注册
- 比赛密码按「人 × 比赛」10 分钟 10 次失败
- IP 取 X-Real-IP:api 端口不对外,Caddy 用 {client_ip} 覆盖写入,可信

前端登录、注册、比赛密码三处按新错误码给中文提示。

本机实跑核过:第 11 次错误登录被拦、成功清零(用户名大小写不敏感)、同 IP 换
100 个用户名被拦且别的 IP 不受影响、比赛密码第 11 次被拦。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:51:52 +08:00
xuyueandClaude Opus 5.5 5813981bb1 fix(部署): Redis 强制口令,外接形态部署前实测
会话存在 Redis 里(session:<token> → 用户 id),能连上它就能伪造任意用户包括超管的
登录态。旧栈起的 oj-redis 发布在 0.0.0.0:5446、不带口令,2026-09-27 从公网实测
PING 得通。

- compose.debian.yml:REDIS_URL 用 ${REDIS_PASSWORD:?} 带上口令,缺了直接报错;
  自带数据形态的 oj-redis 加 --requirepass,端口只发布在 127.0.0.1
- deploy.sh:外接形态下实测「不带口令连不上、带 docker/.env 的口令连得上」,
  任一条不满足就中止部署
- clear-sessions.sh:从 docker/.env 读口令走 REDISCLI_AUTH;口令为空时不带 -e
  (空的 REDISCLI_AUTH 会发一条 AUTH "",实测会报一行 AUTH failed)
- compose.dev.yml:三个端口只绑 127.0.0.1
- docs/deploy.md 加「Redis 口令」一节:给旧栈 redis 加口令的一次性步骤

旧栈那份 compose 不在本仓库,线上 redis 的口令要照手册在服务器上手工加;
加之前 deploy.sh 会一直拦着。机房的 redis 不发布端口,不需要口令。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:51:52 +08:00
98 changed files with 2169 additions and 2202 deletions

No files matched your search

+1
View File
@@ -58,6 +58,7 @@ bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级
cd apps/web && bun run type-check # 前端类型检查
cd apps/web && bun run build # 前端构建
bun run fmt # Prettier,全仓一把(只在根目录有)
bun run lint # oxlint,全仓一把,有一条 warning 就算失败
```
**格式化是全仓一套 Prettier**,配置只有根目录的 `.prettierrc.toml`(`semi=false`,
+108 -33
View File
@@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto"
import { readFileSync } from "node:fs"
import { isAbsolute, resolve } from "node:path"
import { z } from "zod"
import { isCompiled, pathBase } from "./runtime"
/**
@@ -38,11 +40,79 @@ function loadRepoRootEnv() {
loadRepoRootEnv()
/**
* 判题机 token。对齐旧后端 `options/options.py:93`:
* token = os.environ.get("JUDGE_SERVER_TOKEN"); return token if token else rand_str()
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
* 也不要在仓库里写死一个人人都知道的弱默认值。
* 环境变量在**启动时**一次解析完,有错就列出全部问题退出,不带病起来。
*
* 原来是 `Number(env ?? 默认值)` 散写:`JUDGE_CONCURRENCY=two` 得到 NaN,BullMQ 拿着
* NaN 当并发数照样起;`DATABASE_URL` 漏配就静默连上开发库的默认账号。这类错都不在
* 启动时报,而是到第一个请求、第一条提交才以莫名其妙的形式冒出来。
*
* **编译形态(线上)下,库、Redis、判题机 token 必须显式给**:dev 的默认值指向本机
* 5433 / 6380,放到容器里要么连不上、要么连错,都不如当场报错。
* 空字符串一律按「没设」处理 —— compose 里 `${X:-}` 展开出来就是空串。
*/
const blankAsUnset = (value: unknown) => (value === "" ? undefined : value)
const text = (fallback: string) =>
z.preprocess(blankAsUnset, z.string().default(fallback))
const positiveInt = (fallback: number) =>
z.preprocess(
blankAsUnset,
z.coerce.number().int().positive().default(fallback),
)
const requiredWhenCompiled = (devDefault: string) =>
isCompiled
? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" }))
: text(devDefault)
const envSchema = z.object({
PORT: positiveInt(3000),
DATABASE_URL: requiredWhenCompiled(
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
),
REDIS_URL: requiredWhenCompiled("redis://localhost:6380"),
SESSION_TTL_SECONDS: positiveInt(7 * 24 * 60 * 60),
COOKIE_SECURE: z.preprocess(
blankAsUnset,
z.enum(["true", "false"]).optional(),
),
JUDGE_SERVER_URL: text("http://localhost:8081"),
// dev 不设时生成一次性随机值,见 judgeServerToken()
JUDGE_SERVER_TOKEN: isCompiled
? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" }))
: z.preprocess(blankAsUnset, z.string().optional()),
JUDGE_CONCURRENCY: positiveInt(2),
AVATAR_DIRECTORY: text("data/avatar"),
TEST_CASE_DIRECTORY: text("data/test_case"),
UPLOAD_DIRECTORY: text("data/upload"),
HITOKOTO_DIRECTORY: text("data/hitokoto"),
ALLOWED_WS_ORIGINS: text(""),
UPLOAD_URI_PREFIX: text("/public/upload"),
AVATAR_URI_PREFIX: text("/public/avatar"),
AI_BASE_URL: z.preprocess(
blankAsUnset,
z.url().default("https://api.deepseek.com"),
),
AI_PROVIDER: text("deepseek"),
AI_KEY: text(""),
AI_MODEL: text("deepseek-flash"),
AI_HINT_DIAGNOSE: z.preprocess(blankAsUnset, z.enum(["1", "0"]).optional()),
RUFF_PATH: text("ruff"),
CLANG_FORMAT_PATH: text("clang-format"),
})
function parseEnv() {
const parsed = envSchema.safeParse(process.env)
if (parsed.success) return parsed.data
console.error(
"[config] 环境变量有误,拒绝启动:\n" +
parsed.error.issues
.map((issue) => ` ${issue.path.join(".")}: ${issue.message}`)
.join("\n"),
)
process.exit(1)
}
const env = parseEnv()
/**
* 相对路径按 `pathBase` 解析(开发时是仓库根,编译后是 cwd),基准的取舍见 runtime.ts。
*
@@ -52,9 +122,14 @@ function repoPath(value: string) {
return isAbsolute(value) ? value : resolve(pathBase, value)
}
/**
* 判题机 token。对齐旧后端 `options/options.py:93`:
* token = os.environ.get("JUDGE_SERVER_TOKEN"); return token if token else rand_str()
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
* 也不要在仓库里写死一个人人都知道的弱默认值。编译形态下缺了直接拒绝启动(见上面)。
*/
function judgeServerToken() {
const fromEnv = process.env.JUDGE_SERVER_TOKEN
if (fromEnv) return fromEnv
if (env.JUDGE_SERVER_TOKEN) return env.JUDGE_SERVER_TOKEN
console.warn(
"[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。" +
"判题机将无法通过鉴权,本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN," +
@@ -63,49 +138,49 @@ function judgeServerToken() {
return randomBytes(32).toString("hex")
}
// AI 是可选功能(.env.example 写明留空即关),所以只告警不退出 —— 但要在启动时说,
// 别等到学生点了「AI 提示」才从一条 401 里看出来
if (!env.AI_KEY && process.argv[2] !== "sql-child") {
console.warn("[config] AI_KEY 未设置,AI 分析 / 提示 / 流程图评分都不可用。")
}
export const config = {
port: Number(process.env.PORT ?? 3000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
port: env.PORT,
databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
sessionCookie: "oj2_session",
sessionTtlSeconds: Number(
process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60,
),
secureCookies: process.env.COOKIE_SECURE === "true",
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
sessionTtlSeconds: env.SESSION_TTL_SECONDS,
secureCookies: env.COOKIE_SECURE === "true",
judgeServerUrl: env.JUDGE_SERVER_URL,
judgeServerToken: judgeServerToken(),
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
judgeConcurrency: env.JUDGE_CONCURRENCY,
avatarDirectory: repoPath(env.AVATAR_DIRECTORY),
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
testCaseDirectory: repoPath(
process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
),
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY),
uploadDirectory: repoPath(env.UPLOAD_DIRECTORY),
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
hitokotoDirectory: repoPath(
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
),
hitokotoDirectory: repoPath(env.HITOKOTO_DIRECTORY),
/**
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
* 同源本来就放行,只有前后端分处不同域名时才需要配。
*/
allowedWebSocketOrigins: (process.env.ALLOWED_WS_ORIGINS ?? "")
.split(",")
allowedWebSocketOrigins: env.ALLOWED_WS_ORIGINS.split(",")
.map((value) => value.trim())
.filter(Boolean),
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
uploadUriPrefix: env.UPLOAD_URI_PREFIX,
avatarUriPrefix: env.AVATAR_URI_PREFIX,
aiBaseUrl: env.AI_BASE_URL,
/** 只用来写 ai_analysis.provider 这一列,换 provider 时和 AI_BASE_URL 一起改 */
aiProvider: process.env.AI_PROVIDER ?? "deepseek",
aiKey: process.env.AI_KEY ?? "",
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
aiProvider: env.AI_PROVIDER,
aiKey: env.AI_KEY,
aiModel: env.AI_MODEL,
/**
* AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**:
* 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。
* 设成 "1" 打开。
*/
aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1",
ruffPath: process.env.RUFF_PATH ?? "ruff",
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
aiHintDiagnose: env.AI_HINT_DIAGNOSE === "1",
ruffPath: env.RUFF_PATH,
clangFormatPath: env.CLANG_FORMAT_PATH,
}
+10 -5
View File
@@ -1,18 +1,23 @@
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { config } from "../config"
import * as schema from "./schema"
const url =
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone`(`../time` 的 localTime),
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。
const client = postgres(url)
const client = postgres(config.databaseUrl)
export const db = drizzle(client, { schema })
/**
* 「库或事务」都能接的参数类型。辅助函数既要能单独调、又要能在事务里调时用它,
* 别再写 `tx as unknown as typeof db` —— 那个断言把两者的差异(事务上没有
* `$client`、`batch` 等)一并抹掉了。
*/
export type DbOrTx =
typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。
*
+1 -1
View File
@@ -755,7 +755,7 @@ export const problemTag = pgTable(
id: serial().primaryKey().notNull(),
name: text().notNull(),
},
(table) => [
() => [
uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`),
],
)
+3 -2
View File
@@ -1,6 +1,7 @@
import type { FlowchartUpdate } from "@oj2/contract"
import { eq } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { publishFlowchartUpdate } from "../events"
import { completeChat } from "../services/ai"
@@ -85,8 +86,8 @@ export async function evaluateFlowchart(
aiFeedback: result.feedback,
aiSuggestions: result.suggestions,
aiCriteriaDetails: result.criteria,
aiProvider: "deepseek",
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
aiProvider: config.aiProvider,
aiModel: config.aiModel,
processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(),
})
+40
View File
@@ -1,4 +1,5 @@
import type { Context } from "hono"
import type { z } from "zod"
export function success<T>(c: Context, data: T, status = 200) {
return c.json({ data }, status as 200)
@@ -12,3 +13,42 @@ export function failure(
) {
return c.json({ error: { code, message } }, status)
}
/**
* 读 JSON 请求体并按契约校验。body 不是合法 JSON 时按 `null` 交给 schema,
* 由 schema 统一判失败 —— 原来每个 handler 都手写一遍 `c.req.json().catch(() => null)`,
* 漏写 `.catch` 就是一个畸形请求打出 500。
*/
export async function readJson<S extends z.ZodType>(c: Context, schema: S) {
return schema.safeParse(await c.req.json().catch(() => null))
}
/**
* `readJson` + 校验失败时的 400。绝大多数入参端点就是这个形状:
*
* const parsed = await parseBody(c, schema, "标签名不能为空")
* if (!parsed.success) return parsed.response
*
* 不给 `message` 就回 zod 的第一条 issue(后台表单要告诉老师具体哪一栏不对)。
* 失败时要带别的错误码、或者和路径参数一起判的,用 `readJson` 自己写。
*/
export async function parseBody<S extends z.ZodType>(
c: Context,
schema: S,
message?: string,
): Promise<
{ success: true; data: z.output<S> } | { success: false; response: Response }
> {
const parsed = await readJson(c, schema)
if (parsed.success) return { success: true, data: parsed.data }
return {
success: false,
response: failure(
c,
400,
"invalid-request",
// issues 不会是空的,?? 只是给类型一个交代
message ?? parsed.error.issues[0]?.message ?? "参数错误",
),
}
}
+11 -1
View File
@@ -78,7 +78,12 @@ async function serveUpload(
if (await file.exists()) {
// 文件名由后端生成且内容不变,可以放心长缓存
return new Response(file, {
headers: { "cache-control": "public, max-age=86400" },
headers: {
"cache-control": "public, max-age=86400",
// 线上 Caddy 也加了这个头,这里再加一道:content-type 按扩展名配,
// 不让浏览器自己嗅探成 HTML 去执行
"x-content-type-options": "nosniff",
},
})
}
return null
@@ -86,6 +91,11 @@ async function serveUpload(
const server = Bun.serve<SubmissionSocketData>({
port: config.port,
// Bun 默认 10 秒没有字节往来就掐连接,而且不报任何错。AI 的 SSE 两条路都会静默
// 超过 10 秒:streamWhole 在 produce 期间每 15 秒才发一次心跳,streamChat 等首
// token 也常过 10 秒 —— 学生看到的就是「AI 提示生成失败」。对齐反代链上最短的
// 那一环(NPM 的 proxy_read_timeout 60s),两边的心跳 / 空闲超时都在它之内。
idleTimeout: 60,
async fetch(request, bunServer) {
const url = new URL(request.url)
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
+1 -1
View File
@@ -159,7 +159,7 @@ function iterate(db: Database, script: string): Iterable<PreparedStatement> {
/**
* 取语句的首关键字。优先用 sqlite3_normalized_sql —— 归一化由 SQLite 自己做,
* 注释、大小写、空白都已抹平(`/*x*​/ pragma Query_Only = 0` → `PRAGMA query_only=?`),
* 注释、大小写、空白都已抹平(`/*x*\/ pragma Query_Only = 0` → `PRAGMA query_only=?`),
* 比在原文上自己做词法猜测可靠得多。
*/
function leadingKeyword(statement: PreparedStatement) {
+39 -13
View File
@@ -1,8 +1,7 @@
import { randomBytes } from "node:crypto"
import { extname, resolve } from "node:path"
import { resolve } from "node:path"
import {
rankProfileSchema,
registerRequestSchema,
STUDENT_ROLES,
updateProfileRequestSchema,
@@ -43,10 +42,17 @@ import { onlineUserIds } from "../auth/presence"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options"
import { sniffImageExtension } from "../services/image"
import { getUserProfileById } from "../services/profile"
import {
clientIp,
countAttempt,
lockoutRemaining,
type AttemptRule,
} from "../services/throttling"
import { localTime, weekStart } from "../time"
import {
isTeacherOrAbove,
@@ -57,12 +63,19 @@ import {
export const accountRoutes = new Hono<AppEnv>()
/**
* 注册限流:按 IP 每小时 100 个号,只数成功的。拦的是脚本批量造号;
* 机房一个班共用一个出口 IP,一节课全班现场注册也够用。
*/
const REGISTER_PER_IP: AttemptRule = { limit: 100, windowSeconds: 60 * 60 }
accountRoutes.post("/users", async (c) => {
const parsed = registerRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
registerRequestSchema,
"Invalid registration payload",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid registration payload")
if (!parsed.success) return parsed.response
if (!(await getBooleanOption("allow_register", true))) {
return failure(
c,
@@ -72,6 +85,17 @@ accountRoutes.post("/users", async (c) => {
)
}
const registerKey = `register:ip:${clientIp(c)}`
const wait = await lockoutRemaining(registerKey, REGISTER_PER_IP)
if (wait !== null) {
return failure(
c,
429,
"too-many-registrations",
`Too many registrations, please wait ${wait} seconds`,
)
}
const username = parsed.data.username.toLowerCase()
const email = parsed.data.email.toLowerCase()
const [duplicate] = await db
@@ -120,6 +144,7 @@ accountRoutes.post("/users", async (c) => {
realName: null,
})
})
await countAttempt(registerKey, REGISTER_PER_IP)
return success(c, { ok: true }, 201)
})
@@ -149,11 +174,12 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
})
accountRoutes.put("/me/profile", requireAuth, async (c) => {
const parsed = updateProfileRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
updateProfileRequestSchema,
"Invalid profile payload",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid profile payload")
if (!parsed.success) return parsed.response
const values = Object.fromEntries(
Object.entries(parsed.data).map(([key, value]) => [
key,
@@ -179,8 +205,8 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
return failure(c, 400, "invalid-file", "Invalid file content")
if (image.size > 2 * 1024 * 1024)
return failure(c, 400, "file-too-large", "Picture is too large")
const extension = extname(image.name).toLowerCase()
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
const extension = await sniffImageExtension(image)
if (!extension) {
return failure(c, 400, "unsupported-file", "Unsupported file format")
}
const filename = `${randomBytes(10).toString("hex")}${extension}`
+6 -5
View File
@@ -10,7 +10,7 @@ import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { objectValue } from "./helpers"
export const achievementRoutes = new Hono<AppEnv>()
@@ -177,11 +177,12 @@ achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
})
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
const parsed = markAchievementsReadSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
markAchievementsReadSchema,
"Invalid achievement ids",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid achievement ids")
if (!parsed.success) return parsed.response
if (parsed.data.ids.length > 0) {
await db
.update(schema.userAchievement)
+7 -28
View File
@@ -33,7 +33,7 @@ import { isUserOnline, onlineUserIds } from "../../auth/presence"
import { revokeUserSessions } from "../../auth/session"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { queryInteger, sampleUser } from "../helpers"
export const adminAccountRoutes = new Hono<AppEnv>()
@@ -252,17 +252,8 @@ adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateUserRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateUserRequestSchema)
if (!parsed.success) return parsed.response
const data = parsed.data
const [existing] = await selectUser(id)
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
@@ -350,17 +341,8 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
})
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
const parsed = importUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, importUsersRequestSchema)
if (!parsed.success) return parsed.response
const rows = parsed.data.users
type Prepared = {
username: string
@@ -544,11 +526,8 @@ function isForeignKeyViolation(error: unknown) {
class UserHasSubmissionsError extends Error {}
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = deleteUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "ids is required")
const parsed = await parseBody(c, deleteUsersRequestSchema, "ids is required")
if (!parsed.success) return parsed.response
const me = c.get("user")!.id
if (parsed.data.ids.includes(me)) {
return failure(
+5 -23
View File
@@ -9,7 +9,7 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import {
ACHIEVEMENT_METRICS,
findMetric,
@@ -72,17 +72,8 @@ adminAchievementRoutes.get(
)
adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
const parsed = createAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, createAchievementRequestSchema)
if (!parsed.success) return parsed.response
if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
@@ -111,17 +102,8 @@ adminAchievementRoutes.put(
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, updateAchievementRequestSchema)
if (!parsed.success) return parsed.response
if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
+5 -23
View File
@@ -9,7 +9,7 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { queryInteger, sampleUser } from "../helpers"
export const adminAnnouncementRoutes = new Hono<AppEnv>()
@@ -78,17 +78,8 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
})
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
const parsed = createAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, createAnnouncementRequestSchema)
if (!parsed.success) return parsed.response
const now = new Date().toISOString()
const [created] = await db
.insert(schema.announcement)
@@ -126,17 +117,8 @@ adminAnnouncementRoutes.put(
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateAnnouncementRequestSchema)
if (!parsed.success) return parsed.response
const updated = await db
.update(schema.announcement)
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
+15 -18
View File
@@ -22,7 +22,8 @@ import {
import { config } from "../../config"
import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { sniffImageExtension } from "../../services/image"
import { getWebsiteOptions } from "../../services/options"
import { todayStart } from "../../time"
import { queryInteger } from "../helpers"
@@ -77,17 +78,8 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
})
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
const parsed = updateWebsiteConfigRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateWebsiteConfigRequestSchema)
if (!parsed.success) return parsed.response
const entries = (
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
@@ -131,11 +123,12 @@ adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
})
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
const parsed = updateJudgeServerRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
updateJudgeServerRequestSchema,
"isDisabled is required",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isDisabled is required")
if (!parsed.success) return parsed.response
const updated = await db
.update(schema.judgeServer)
.set({ isDisabled: parsed.data.isDisabled })
@@ -300,8 +293,12 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
filePath: "",
} satisfies UploadImageResponse)
}
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
if (!IMAGE_SUFFIXES.includes(suffix)) {
// 以文件头为准,扩展名只是第一道快速筛
const claimed = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
const suffix = IMAGE_SUFFIXES.includes(claimed)
? await sniffImageExtension(image)
: null
if (!suffix) {
return success(c, {
success: false,
msg: "Unsupported file format",
+8 -35
View File
@@ -12,9 +12,9 @@ import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { contestStatus } from "../../services/contest"
import { objectValue, queryInteger, sampleUser } from "../helpers"
import { queryInteger, sampleUser } from "../helpers"
export const adminContestRoutes = new Hono<AppEnv>()
@@ -120,17 +120,8 @@ adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
})
adminContestRoutes.post("/contests", requireTeacher, async (c) => {
const parsed = createContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, createContestRequestSchema)
if (!parsed.success) return parsed.response
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error)
@@ -157,17 +148,8 @@ adminContestRoutes.post("/contests", requireTeacher, async (c) => {
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateContestRequestSchema)
if (!parsed.success) return parsed.response
const [existing] = await selectContest(id)
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
@@ -386,17 +368,8 @@ adminContestRoutes.put(
requireTeacher,
async (c) => {
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAcmHelperRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateAcmHelperRequestSchema)
if (!parsed.success) return parsed.response
const [contest] = await db
.select()
.from(schema.contest)
+148 -224
View File
@@ -31,8 +31,8 @@ import { Hono } from "hono"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { db, schema, type DbOrTx } from "../../db"
import { failure, parseBody, success } from "../../http"
import { astRulesError, pickAstRules } from "../../judge/ast"
import { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai"
@@ -120,7 +120,7 @@ export function normalizeTagNames(names: string[]) {
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
* 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。
*/
export async function findTagsByName(tx: typeof db, names: string[]) {
export async function findTagsByName(tx: DbOrTx, names: string[]) {
const map = new Map<string, number>()
if (names.length === 0) return map
const rows = await tx
@@ -137,7 +137,7 @@ export async function findTagsByName(tx: typeof db, names: string[]) {
}
/** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */
async function resolveTags(tx: typeof db, names: string[]) {
async function resolveTags(tx: DbOrTx, names: string[]) {
const wanted = normalizeTagNames(names)
if (wanted.length === 0) return []
const existing = await findTagsByName(tx, wanted)
@@ -154,7 +154,7 @@ async function resolveTags(tx: typeof db, names: string[]) {
.filter((id) => id !== undefined)
}
async function setTags(tx: typeof db, problemId: number, names: string[]) {
async function setTags(tx: DbOrTx, problemId: number, names: string[]) {
const ids = await resolveTags(tx, names)
await tx
.delete(schema.problemTags)
@@ -332,6 +332,99 @@ function problemValues(
}
}
type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
/**
* 保存前的全部校验:公共校验 + SQL 题的展示数据。新建公开题、新建比赛题、编辑题目
* 三条路径共用 —— 原来各抄一份,比赛题那份还把算好的 sqlDisplay 丢了(见 insertProblem)。
*
* SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上。
*/
async function validateProblem(
data: ProblemInput,
): Promise<
{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }
> {
const checked = commonChecks(data)
if ("error" in checked) return checked
if (!checked.sql) return { sql: false, sqlDisplay: null }
const built = await generateSqlDisplay(
data.testCaseId,
data.answers,
data.sqlConfig!,
)
if ("error" in built) return built
return { sql: true, sqlDisplay: built.display }
}
/** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */
async function insertProblem(
data: ProblemInput,
validated: { sql: boolean; sqlDisplay: SqlDisplay | null },
owner: { contestId: number | null; createdById: number },
) {
const now = new Date().toISOString()
return db.transaction(async (tx) => {
const [row] = await tx
.insert(schema.problem)
.values({
...problemValues(data, validated.sql),
...owner,
createTime: now,
lastUpdateTime: now,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
isPublic: false,
// 比赛题原来这里写死 null,结果比赛里的 SQL 题打开后看不到示例数据表和期望结果
sqlDisplay: validated.sqlDisplay,
})
.returning()
await setTags(tx, row!.id, data.tags)
return row!
})
}
/**
* 把一道已有的题复制一份(连同标签),统计清零。比赛题转公开、从公开题库拉进比赛
* 都是这个动作,只是落到哪、可见性怎么设不同,由 `overrides` 给。
*/
async function copyProblem(
tx: DbOrTx,
source: ProblemRow,
overrides: Pick<
typeof schema.problem.$inferInsert,
"contestId" | "displayId" | "visible" | "isPublic"
>,
) {
const now = new Date().toISOString()
const { id: _old, ...rest } = source
const [copy] = await tx
.insert(schema.problem)
.values({
...rest,
...overrides,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createTime: now,
lastUpdateTime: now,
})
.returning()
const tags = await tx
.select({ tagId: schema.problemTags.problemtagId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemId, source.id))
if (tags.length) {
await tx
.insert(schema.problemTags)
.values(
tags.map((tag) => ({ problemId: copy!.id, problemtagId: tag.tagId })),
)
}
return copy!
}
// ---------------------------------------------------------------- 公开题目
adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
@@ -432,30 +525,11 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
})
adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
const parsed = createProblemRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const checked = commonChecks(parsed.data)
if ("error" in checked)
return failure(c, 400, "invalid-problem", checked.error)
let sqlDisplay: SqlDisplay | null = null
if (checked.sql) {
const built = await generateSqlDisplay(
parsed.data.testCaseId,
parsed.data.answers,
parsed.data.sqlConfig!,
)
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
sqlDisplay = built.display
}
const parsed = await parseBody(c, createProblemRequestSchema)
if (!parsed.success) return parsed.response
const validated = await validateProblem(parsed.data)
if ("error" in validated)
return failure(c, 400, "invalid-problem", validated.error)
const [duplicate] = await db
.select({ id: schema.problem.id })
@@ -470,42 +544,17 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
if (duplicate)
return failure(c, 409, "display-id-exists", "Display ID already exists")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
const [row] = await tx
.insert(schema.problem)
.values({
...problemValues(parsed.data, checked.sql),
contestId: null,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
isPublic: false,
sqlDisplay,
})
.returning()
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
return row!
const created = await insertProblem(parsed.data, validated, {
contestId: null,
createdById: c.get("user")!.id,
})
return success(c, await serialize(created), 201)
})
adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateProblemRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, updateProblemRequestSchema)
if (!parsed.success) return parsed.response
const [existing] = await db
.select()
.from(schema.problem)
@@ -516,9 +565,9 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const checked = commonChecks(parsed.data)
if ("error" in checked)
return failure(c, 400, "invalid-problem", checked.error)
const validated = await validateProblem(parsed.data)
if ("error" in validated)
return failure(c, 400, "invalid-problem", validated.error)
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
const [duplicate] = await db
@@ -537,29 +586,17 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
if (duplicate)
return failure(c, 409, "display-id-exists", "Display ID already exists")
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
let sqlDisplay: SqlDisplay | null = null
if (checked.sql) {
const built = await generateSqlDisplay(
parsed.data.testCaseId,
parsed.data.answers,
parsed.data.sqlConfig!,
)
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
sqlDisplay = built.display
}
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(schema.problem)
.set({
...problemValues(parsed.data, checked.sql),
sqlDisplay,
...problemValues(parsed.data, validated.sql),
sqlDisplay: validated.sqlDisplay,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problem.id, id))
.returning()
await setTags(tx as unknown as typeof db, id, parsed.data.tags)
await setTags(tx, id, parsed.data.tags)
return row!
})
return success(c, await serialize(updated))
@@ -702,31 +739,11 @@ adminProblemRoutes.post(
) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const parsed = createProblemRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const checked = commonChecks(parsed.data)
if ("error" in checked)
return failure(c, 400, "invalid-problem", checked.error)
let sqlDisplay: SqlDisplay | null = null
if (checked.sql) {
const built = await generateSqlDisplay(
parsed.data.testCaseId,
parsed.data.answers,
parsed.data.sqlConfig!,
)
if ("error" in built)
return failure(c, 400, "invalid-problem", built.error)
sqlDisplay = built.display
}
const parsed = await parseBody(c, createProblemRequestSchema)
if (!parsed.success) return parsed.response
const validated = await validateProblem(parsed.data)
if ("error" in validated)
return failure(c, 400, "invalid-problem", validated.error)
const [duplicate] = await db
.select({ id: schema.problem.id })
@@ -741,27 +758,9 @@ adminProblemRoutes.post(
if (duplicate)
return failure(c, 409, "display-id-exists", "Duplicate Display id")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
const [row] = await tx
.insert(schema.problem)
.values({
...problemValues(parsed.data, checked.sql),
contestId,
createdById: user.id,
createTime: now,
lastUpdateTime: now,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
isPublic: false,
// 上面 generateSqlDisplay 已经把展示数据算好了,之前这里写死 null,
// 结果比赛里的 SQL 题打开后看不到示例数据表和期望结果(公开题那两条路径都是对的)
sqlDisplay,
})
.returning()
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
return row!
const created = await insertProblem(parsed.data, validated, {
contestId,
createdById: user.id,
})
return success(c, await serialize(created), 201)
},
@@ -774,12 +773,12 @@ adminProblemRoutes.post(
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = makeProblemPublicRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
makeProblemPublicRequestSchema,
"displayId 不能为空",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "displayId 不能为空")
if (!parsed.success) return parsed.response
const [problem] = await db
.select()
.from(schema.problem)
@@ -809,43 +808,19 @@ adminProblemRoutes.post(
if (duplicate)
return failure(c, 409, "display-id-exists", "Duplicate display ID")
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
// 原比赛题标记成「已转公开」,避免同一道题被转两次
await tx
.update(schema.problem)
.set({ isPublic: true })
.where(eq(schema.problem.id, id))
const { id: _old, ...rest } = problem
const [copy] = await tx
.insert(schema.problem)
.values({
...rest,
contestId: null,
displayId: parsed.data.displayId,
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
visible: false,
isPublic: true,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createTime: now,
lastUpdateTime: now,
})
.returning()
const tags = await tx
.select({ tagId: schema.problemTags.problemtagId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemId, id))
if (tags.length) {
await tx.insert(schema.problemTags).values(
tags.map((tag) => ({
problemId: copy!.id,
problemtagId: tag.tagId,
})),
)
}
return copy!
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
return copyProblem(tx, problem, {
contestId: null,
displayId: parsed.data.displayId,
visible: false,
isPublic: true,
})
})
return success(c, await serialize(created), 201)
},
@@ -856,17 +831,8 @@ adminProblemRoutes.post(
requireProblemPermission,
async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const parsed = addContestProblemRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, addContestProblemRequestSchema)
if (!parsed.success) return parsed.response
const [contest] = await db
.select()
.from(schema.contest)
@@ -917,38 +883,14 @@ adminProblemRoutes.post(
"Duplicate display id in this contest",
)
const now = new Date().toISOString()
const created = await db.transaction(async (tx) => {
const { id: _old, ...rest } = problem
const [copy] = await tx
.insert(schema.problem)
.values({
...rest,
contestId,
isPublic: true,
visible: true,
displayId: parsed.data.displayId,
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createTime: now,
lastUpdateTime: now,
})
.returning()
const tags = await tx
.select({ tagId: schema.problemTags.problemtagId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemId, problem.id))
if (tags.length) {
await tx.insert(schema.problemTags).values(
tags.map((tag) => ({
problemId: copy!.id,
problemtagId: tag.tagId,
})),
)
}
return copy!
})
const created = await db.transaction((tx) =>
copyProblem(tx, problem, {
contestId,
displayId: parsed.data.displayId,
visible: true,
isPublic: true,
}),
)
return success(c, await serialize(created), 201)
},
)
@@ -1055,17 +997,8 @@ adminProblemRoutes.post(
"/sql-test-cases/preview",
requireProblemPermission,
async (c) => {
const parsed = sqlPreviewRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, sqlPreviewRequestSchema)
if (!parsed.success) return parsed.response
const outcome = await buildSqlDisplay(
parsed.data.initSql,
parsed.data.refSql,
@@ -1082,17 +1015,8 @@ adminProblemRoutes.post(
"/sql-test-cases/generate",
requireProblemPermission,
async (c) => {
const parsed = generateSqlTestCaseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, generateSqlTestCaseRequestSchema)
if (!parsed.success) return parsed.response
try {
const sql = await completeChat(
`你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的
+21 -63
View File
@@ -29,7 +29,7 @@ import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { recalculateBadge, resyncProgress } from "../../services/problemset"
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
@@ -175,17 +175,8 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
})
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
const parsed = createProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, createProblemSetRequestSchema)
if (!parsed.success) return parsed.response
const now = new Date().toISOString()
const [created] = await db
.insert(schema.problemset)
@@ -211,17 +202,8 @@ adminProblemSetRoutes.get("/problem-sets/:id", requireTeacher, async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, updateProblemSetRequestSchema)
if (!parsed.success) return parsed.response
const [updated] = await db
.update(schema.problemset)
.set({
@@ -258,11 +240,12 @@ adminProblemSetRoutes.put(
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetStatusRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
updateProblemSetStatusRequestSchema,
"status 不合法",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "status 不合法")
if (!parsed.success) return parsed.response
const [updated] = await db
.update(schema.problemset)
.set({
@@ -331,17 +314,8 @@ adminProblemSetRoutes.post(
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = addProblemToSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, addProblemToSetRequestSchema)
if (!parsed.success) return parsed.response
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
@@ -394,10 +368,12 @@ adminProblemSetRoutes.put(
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemInSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
updateProblemInSetRequestSchema,
"参数错误",
)
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
if (!parsed.success) return parsed.response
const updated = await db
.update(schema.problemsetProblem)
.set(parsed.data)
@@ -512,17 +488,8 @@ adminProblemSetRoutes.post(
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = createProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response
const [created] = await db
.insert(schema.problemsetBadge)
.values({
@@ -542,17 +509,8 @@ adminProblemSetRoutes.put(
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response
const [updated] = await db
.update(schema.problemsetBadge)
.set(parsed.data)
+11 -22
View File
@@ -33,7 +33,7 @@ import {
} from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { JudgeStatus } from "../../judge/status"
import { completeChat } from "../../services/ai"
import { localTime, localYear } from "../../time"
@@ -82,11 +82,8 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = renameTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "标签名不能为空")
const parsed = await parseBody(c, renameTagRequestSchema, "标签名不能为空")
if (!parsed.success) return parsed.response
const name = parsed.data.name
const [tag] = await db
@@ -177,17 +174,8 @@ adminTagRoutes.post(
"/problems/batch-tag",
requireProblemPermission,
async (c) => {
const parsed = batchProblemTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const parsed = await parseBody(c, batchProblemTagRequestSchema)
if (!parsed.success) return parsed.response
const user = c.get("user")!
const filters = [
inArray(schema.problem.id, parsed.data.problemIds),
@@ -206,7 +194,7 @@ adminTagRoutes.post(
const wanted = normalizeTagNames(parsed.data.tagNames)
const tagIds = await db.transaction(async (tx) => {
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
const existing = await findTagsByName(tx, wanted)
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
if (parsed.data.action === "add") {
const missing = wanted.filter(
@@ -462,11 +450,12 @@ adminTagRoutes.post(
"/problems/flowchart",
requireProblemPermission,
async (c) => {
const parsed = generateFlowchartRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
generateFlowchartRequestSchema,
"python 代码不能为空",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "python 代码不能为空")
if (!parsed.success) return parsed.response
try {
const flowchart = await completeChat(
`你是一个可以将Python代码转换为mermaid的助手。
+14 -49
View File
@@ -13,7 +13,7 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { failure, parseBody, success } from "../../http"
import { exerciseDataError } from "../../services/exercise"
import { objectValue, queryInteger, sampleUser } from "../helpers"
@@ -72,17 +72,8 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
})
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
const parsed = createTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, createTutorialRequestSchema)
if (!parsed.success) return parsed.response
const now = new Date().toISOString()
const [created] = await db
.insert(schema.tutorial)
@@ -108,17 +99,8 @@ adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateTutorialRequestSchema)
if (!parsed.success) return parsed.response
const updated = await db
.update(schema.tutorial)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
@@ -135,11 +117,12 @@ adminTutorialRoutes.put(
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setTutorialVisibilityRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
setTutorialVisibilityRequestSchema,
"isPublic is required",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isPublic is required")
if (!parsed.success) return parsed.response
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
const updated = await db
.update(schema.tutorial)
@@ -199,17 +182,8 @@ adminTutorialRoutes.get(
)
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
const parsed = createExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, createExerciseRequestSchema)
if (!parsed.success) return parsed.response
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
@@ -233,17 +207,8 @@ adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
})
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
const parsed = updateExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const parsed = await parseBody(c, updateExerciseRequestSchema)
if (!parsed.success) return parsed.response
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [updated] = await db
+30 -594
View File
@@ -7,18 +7,12 @@ import {
HINT_MIN_FAILURES,
type AiAnalysisRecord,
type AiHintDone,
type AiDetail,
type DurationData,
type HintDiagnosis,
type Grade,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type SolvedProblem,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
eq,
@@ -26,19 +20,24 @@ import {
inArray,
isNull,
lte,
min,
sql,
} from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session"
import { getPreviousLogin } from "../auth/session"
import { config } from "../config"
import { db, schema } from "../db"
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { failure, parseBody, readJson, success } from "../http"
import { completeChat, streamChat, streamWhole } from "../services/ai"
import { generateFilteredHint } from "../services/hint-filter"
import {
accepted,
buildDetail,
buildDuration,
listSolved,
} from "../services/learning-stats"
import { decideHintLevel } from "../services/hint-level"
import {
hintDiagnosis,
@@ -52,28 +51,16 @@ import {
dayText,
localTime,
localWeekday,
shiftMonthsByCalendar,
} from "../time"
import {
countFailedSubmissions,
isTeacherOrAbove,
objectValue,
queryInteger,
rounded,
} from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
const accepted: JudgeStatusValue[] = [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]
const difficultyNames: Record<string, string> = {
Low: "简单",
Mid: "中等",
High: "困难",
}
/**
* 每次 AI 调用都过一遍令牌桶,复用 services/throttling 的那只桶(capacity 20 / 0.03 每秒)。
* key 与代码提交的 `throttling:user:<id>`、流程图评分的 `flowchart:<id>` 分开计数 ——
@@ -94,29 +81,6 @@ async function throttleAi(c: Context<AppEnv>) {
)
}
function grade(rank: number | null, count: number, reference = count): Grade {
if (!rank || count <= 0) return "C"
const percentile = ((rank - 1) / count) * 100
let value: Grade =
percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
return value
}
function averageGrade(grades: Grade[]): Grade {
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
const values = grades.flatMap((item) => weights[item] ?? [])
if (!values.length) return ""
const average = values.reduce((sum, value) => sum + value, 0) / values.length
return average >= 3.5
? "S"
: average >= 2.5
? "A"
: average >= 1.5
? "B"
: "C"
}
async function targetUser(c: Context<AppEnv>, override?: string) {
const current = c.get("user")!
const username = override ?? c.req.query("username")
@@ -137,378 +101,6 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
return target ?? null
}
type FirstAcRow = { problemId: number; first: string | null }
/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */
function firstAcQuery(
user: AuthUser,
start: string,
end: string,
limit?: number,
offset?: number,
) {
const first = min(schema.submission.createTime)
const query = db
.select({ problemId: schema.submission.problemId, first })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(schema.submission.problemId)
.orderBy(asc(first))
return limit === undefined ? query : query.limit(limit).offset(offset ?? 0)
}
/**
* 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。
* 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。
*/
async function buildSolved(
user: AuthUser,
start: string,
end: string,
firstAc: FirstAcRow[],
) {
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
solved: [],
problems: [] as {
problem: typeof schema.problem.$inferSelect
contestTitle: string | null
}[],
scopeIds: null as number[] | null,
}
const classUsers = user.className
? await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds =
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const [problems, rankRows, periodRows, attemptRows] = await Promise.all([
db
.select({ problem: schema.problem, contestTitle: schema.contest.title })
.from(schema.problem)
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id))
.where(inArray(schema.problem.id, problemIds)),
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
first: min(schema.submission.createTime),
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, problemIds),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
first: min(schema.submission.createTime),
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, problemIds),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, problemIds),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
),
])
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
// 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交
const firstAcTime = new Map(
firstAc.flatMap((item) =>
item.first ? ([[item.problemId, Date.parse(item.first)]] as const) : [],
),
)
const attemptsByProblem = new Map<number, number>()
for (const row of attemptRows) {
const deadline = firstAcTime.get(row.problemId)
if (deadline === undefined || Date.parse(row.time) > deadline) continue
attemptsByProblem.set(
row.problemId,
(attemptsByProblem.get(row.problemId) ?? 0) + 1,
)
}
function ranks(rows: typeof rankRows, problemId: number) {
return rows
.filter((item) => item.problemId === problemId)
.sort(
(a, b) =>
Date.parse(a.first ?? "") - Date.parse(b.first ?? "") ||
a.userId - b.userId,
)
}
const solved = firstAc
.flatMap((item) => {
const problem = byProblem.get(item.problemId)
if (!problem || !item.first) return []
const all = ranks(rankRows, item.problemId)
const period = ranks(periodRows, item.problemId)
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
const periodRank =
period.findIndex((row) => row.userId === user.id) + 1 || null
return {
problem: {
title: problem.problem.title,
displayId: problem.problem.displayId,
contestTitle: problem.contestTitle ?? "",
contestId: problem.problem.contestId,
},
acTime: item.first,
rank,
acCount: all.length,
grade: grade(periodRank, period.length, all.length),
periodRank,
periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
attempts: attemptsByProblem.get(item.problemId) ?? 1,
} satisfies SolvedProblem
})
.sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
return { solved, problems, scopeIds }
}
/** 分页版:只算这一页的题 */
async function listSolved(
user: AuthUser,
start: string,
end: string,
limit: number,
offset: number,
) {
const [firstAc, totalRows] = await Promise.all([
firstAcQuery(user, start, end, limit, offset),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
),
])
const { solved } = await buildSolved(user, start, end, firstAc)
return {
results: solved,
total: totalRows[0]?.value ?? 0,
} satisfies SolvedList
}
async function buildDetail(user: AuthUser, start: string, end: string) {
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
// 星期和小时都按东八区取,和热力图同口径
const weekday =
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(
Number,
)
const period =
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
Number,
)
const activityRows = await db
.select({ weekday, period, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(weekday, period)
const activity = activityRows.map((row) => ({
weekday: row.weekday,
period: row.period,
count: row.value,
}))
// 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。
// 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义
const submissions = await db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
const settledFail = (result: JudgeStatusValue) =>
!accepted.includes(result) &&
result !== JudgeStatus.PENDING &&
result !== JudgeStatus.JUDGING
const errorCounts = new Map<number, number>()
for (const row of submissions) {
if (!settledFail(row.result)) continue
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
}
const errors = [...errorCounts]
.map(([result, count]) => ({ result, count }))
.sort((a, b) => b.count - a.count || a.result - b.result)
const firstAc = await firstAcQuery(user, start, end)
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
user: user.username,
className: user.className,
start,
end,
solvedCount: 0,
attempts: [],
flowcharts: [],
grade: "",
tags: {},
difficulty: {},
contestCount: 0,
activity,
errors,
rankScope: "global",
} satisfies AiDetail
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all(
[
buildSolved(user, start, end, firstAc),
db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(inArray(schema.problemTags.problemId, problemIds)),
db
.select({
flow: schema.flowchartSubmission,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
eq(schema.flowchartSubmission.status, 2),
gte(schema.flowchartSubmission.createTime, start),
lte(schema.flowchartSubmission.createTime, end),
),
),
],
)
const tags: Record<string, number> = {}
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
const topTags = Object.fromEntries(
Object.entries(tags)
.sort((a, b) => b[1] - a[1])
.slice(0, 5),
)
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
for (const item of problems) {
const name = difficultyNames[item.problem.difficulty] ?? "中等"
difficulty[name] = (difficulty[name] ?? 0) + 1
}
const flowGroups = new Map<string, typeof flowRows>()
for (const flow of flowRows)
flowGroups.set(flow.displayId, [
...(flowGroups.get(flow.displayId) ?? []),
flow,
])
const flowcharts = [...flowGroups]
.map(([displayId, rows]) => {
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
const top = rows.reduce(
(best, row) =>
(row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best,
rows[0]!,
)
return {
problemId: displayId,
problemTitle: rows[0]?.title ?? "",
submissionCount: rows.length,
bestScore: Math.max(0, top.flow.aiScore ?? 0),
bestGrade: top.flow.aiGrade ?? "",
latestSubmissionTime:
rows
.map((row) => row.flow.createTime)
.sort()
.at(-1) ?? start,
avgScore: rounded(
scores.length
? scores.reduce((sum, value) => sum + value, 0) / scores.length
: 0,
0,
),
}
})
.sort((a, b) =>
b.latestSubmissionTime.localeCompare(a.latestSubmissionTime),
)
return {
user: user.username,
className: user.className,
start,
end,
flowcharts,
solvedCount: solved.length,
attempts: solved.map((item) => item.attempts),
grade: averageGrade(solved.map((item) => item.grade)),
tags: topTags,
difficulty,
contestCount: new Set(
solved.flatMap((item) => item.problem.contestId ?? []),
).size,
activity,
errors,
rankScope: scopeIds ? "class" : "global",
} satisfies AiDetail
}
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
@@ -553,154 +145,6 @@ aiRoutes.get("/ai/solved", requireAuth, async (c) => {
return success(c, await listSolved(user, start, end, limit, offset))
})
async function buildDuration(
user: AuthUser,
endText: string,
duration: string,
) {
const config =
duration === "months:2"
? {
count: 8,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
: duration === "months:6"
? {
count: 6,
unit: "months",
rewind: (date: Date) => shiftMonthsByCalendar(date, -7),
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
}
: duration === "years:1"
? {
count: 12,
unit: "months",
rewind: (date: Date) => shiftMonthsByCalendar(date, -13),
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
}
: {
count: 4,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
let cursor = config.rewind(new Date(endText))
const buckets: { start: Date; end: Date }[] = []
for (let index = 0; index < config.count; index++) {
const start = config.advance(cursor)
buckets.push({ start, end: config.advance(start) })
cursor = start
}
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
const rows = await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
),
)
// 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里
// 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
// 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。
const solvedIds = [
...new Set(
rows
.filter((row) => accepted.includes(row.result))
.map((row) => row.problemId),
),
]
const classUsers = user.className
? await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds =
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const peers = solvedIds.length
? await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
userId: schema.submission.userId,
problemId: schema.submission.problemId,
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, solvedIds),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(
schema.submission.createTime,
buckets.at(-1)!.end.toISOString(),
),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
: []
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题
const peersByProblem = new Map<number, typeof peers>()
for (const row of peers)
peersByProblem.set(row.problemId, [
...(peersByProblem.get(row.problemId) ?? []),
row,
])
function bucketGrade(problemIds: number[], from: number, to: number) {
return averageGrade(
problemIds.map((problemId) => {
const firstAc = new Map<number, number>()
for (const row of peersByProblem.get(problemId) ?? []) {
if (row.time < from || row.time > to) continue
const seen = firstAc.get(row.userId)
if (seen === undefined || row.time < seen)
firstAc.set(row.userId, row.time)
}
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0])
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null
return grade(rank, ordered.length)
}),
)
}
return buckets.map((bucket, index) => {
const from = bucket.start.getTime()
const to = bucket.end.getTime()
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
return {
unit: config.unit,
index: config.count - 1 - index,
start: bucket.start.toISOString(),
end: bucket.end.toISOString(),
grade: solved.length ? bucketGrade(solved, from, to) : "",
problemCount: solved.length,
acceptedCount: acceptedRows.length,
submissionCount: inRange.length,
} satisfies DurationData
})
}
aiRoutes.get("/ai/duration", requireAuth, async (c) => {
const endText = c.req.query("end")
if (!endText || Number.isNaN(Date.parse(endText)))
@@ -882,16 +326,12 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
})
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = aiAnalysisRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
aiAnalysisRequestSchema,
"start, end and duration are required",
)
if (!parsed.success)
return failure(
c,
400,
"invalid-request",
"start, end and duration are required",
)
if (!parsed.success) return parsed.response
if (
Number.isNaN(Date.parse(parsed.data.start)) ||
Number.isNaN(Date.parse(parsed.data.end))
@@ -983,11 +423,12 @@ async function recordHint(
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = aiHintRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
aiHintRequestSchema,
"submissionId is required",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "submissionId is required")
if (!parsed.success) return parsed.response
const [row] = await db
.select({ submission: schema.submission, problem: schema.problem })
.from(schema.submission)
@@ -1081,9 +522,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = aiHintFeedbackRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = await readJson(c, aiHintFeedbackRequestSchema)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
@@ -1114,11 +553,12 @@ aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user")))
return failure(c, 403, "permission-denied", "Permission denied")
const parsed = classAnalysisRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
classAnalysisRequestSchema,
"Class data is required",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Class data is required")
if (!parsed.success) return parsed.response
const limited = await throttleAi(c)
if (limited) return limited
return streamChat(
@@ -1130,16 +570,12 @@ aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user")))
return failure(c, 403, "permission-denied", "Permission denied")
const parsed = classPkAnalysisRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
classPkAnalysisRequestSchema,
"At least two classes are required",
)
if (!parsed.success)
return failure(
c,
400,
"invalid-request",
"At least two classes are required",
)
if (!parsed.success) return parsed.response
const limited = await throttleAi(c)
if (limited) return limited
return streamChat(
+50 -23
View File
@@ -7,21 +7,61 @@ import { createSession, destroySession } from "../auth/session"
import { publishSessionRevoked } from "../events"
import { hashPassword, verifyPassword } from "../auth/password"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { getUserProfileById } from "../services/profile"
import {
clientIp,
countAttempt,
lockoutRemaining,
resetAttempts,
type AttemptRule,
} from "../services/throttling"
export const authRoutes = new Hono<AppEnv>()
/**
* 登录防爆破,两道都只数失败:
*
* - **按用户名**:盯着一个账号猜。学生的初始密码弱、`/classes/:name/usernames` 又能
* 公开列出整班用户名,这是最现实的攻击。15 分钟 10 次,登录成功清零。
* 代价是别人能故意输错把某个学生锁 15 分钟 —— 比被猜中密码好得多。
* - **按 IP**:换着用户名猜(每个号只试几次常见密码,绕开上一道)。机房一个班共用一个
* 出口 IP,所以放得很宽,而且成功**不清零**,否则攻击者拿自己的号登一次就能重置。
*/
const LOGIN_PER_USERNAME: AttemptRule = { limit: 10, windowSeconds: 15 * 60 }
const LOGIN_PER_IP: AttemptRule = { limit: 100, windowSeconds: 15 * 60 }
authRoutes.post("/auth/login", async (c) => {
const parsed = loginRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
loginRequestSchema,
"Username and password are required",
)
if (!parsed.success) {
if (!parsed.success) return parsed.response
const usernameKey = `login:user:${parsed.data.username.toLowerCase()}`
const ipKey = `login:ip:${clientIp(c)}`
const wait =
(await lockoutRemaining(usernameKey, LOGIN_PER_USERNAME)) ??
(await lockoutRemaining(ipKey, LOGIN_PER_IP))
if (wait !== null) {
return failure(
c,
400,
"invalid-request",
"Username and password are required",
429,
"too-many-login-attempts",
`Too many failed attempts, please wait ${wait} seconds`,
)
}
const loginFailed = async () => {
await Promise.all([
countAttempt(usernameKey, LOGIN_PER_USERNAME),
countAttempt(ipKey, LOGIN_PER_IP),
])
return failure(
c,
401,
"invalid-credentials",
"Invalid username or password",
)
}
@@ -31,27 +71,14 @@ authRoutes.post("/auth/login", async (c) => {
.where(sql`lower(${schema.user.username}) = lower(${parsed.data.username})`)
.limit(1)
if (!user) {
return failure(
c,
401,
"invalid-credentials",
"Invalid username or password",
)
}
if (!user) return loginFailed()
if (user.isDisabled) {
return failure(c, 403, "account-disabled", "Your account has been disabled")
}
const password = await verifyPassword(parsed.data.password, user.password)
if (!password.valid) {
return failure(
c,
401,
"invalid-credentials",
"Invalid username or password",
)
}
if (!password.valid) return loginFailed()
await resetAttempts(usernameKey)
const now = new Date().toISOString()
const update: { lastLogin: string; password?: string } = { lastLogin: now }
+6 -5
View File
@@ -11,7 +11,7 @@ import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { queryInteger, rounded } from "./helpers"
@@ -165,11 +165,12 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
})
classroomRoutes.post("/classes/comparison", async (c) => {
const parsed = classComparisonRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
classComparisonRequestSchema,
"At least one class is required",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "At least one class is required")
if (!parsed.success) return parsed.response
const users = await loadClassUsers(parsed.data.classNames)
const allAc = users.map((user) => user.acceptedNumber)
const globalQ1 = quantile(allAc, 0.25)
+21 -18
View File
@@ -1,6 +1,5 @@
import {
createMessageRequestSchema,
embeddedSubmissionSchema,
exerciseAttemptRequestSchema,
reactionKeySchema,
setReactionRequestSchema,
@@ -23,7 +22,7 @@ import { Hono } from "hono"
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { objectValue, queryInteger, sampleUser } from "./helpers"
@@ -187,11 +186,12 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
*/
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
const user = c.get("user")!
const parsed = createMessageRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
createMessageRequestSchema,
"Invalid message payload",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid message payload")
if (!parsed.success) return parsed.response
if (parsed.data.recipientId === user.id)
return failure(
c,
@@ -263,11 +263,12 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setReactionRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
setReactionRequestSchema,
"Invalid reaction",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid reaction")
if (!parsed.success) return parsed.response
const user = c.get("user")!
const [[problem], [solved]] = await Promise.all([
db
@@ -447,11 +448,12 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = tutorialProgressPingSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
tutorialProgressPingSchema,
"Invalid progress payload",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid progress payload")
if (!parsed.success) return parsed.response
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
@@ -501,11 +503,12 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = exerciseAttemptRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
exerciseAttemptRequestSchema,
"Invalid attempt payload",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid attempt payload")
if (!parsed.success) return parsed.response
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
const [exercise] = await db
.select({ id: schema.exercise.id })
+26 -5
View File
@@ -27,7 +27,7 @@ import { optionalAuth, requireAuth } from "../auth/middleware"
import { setContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import {
canAccessContest,
checkContestPassword,
@@ -38,6 +38,11 @@ import {
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import {
countAttempt,
lockoutRemaining,
type AttemptRule,
} from "../services/throttling"
import {
objectValue,
publicTemplates,
@@ -149,6 +154,8 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
)
})
const CONTEST_PASSWORD_RULE: AttemptRule = { limit: 10, windowSeconds: 10 * 60 }
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
const contest = await findAccessibleContest(
c.get("user"),
@@ -156,12 +163,26 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = contestPasswordRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
contestPasswordRequestSchema,
"Password is required",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Password is required")
if (!parsed.success) return parsed.response
// 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。
// 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。
const attemptKey = `contest-password:${contest.id}:${c.get("user")!.id}`
const wait = await lockoutRemaining(attemptKey, CONTEST_PASSWORD_RULE)
if (wait !== null) {
return failure(
c,
429,
"too-many-password-attempts",
`Too many wrong passwords, please wait ${wait} seconds`,
)
}
if (!checkContestPassword(parsed.data.password, contest.password)) {
await countAttempt(attemptKey, CONTEST_PASSWORD_RULE)
return failure(
c,
403,
+4 -6
View File
@@ -26,7 +26,7 @@ import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, readJson, success } from "../http"
import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
@@ -85,9 +85,7 @@ function flowchartData(
}
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
const parsed = createFlowchartRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = await readJson(c, createFlowchartRequestSchema)
if (
!parsed.success ||
JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024
@@ -149,7 +147,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
})
try {
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
} catch (error) {
} catch {
await db
.update(schema.flowchartSubmission)
.set({ status: 3 })
@@ -644,7 +642,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
{ submissionId: row.flowchart.id },
{ jobId: `${row.flowchart.id}:retry:${Date.now()}` },
)
} catch (error) {
} catch {
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
await db
.update(schema.flowchartSubmission)
+2 -2
View File
@@ -6,7 +6,7 @@ import { z } from "zod"
import { config } from "../config"
import { db, schema } from "../db"
import { failure } from "../http"
import { failure, readJson } from "../http"
const heartbeatSchema = z.object({
hostname: z.string().min(1).max(128),
@@ -38,7 +38,7 @@ async function heartbeat(c: Context) {
return failure(c, 403, "invalid-judge-token", "Invalid token")
}
const parsed = heartbeatSchema.safeParse(await c.req.json().catch(() => null))
const parsed = await readJson(c, heartbeatSchema)
if (!parsed.success) {
return failure(c, 400, "invalid-heartbeat", "Invalid heartbeat payload")
}
+6 -5
View File
@@ -31,7 +31,7 @@ import {
type AppEnv,
} from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { computeProgress } from "../services/problemset"
import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers"
@@ -342,11 +342,12 @@ async function recomputeProgress(
}
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
const parsed = joinProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
joinProblemSetRequestSchema,
"Invalid problem set",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid problem set")
if (!parsed.success) return parsed.response
const user = c.get("user")!
const [problemSet] = await db
.select({ id: schema.problemset.id })
@@ -26,7 +26,6 @@ import {
import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import {
+11 -10
View File
@@ -31,7 +31,7 @@ import {
} from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { judgeQueue } from "../queue"
import {
@@ -93,12 +93,12 @@ async function saveTrace(
}
submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
createSubmissionRequestSchema,
"Invalid submission payload",
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", "Invalid submission payload")
}
if (!parsed.success) return parsed.response
let contestId: number | null = null
if (parsed.data.contestId) {
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
@@ -293,11 +293,12 @@ submissionRoutes.post(
)
submissionRoutes.post("/code/format", requireAuth, async (c) => {
const parsed = formatCodeRequestSchema.safeParse(
await c.req.json().catch(() => null),
const parsed = await parseBody(
c,
formatCodeRequestSchema,
"Invalid format payload",
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid format payload")
if (!parsed.success) return parsed.response
try {
const code = await formatCode(parsed.data.code, parsed.data.language)
return success(c, { code } satisfies FormatCodeResponse)
+68 -13
View File
@@ -19,11 +19,29 @@ function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
/**
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
* 客户端断开本来就能收尾。
* 学生那边的按钮也就一直转。
*/
const COMPLETE_TIMEOUT_MS = 60_000
/**
* 流式调用的**空闲**超时:连续这么久没收到一个字节就放弃。不用总时长超时 —— 那会把
* 正在推的长回答直接掐断;空闲超时每收到一块就重新计时,只拦「provider 卡住不动」。
* 首字节也算在内(DeepSeek 首 token 通常几秒,给足余量)。
*/
const STREAM_IDLE_TIMEOUT_MS = 45_000
/**
* 推给前端的错误文案。真实原因(provider 的 HTTP 响应体、数据库报错……)只进日志和
* `onError` 留痕,**不下发**:学生看不懂,也不该看到上游的响应内容。
*/
const CLIENT_ERROR_MESSAGE = "AI 服务暂时不可用,请稍后再试"
const MISSING_KEY_MESSAGE = "AI 功能未开启"
/** 给客户端的错误事件;原因另行记日志 */
function errorEvent(message: string) {
return `data: ${JSON.stringify({ type: "error", message })}\n\n`
}
export async function completeChat(
system: string,
user: string,
@@ -81,23 +99,42 @@ export function streamChat(
hooks.onError?.(message).catch((error) => {
console.error("streamChat onError hook failed", error)
})
// 客户端断开(cancel)和空闲超时都走这一个 controller,把上游的 fetch / read 一起停掉。
// 原来客户端走了之后这里还在把 provider 的回答一块块读完,白烧 token
const upstream = new AbortController()
let closed = false
let idleTimer: ReturnType<typeof setTimeout> | undefined
const touch = () => {
clearTimeout(idleTimer)
idleTimer = setTimeout(
() => upstream.abort(new Error("AI provider stream idle timeout")),
STREAM_IDLE_TIMEOUT_MS,
)
}
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value))
const send = (value: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(value))
} catch {
closed = true
}
}
if (!config.aiKey) {
await reportError("缺少 AI_KEY")
send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
)
send(errorEvent(MISSING_KEY_MESSAGE))
send("event: end\n\n")
controller.close()
if (!closed) controller.close()
return
}
try {
touch()
const response = await fetch(
new URL("/chat/completions", config.aiBaseUrl),
{
method: "POST",
signal: upstream.signal,
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
@@ -123,6 +160,7 @@ export function streamChat(
let buffer = ""
const chunks: string[] = []
while (true) {
touch()
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split("\n")
@@ -157,15 +195,25 @@ export function streamChat(
: undefined
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
// 客户端主动走掉不算 AI 失败,但照样留痕:这条分析没有落库
const message = closed
? "客户端已断开"
: error instanceof Error
? error.message
: String(error)
if (!closed) console.error("streamChat failed", error)
await reportError(message)
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
send(errorEvent(CLIENT_ERROR_MESSAGE))
} finally {
clearTimeout(idleTimer)
send("event: end\n\n")
controller.close()
if (!closed) controller.close()
}
},
cancel() {
closed = true
upstream.abort(new Error("client disconnected"))
},
})
return new Response(body, {
headers: {
@@ -222,11 +270,18 @@ export function streamWhole(
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// 先留痕再回前端
console.error("streamWhole failed", error)
// 先留痕再回前端;前端只拿固定文案,原因见 CLIENT_ERROR_MESSAGE
await hooks.onError?.(message).catch((e) => {
console.error("streamWhole onError hook failed", e)
})
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
send(
errorEvent(
message === "缺少 AI_KEY"
? MISSING_KEY_MESSAGE
: CLIENT_ERROR_MESSAGE,
),
)
} finally {
clearInterval(heartbeat)
send("event: end\n\n")
+21
View File
@@ -0,0 +1,21 @@
/**
* 按文件头认图片格式,不信文件名。头像和题面插图原来只看扩展名:改个后缀什么都能
* 落盘,再由 /public/* 按扩展名配 content-type 发出去。
*
* 返回值就是落盘该用的扩展名 —— 以内容为准,一张改名成 .png 的 JPEG 会存成 .jpg,
* 浏览器拿到的 content-type 才对得上。
*/
const SIGNATURES: [extension: string, bytes: number[]][] = [
[".png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
[".jpg", [0xff, 0xd8, 0xff]],
[".gif", [0x47, 0x49, 0x46, 0x38]], // GIF8(GIF87a / GIF89a)
[".bmp", [0x42, 0x4d]], // BM
]
export async function sniffImageExtension(file: Blob) {
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer())
for (const [extension, bytes] of SIGNATURES) {
if (bytes.every((byte, index) => head[index] === byte)) return extension
}
return null
}
+583
View File
@@ -0,0 +1,583 @@
/**
* 学情统计:AI 学情分析页(/ai/detail、/ai/duration、/ai/solved)和喂给模型的
* 数据都从这里算。原来整段放在 routes/ai.ts 里,占了那个文件六成;它们只是查询,
* 不碰 Context,挪出来 route 文件只剩「鉴权 → 取参 → 调这里 → 回包」。
*/
import {
type AiDetail,
type DurationData,
type Grade,
type SolvedList,
type SolvedProblem,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
eq,
gte,
inArray,
lte,
min,
sql,
} from "drizzle-orm"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
import { rounded } from "../routes/helpers"
import { localTime, shiftMonthsByCalendar } from "../time"
export const accepted: JudgeStatusValue[] = [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]
const difficultyNames: Record<string, string> = {
Low: "简单",
Mid: "中等",
High: "困难",
}
function grade(rank: number | null, count: number, reference = count): Grade {
if (!rank || count <= 0) return "C"
const percentile = ((rank - 1) / count) * 100
let value: Grade =
percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
return value
}
function averageGrade(grades: Grade[]): Grade {
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
const values = grades.flatMap((item) => weights[item] ?? [])
if (!values.length) return ""
const average = values.reduce((sum, value) => sum + value, 0) / values.length
return average >= 3.5
? "S"
: average >= 2.5
? "A"
: average >= 1.5
? "B"
: "C"
}
type FirstAcRow = { problemId: number; first: string | null }
/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */
function firstAcQuery(
user: AuthUser,
start: string,
end: string,
limit?: number,
offset?: number,
) {
const first = min(schema.submission.createTime)
const query = db
.select({ problemId: schema.submission.problemId, first })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(schema.submission.problemId)
.orderBy(asc(first))
return limit === undefined ? query : query.limit(limit).offset(offset ?? 0)
}
/**
* 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。
* 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。
*/
export async function buildSolved(
user: AuthUser,
start: string,
end: string,
firstAc: FirstAcRow[],
) {
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
solved: [],
problems: [] as {
problem: typeof schema.problem.$inferSelect
contestTitle: string | null
}[],
scopeIds: null as number[] | null,
}
const classUsers = user.className
? await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds =
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const [problems, rankRows, periodRows, attemptRows] = await Promise.all([
db
.select({ problem: schema.problem, contestTitle: schema.contest.title })
.from(schema.problem)
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id))
.where(inArray(schema.problem.id, problemIds)),
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
first: min(schema.submission.createTime),
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, problemIds),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
first: min(schema.submission.createTime),
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, problemIds),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, problemIds),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
),
])
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
// 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交
const firstAcTime = new Map(
firstAc.flatMap((item) =>
item.first ? ([[item.problemId, Date.parse(item.first)]] as const) : [],
),
)
const attemptsByProblem = new Map<number, number>()
for (const row of attemptRows) {
const deadline = firstAcTime.get(row.problemId)
if (deadline === undefined || Date.parse(row.time) > deadline) continue
attemptsByProblem.set(
row.problemId,
(attemptsByProblem.get(row.problemId) ?? 0) + 1,
)
}
function ranks(rows: typeof rankRows, problemId: number) {
return rows
.filter((item) => item.problemId === problemId)
.sort(
(a, b) =>
Date.parse(a.first ?? "") - Date.parse(b.first ?? "") ||
a.userId - b.userId,
)
}
const solved = firstAc
.flatMap((item) => {
const problem = byProblem.get(item.problemId)
if (!problem || !item.first) return []
const all = ranks(rankRows, item.problemId)
const period = ranks(periodRows, item.problemId)
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
const periodRank =
period.findIndex((row) => row.userId === user.id) + 1 || null
return {
problem: {
title: problem.problem.title,
displayId: problem.problem.displayId,
contestTitle: problem.contestTitle ?? "",
contestId: problem.problem.contestId,
},
acTime: item.first,
rank,
acCount: all.length,
grade: grade(periodRank, period.length, all.length),
periodRank,
periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
attempts: attemptsByProblem.get(item.problemId) ?? 1,
} satisfies SolvedProblem
})
.sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
return { solved, problems, scopeIds }
}
/** 分页版:只算这一页的题 */
export async function listSolved(
user: AuthUser,
start: string,
end: string,
limit: number,
offset: number,
) {
const [firstAc, totalRows] = await Promise.all([
firstAcQuery(user, start, end, limit, offset),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
),
])
const { solved } = await buildSolved(user, start, end, firstAc)
return {
results: solved,
total: totalRows[0]?.value ?? 0,
} satisfies SolvedList
}
export async function buildDetail(user: AuthUser, start: string, end: string) {
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
// 星期和小时都按东八区取,和热力图同口径
const weekday =
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(
Number,
)
const period =
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
Number,
)
const activityRows = await db
.select({ weekday, period, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
.groupBy(weekday, period)
const activity = activityRows.map((row) => ({
weekday: row.weekday,
period: row.period,
count: row.value,
}))
// 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。
// 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义
const submissions = await db
.select({
problemId: schema.submission.problemId,
time: schema.submission.createTime,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start),
lte(schema.submission.createTime, end),
),
)
const settledFail = (result: JudgeStatusValue) =>
!accepted.includes(result) &&
result !== JudgeStatus.PENDING &&
result !== JudgeStatus.JUDGING
const errorCounts = new Map<number, number>()
for (const row of submissions) {
if (!settledFail(row.result)) continue
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
}
const errors = [...errorCounts]
.map(([result, count]) => ({ result, count }))
.sort((a, b) => b.count - a.count || a.result - b.result)
const firstAc = await firstAcQuery(user, start, end)
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length)
return {
user: user.username,
className: user.className,
start,
end,
solvedCount: 0,
attempts: [],
flowcharts: [],
grade: "",
tags: {},
difficulty: {},
contestCount: 0,
activity,
errors,
rankScope: "global",
} satisfies AiDetail
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all(
[
buildSolved(user, start, end, firstAc),
db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(inArray(schema.problemTags.problemId, problemIds)),
db
.select({
flow: schema.flowchartSubmission,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
eq(schema.flowchartSubmission.status, 2),
gte(schema.flowchartSubmission.createTime, start),
lte(schema.flowchartSubmission.createTime, end),
),
),
],
)
const tags: Record<string, number> = {}
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
const topTags = Object.fromEntries(
Object.entries(tags)
.sort((a, b) => b[1] - a[1])
.slice(0, 5),
)
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
for (const item of problems) {
const name = difficultyNames[item.problem.difficulty] ?? "中等"
difficulty[name] = (difficulty[name] ?? 0) + 1
}
const flowGroups = new Map<string, typeof flowRows>()
for (const flow of flowRows)
flowGroups.set(flow.displayId, [
...(flowGroups.get(flow.displayId) ?? []),
flow,
])
const flowcharts = [...flowGroups]
.map(([displayId, rows]) => {
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
const top = rows.reduce(
(best, row) =>
(row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best,
rows[0]!,
)
return {
problemId: displayId,
problemTitle: rows[0]?.title ?? "",
submissionCount: rows.length,
bestScore: Math.max(0, top.flow.aiScore ?? 0),
bestGrade: top.flow.aiGrade ?? "",
latestSubmissionTime:
rows
.map((row) => row.flow.createTime)
.sort()
.at(-1) ?? start,
avgScore: rounded(
scores.length
? scores.reduce((sum, value) => sum + value, 0) / scores.length
: 0,
0,
),
}
})
.sort((a, b) =>
b.latestSubmissionTime.localeCompare(a.latestSubmissionTime),
)
return {
user: user.username,
className: user.className,
start,
end,
flowcharts,
solvedCount: solved.length,
attempts: solved.map((item) => item.attempts),
grade: averageGrade(solved.map((item) => item.grade)),
tags: topTags,
difficulty,
contestCount: new Set(
solved.flatMap((item) => item.problem.contestId ?? []),
).size,
activity,
errors,
rankScope: scopeIds ? "class" : "global",
} satisfies AiDetail
}
export async function buildDuration(
user: AuthUser,
endText: string,
duration: string,
) {
const config =
duration === "months:2"
? {
count: 8,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
: duration === "months:6"
? {
count: 6,
unit: "months",
rewind: (date: Date) => shiftMonthsByCalendar(date, -7),
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
}
: duration === "years:1"
? {
count: 12,
unit: "months",
rewind: (date: Date) => shiftMonthsByCalendar(date, -13),
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
}
: {
count: 4,
unit: "weeks",
rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5),
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
}
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
let cursor = config.rewind(new Date(endText))
const buckets: { start: Date; end: Date }[] = []
for (let index = 0; index < config.count; index++) {
const start = config.advance(cursor)
buckets.push({ start, end: config.advance(start) })
cursor = start
}
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
const rows = await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
),
)
// 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里
// 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
// 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。
const solvedIds = [
...new Set(
rows
.filter((row) => accepted.includes(row.result))
.map((row) => row.problemId),
),
]
const classUsers = user.className
? await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, user.className))
: []
const scopeIds =
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const peers = solvedIds.length
? await db
.select({
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
Number,
),
userId: schema.submission.userId,
problemId: schema.submission.problemId,
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.result, accepted),
inArray(schema.submission.problemId, solvedIds),
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
lte(
schema.submission.createTime,
buckets.at(-1)!.end.toISOString(),
),
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
),
)
: []
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题
const peersByProblem = new Map<number, typeof peers>()
for (const row of peers)
peersByProblem.set(row.problemId, [
...(peersByProblem.get(row.problemId) ?? []),
row,
])
function bucketGrade(problemIds: number[], from: number, to: number) {
return averageGrade(
problemIds.map((problemId) => {
const firstAc = new Map<number, number>()
for (const row of peersByProblem.get(problemId) ?? []) {
if (row.time < from || row.time > to) continue
const seen = firstAc.get(row.userId)
if (seen === undefined || row.time < seen)
firstAc.set(row.userId, row.time)
}
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0])
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null
return grade(rank, ordered.length)
}),
)
}
return buckets.map((bucket, index) => {
const from = bucket.start.getTime()
const to = bucket.end.getTime()
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
return {
unit: config.unit,
index: config.count - 1 - index,
start: bucket.start.toISOString(),
end: bucket.end.toISOString(),
grade: solved.length ? bucketGrade(solved, from, to) : "",
problemCount: solved.length,
acceptedCount: acceptedRows.length,
submissionCount: inRange.length,
} satisfies DurationData
})
}
+40 -5
View File
@@ -77,6 +77,38 @@ function collectSqlScripts(names: Set<string>) {
return scripts
}
/**
* 解压**之前**按条目头拦大小。原来是先 `unzipSync` 整包解进内存、再逐个查大小 ——
* 一个几十 KB 的 zip bomb 在检查之前就能把 512MB 的容器撑爆。
*
* 条目头里的 `originalSize` 是压缩包自己声明的,可以造假,但 fflate 按这个值分配
* 输出缓冲、不会扩容(`inflateSync(..., { out: new u8(su) })`,解多了只会截断),
* 所以按它限额就是按实际内存限额。顺带只解需要的文件名,压缩包里别的东西一律不解。
*/
function wantedEntry(file: { name: string }) {
const match = /^(\d+)\.(in|out|sql)$/.exec(file.name)
return !!match && Number(match[1]) >= 1 && Number(match[1]) <= MAX_CASES
}
function entryFilter() {
let total = 0
return (file: { name: string; originalSize: number }) => {
if (!wantedEntry(file)) return false
if (file.originalSize > MAX_ENTRY_BYTES) {
throw new TestCaseError(
`测试点 ${file.name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`,
)
}
total += file.originalSize
if (total > MAX_TOTAL_BYTES) {
throw new TestCaseError(
`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`,
)
}
return true
}
}
export interface ProcessedTestCase {
testCaseId: string
info: TestCaseEntry[]
@@ -88,16 +120,19 @@ export async function processTestCaseZip(
): Promise<ProcessedTestCase> {
let files: Record<string, Uint8Array>
try {
files = unzipSync(archive)
} catch {
// 第一遍只读目录、一个字节都不解(filter 恒返回 false),超限就在这里抛;
// 第二遍才真解。否则 fflate 边过滤边解,超限之前已经解了上百 MB
const check = entryFilter()
unzipSync(archive, { filter: (file) => (check(file), false) })
files = unzipSync(archive, { filter: wantedEntry })
} catch (error) {
if (error instanceof TestCaseError) throw error
throw new TestCaseError("压缩包损坏或不是 zip 格式")
}
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
// 条目名一律不参与路径拼接,zip slip(`../../etc/passwd` 这类条目名)从设计上就进不来。
const names = new Set(
Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)),
)
const names = new Set(Object.keys(files))
const selected = options.sql
? collectSqlScripts(names)
+49
View File
@@ -1,3 +1,5 @@
import type { Context } from "hono"
import { redis } from "../redis"
import { getOptions } from "./options"
@@ -166,3 +168,50 @@ export async function consumeToken(
if (Number(result[0]) === 1) return { allowed: true }
return { allowed: false, wait: Number(result[1]) || 0 }
}
/**
* 计数锁定:固定窗口内计满 `limit` 次就锁到窗口结束。**什么时候计数由调用方决定**:
*
* - 登录、比赛密码只在**失败**时计数 —— 正常用户几乎不会连续输错,暴力猜解全是失败,
* 只数失败的话机房一个班同时登录也不会误伤;
* - 注册在**成功**时计数 —— 限的是批量造号。
*
* 和上面的令牌桶是两回事:令牌桶限的是资源消耗(判题沙箱、AI),带匀速回填;
* 这里是「窗口内满多少次就停」。用固定窗口而不是滑动窗口:一个 INCR + EXPIRE NX 就够,
* 锁定时长就是 key 的剩余 TTL,精度对防爆破足够了。
*/
export type AttemptRule = { limit: number; windowSeconds: number }
/** 已锁定时返回还要等几秒,否则 null。只读,不计数。 */
export async function lockoutRemaining(key: string, rule: AttemptRule) {
const [count, ttl] = await Promise.all([
redis.get(`attempts:${key}`),
redis.ttl(`attempts:${key}`),
])
if (Number(count) < rule.limit) return null
// ttl 为 -1(没有过期时间)理论上不会出现,EXPIRE NX 总会补上;兜底按整窗算
return ttl > 0 ? ttl : rule.windowSeconds
}
export async function countAttempt(key: string, rule: AttemptRule) {
await redis
.multi()
.incr(`attempts:${key}`)
// NX:只在窗口里第一次计数时设过期,之后不续期 —— 否则攻击者持续猜就永远锁着,
// 真正的主人过了窗口也登不进来
.expire(`attempts:${key}`, rule.windowSeconds, "NX")
.exec()
}
export async function resetAttempts(key: string) {
await redis.del(`attempts:${key}`)
}
/**
* 客户端 IP。线上 api 端口不对外发布,只有 Caddy 连得到它,Caddy 用 `{client_ip}`
* **覆盖**写入 X-Real-IP(它只信任内网来源的 X-Forwarded-For,见 docker/Caddyfile),
* 所以这个头是可信的。本机 dev 走 Vite 代理没有这个头,统一落到一个桶里,无所谓。
*/
export function clientIp(c: Context) {
return c.req.header("x-real-ip") || "unknown"
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import TextEditor from "shared/components/TextEditor.vue"
import type { AnnouncementEdit } from "utils/types"
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
@@ -63,8 +64,8 @@ async function submit() {
message.success("修改已保存")
}
router.push({ name: "admin announcement list" })
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
@@ -102,8 +103,8 @@ async function submit() {
message.success("修改已保存")
}
router.push({ name: "admin contest list" })
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
+9 -8
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions"
@@ -70,8 +71,8 @@ async function toggleChecked(item: HelperItem) {
submissions.value = [...submissions.value]
message.success(newChecked ? "已标记为已检查" : "已取消标记")
} catch (err: any) {
message.error(err.data || "操作失败")
} catch (err) {
message.error(errorMessage(err, "操作失败"))
}
}
@@ -101,9 +102,9 @@ async function markAllAsChecked() {
loadingMsg.destroy()
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
} catch (err: any) {
} catch (err) {
loadingMsg.destroy()
message.error(err.data || "批量操作失败")
message.error(errorMessage(err, "批量操作失败"))
}
}
@@ -159,8 +160,8 @@ async function viewSubmission(item: HelperItem) {
}
toggleCodePanel(true)
} catch (err: any) {
message.error(err.data || "加载提交失败")
} catch (err) {
message.error(errorMessage(err, "加载提交失败"))
}
}
@@ -174,8 +175,8 @@ async function loadData() {
// 再获取 AC 提交列表
const data = await getACMHelperList(Number(props.contestID))
submissions.value = data
} catch (err: any) {
message.error(err.data || "加载失败")
} catch (err) {
message.error(errorMessage(err, "加载失败"))
}
}
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorCode, errorMessage } from "utils/api"
import {
deleteContestProblem,
deleteProblem,
@@ -33,8 +34,8 @@ async function handleDeleteProblem() {
}
message.success("删除成功")
emit("updated")
} catch (err: any) {
if (err.error === "problem-has-submissions") {
} catch (err) {
if (errorCode(err) === "problem-has-submissions") {
message.error("这道题有提交之后,就不能被删除")
} else {
message.error("删除失败")
@@ -81,13 +82,13 @@ async function handleMakePublic() {
message.success("已成功转为公开题目(需要手动设置可见)")
showMakePublicModal.value = false
emit("updated") // 刷新列表
} catch (err: any) {
if (err.error === "display-id-exists") {
} catch (err) {
if (errorCode(err) === "display-id-exists") {
message.error("该题目编号已存在,请使用其他编号")
} else if (err.error === "already-public") {
} else if (errorCode(err) === "already-public") {
message.error("该题目已经是公开题目")
} else {
message.error("转换失败:" + (err.data || "未知错误"))
message.error("转换失败:" + errorMessage(err, "未知错误"))
}
}
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, errorMessage } from "utils/api"
import { addProblemForContest } from "admin/api"
interface Props {
@@ -22,13 +23,13 @@ async function addProblem() {
displayID.value,
)
emit("added")
} catch (err: any) {
if (err.error === "display-id-exists") {
} catch (err) {
if (errorCode(err) === "display-id-exists") {
message.error("显示编号重复了,请重新写一个")
} else if (err.error === "contest-ended") {
} else if (errorCode(err) === "contest-ended") {
message.error("这场比赛已经结束了,不能添加题目")
} else {
message.error(err.data)
message.error(errorMessage(err))
}
}
}
@@ -162,7 +162,7 @@ function getRulesForLang(lang: string): AstRule[] {
}
function updateRules(lang: string, rules: AstRule[]) {
const current = { ...(props.modelValue || {}) }
const current = { ...props.modelValue }
if (rules.length === 0) {
delete current[lang]
} else {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, isApiError } from "utils/api"
import type { LANGUAGE, SqlDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
@@ -81,15 +82,15 @@ onMounted(async () => {
if (res.length) {
scripts.value = res.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch (err: any) {
} catch (err) {
// 新题、以及旧格式(非 SQL)测试点,后端回 404/409,保持空白就是对的,不该报错。
// 但**其它**失败必须说出来:读不出来时编辑器长得和「这题本来就没测试点」一模一样,
// 教师看不出区别,会以为要自己重新填。保存本身是安全的(后端读不到测试点信息会拒绝
// 整个保存),所以这里只提示、不阻塞。
const code = err?.error
const code = errorCode(err)
if (code && code !== "problem-not-found" && code !== "not-sql-test-case") {
// 后端那句话(如"测试点信息读取失败")只说了现象,教师需要的是「下面是空的、别存」
const detail = err?.data ? `(${err.data})` : ""
const detail = isApiError(err) && err.data ? `(${err.data})` : ""
loadError.value =
`已有测试点脚本没能读出来${detail}。下方是空白模板,不是本题真实的测试点 —— ` +
`直接保存不会丢数据(后端会拒绝),但也不会生效。请先排查测试点文件,或重新上传。`
+6 -8
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, errorMessage } from "utils/api"
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
@@ -265,9 +266,6 @@ async function getProblemDetail() {
code: "",
}))
}
if (problem.value.contestId) {
problem.value.contestId = problem.value.contestId
}
// 下面是用来显示的:
// 代码模板 和 模板开关
@@ -484,11 +482,11 @@ async function submit() {
params: { contestID: props.contestID },
})
}
} catch (err: any) {
if (err.error === "display-id-exists") {
} catch (err) {
if (errorCode(err) === "display-id-exists") {
message.error("显示编号重复了,请换一个显示编号")
} else {
message.error(err.data)
message.error(errorMessage(err))
}
}
}
@@ -515,9 +513,9 @@ async function generateMermaid() {
)
problem.value.mermaidCode = res.flowchart
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
} catch (err: any) {
} catch (err) {
// 没有 finally 的话,AI 接口回 502 时按钮永远卡在 loading,只能刷页面
message.error(err.data || "生成失败,请稍后再试")
message.error(errorMessage(err, "生成失败,请稍后再试"))
} finally {
isAIGenerating.value = false
}
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
interface Props {
@@ -24,8 +25,8 @@ async function handleDeleteProblemSet() {
await deleteProblemSet(props.problemSetId)
message.success("删除成功")
emit("updated")
} catch (err: any) {
message.error("删除失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("删除失败:" + errorMessage(err, "未知错误"))
}
}
@@ -53,8 +54,8 @@ async function handleUpdateStatus() {
message.success("状态更新成功")
showStatusModal.value = false
emit("updated")
} catch (err: any) {
message.error("状态更新失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("状态更新失败:" + errorMessage(err, "未知错误"))
}
}
</script>
@@ -1,159 +0,0 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newBadgeName = ref("")
const newBadgeDescription = ref("")
const newBadgeIcon = ref("")
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const newBadgeConditionValue = ref(1)
const BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
})
}
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
emit("confirm", {
name: newBadgeName.value,
description: newBadgeDescription.value,
icon: newBadgeIcon.value,
conditionType: newBadgeConditionType.value,
// 只有非"完成所有题目"时才带条件值
...(newBadgeConditionType.value === "all_problems"
? {}
: { conditionValue: newBadgeConditionValue.value }),
})
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(
() => props.show,
(newVal) => {
if (newVal) {
newBadgeName.value = ""
newBadgeDescription.value = ""
newBadgeIcon.value = ""
newBadgeConditionType.value = "all_problems"
newBadgeConditionValue.value = 1
}
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="奖章名称" required>
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="newBadgeDescription"
type="textarea"
placeholder="奖章描述"
required
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="newBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
newBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
preview-disabled
style="border-radius: 2px"
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="newBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="newBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="newBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
@@ -1,103 +0,0 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
problemId: string
order: number
isRequired: boolean
score: number
hint: string
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newProblemId = ref("")
const newProblemOrder = ref(0)
const newProblemRequired = ref(true)
const newProblemScore = ref(0)
const newProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
problemId: newProblemId.value,
order: newProblemOrder.value,
isRequired: newProblemRequired.value,
score: newProblemScore.value,
hint: newProblemHint.value,
})
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(
() => props.show,
(newVal) => {
if (newVal) {
newProblemId.value = ""
newProblemOrder.value = 0
newProblemRequired.value = true
newProblemScore.value = 0
newProblemHint.value = ""
}
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="题目ID" required>
<n-input
v-model:value="newProblemId"
placeholder="请输入题目的显示ID(如:1001)"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="newProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="newProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="newProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="newProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
@@ -1,35 +1,37 @@
<script setup lang="ts">
import type { AdminProblemSetBadge } from "utils/types"
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。
// 原来是两份八成相同的组件,改一处表单项另一处总要漏
interface Props {
show: boolean
badge: AdminProblemSetBadge | null
}
type BadgeFormData = {
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
},
): void
(e: "create", data: BadgeFormData): void
(e: "update", data: BadgeFormData): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editBadgeName = ref("")
const editBadgeDescription = ref("")
const editBadgeIcon = ref("")
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
const badgeName = ref("")
const badgeDescription = ref("")
const badgeIcon = ref("")
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const editBadgeConditionValue = ref(1)
const badgeConditionValue = ref(1)
// 预设奖章图标选项
const BADGE_LEN = 6
@@ -49,33 +51,36 @@ const conditionTypeOptions = [
]
function handleConfirm() {
emit("confirm", {
name: editBadgeName.value,
description: editBadgeDescription.value,
icon: editBadgeIcon.value,
conditionType: editBadgeConditionType.value,
const data: BadgeFormData = {
name: badgeName.value,
description: badgeDescription.value,
icon: badgeIcon.value,
conditionType: badgeConditionType.value,
// 只有非"完成所有题目"时才带条件值
...(editBadgeConditionType.value === "all_problems"
...(badgeConditionType.value === "all_problems"
? {}
: { conditionValue: editBadgeConditionValue.value }),
})
: { conditionValue: badgeConditionValue.value }),
}
if (props.badge) emit("update", data)
else emit("create", data)
}
function handleCancel() {
emit("update:show", false)
}
// 当奖章数据变化时,更新表单数据
// 每次打开都按当前模式重新填表:编辑就填这枚奖章的值,添加就清空。
// 编辑取消后再打开同一枚,看到的是库里的值,而不是上次没保存的改动
watch(
() => props.badge,
(newBadge) => {
if (newBadge) {
editBadgeName.value = newBadge.name
editBadgeDescription.value = newBadge.description
editBadgeIcon.value = newBadge.icon
editBadgeConditionType.value = newBadge.conditionType
editBadgeConditionValue.value = newBadge.conditionValue
}
() => props.show,
(open) => {
if (!open) return
const badge = props.badge
badgeName.value = badge?.name ?? ""
badgeDescription.value = badge?.description ?? ""
badgeIcon.value = badge?.icon ?? ""
badgeConditionType.value = badge?.conditionType ?? "all_problems"
badgeConditionValue.value = badge?.conditionValue ?? 1
},
{ immediate: true },
)
@@ -85,17 +90,17 @@ watch(
<n-modal
:show="show"
preset="card"
title="编辑奖章"
:title="badge ? '编辑奖章' : '添加奖章'"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="badge">
<n-form>
<n-form-item label="奖章名称" required>
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editBadgeDescription"
v-model:value="badgeDescription"
type="textarea"
placeholder="奖章描述"
required
@@ -106,12 +111,12 @@ watch(
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="editBadgeIcon = option.value"
@click="badgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
editBadgeIcon === option.value
badgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
@@ -120,7 +125,7 @@ watch(
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
badgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
@@ -128,8 +133,8 @@ watch(
width="50"
height="50"
object-fit="cover"
style="border-radius: 2px"
preview-disabled
style="border-radius: 2px"
/>
</div>
</n-flex>
@@ -138,17 +143,17 @@ watch(
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="editBadgeConditionType"
v-model:value="badgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="editBadgeConditionType !== 'all_problems'"
v-if="badgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="editBadgeConditionValue"
v-model:value="badgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
@@ -1,100 +0,0 @@
<script setup lang="ts">
import type { AdminProblemSetProblem } from "utils/types"
interface Props {
show: boolean
problem: AdminProblemSetProblem | null
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
order: number
isRequired: boolean
score: number
hint: string
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editProblemOrder = ref(0)
const editProblemRequired = ref(true)
const editProblemScore = ref(0)
const editProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
order: editProblemOrder.value,
isRequired: editProblemRequired.value,
score: editProblemScore.value,
hint: editProblemHint.value || "",
})
}
function handleCancel() {
emit("update:show", false)
}
// 当问题数据变化时,更新表单数据
watch(
() => props.problem,
(newProblem) => {
if (newProblem) {
editProblemOrder.value = newProblem.order
editProblemRequired.value = newProblem.isRequired
editProblemScore.value = newProblem.score
editProblemHint.value = newProblem.hint || ""
}
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="problem">
<n-form-item label="题目标题">
<n-input :value="problem.title" disabled />
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="editProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="editProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="editProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="editProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { AdminProblemSetProblem } from "utils/types"
// 添加和编辑共用一个弹窗:传了 problem 就是编辑,null 就是添加。
// 两种模式只差第一栏 —— 添加时填题目 ID,编辑时题目已定、只显示标题
interface Props {
show: boolean
problem: AdminProblemSetProblem | null
}
type ProblemFormData = {
order: number
isRequired: boolean
score: number
hint: string
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "create", data: ProblemFormData & { problemId: string }): void
(e: "update", data: ProblemFormData): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const problemId = ref("")
const problemOrder = ref(0)
const problemRequired = ref(true)
const problemScore = ref(0)
const problemHint = ref("")
function handleConfirm() {
const data: ProblemFormData = {
order: problemOrder.value,
isRequired: problemRequired.value,
score: problemScore.value,
hint: problemHint.value,
}
if (props.problem) emit("update", data)
else emit("create", { problemId: problemId.value, ...data })
}
function handleCancel() {
emit("update:show", false)
}
// 每次打开都按当前模式重新填表:编辑就填这道题的值,添加就清空。
// 编辑取消后再打开同一道,看到的是库里的值,而不是上次没保存的改动
watch(
() => props.show,
(open) => {
if (!open) return
const problem = props.problem
problemId.value = ""
problemOrder.value = problem?.order ?? 0
problemRequired.value = problem?.isRequired ?? true
problemScore.value = problem?.score ?? 0
problemHint.value = problem?.hint || ""
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="problem ? '编辑题目' : '添加题目'"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item v-if="problem" label="题目标题">
<n-input :value="problem.title" disabled />
</n-form-item>
<n-form-item v-else label="题目ID" required>
<n-input
v-model:value="problemId"
placeholder="请输入题目的显示ID(如:1001)"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="problemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="problemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="problemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
+46 -52
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
import type {
AdminProblemSet,
@@ -26,10 +27,8 @@ import ProblemSetInfo from "./components/ProblemSetInfo.vue"
import ProblemManagement from "./components/ProblemManagement.vue"
import BadgeManagement from "./components/BadgeManagement.vue"
import ProgressManagement from "./components/ProgressManagement.vue"
import AddProblemModal from "./components/AddProblemModal.vue"
import EditProblemModal from "./components/EditProblemModal.vue"
import AddBadgeModal from "./components/AddBadgeModal.vue"
import EditBadgeModal from "./components/EditBadgeModal.vue"
import ProblemModal from "./components/ProblemModal.vue"
import BadgeModal from "./components/BadgeModal.vue"
const route = useRoute()
const router = useRouter()
@@ -47,11 +46,9 @@ const problems = ref<AdminProblemSetProblem[]>([])
const badges = ref<AdminProblemSetBadge[]>([])
const progress = ref<AdminProblemSetProgress[]>([])
// 模态框状态
const showAddProblemModal = ref(false)
const showEditProblemModal = ref(false)
const showAddBadgeModal = ref(false)
const showEditBadgeModal = ref(false)
// 模态框状态。添加和编辑共用一个弹窗,editing* 为 null 就是添加
const showProblemModal = ref(false)
const showBadgeModal = ref(false)
// 编辑数据
const editingProblem = ref<AdminProblemSetProblem | null>(null)
@@ -61,8 +58,8 @@ async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
}
@@ -70,8 +67,8 @@ async function loadProblems() {
try {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res
} catch (err: any) {
message.error("加载题目列表失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载题目列表失败:" + errorMessage(err, "未知错误"))
}
}
@@ -79,8 +76,8 @@ async function loadBadges() {
try {
const res = await getProblemSetBadges(problemSetId.value)
badges.value = res
} catch (err: any) {
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载奖章列表失败:" + errorMessage(err, "未知错误"))
}
}
@@ -88,8 +85,8 @@ async function loadProgress() {
try {
const res = await getProblemSetProgress(problemSetId.value)
progress.value = res
} catch (err: any) {
message.error("加载进度列表失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载进度列表失败:" + errorMessage(err, "未知错误"))
}
}
@@ -97,11 +94,11 @@ async function handleAddProblem(data: AddProblemToSetRequest) {
try {
await addProblemToSet(problemSetId.value, data)
message.success("题目添加成功")
showAddProblemModal.value = false
showProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("添加题目失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("添加题目失败:" + errorMessage(err, "未知错误"))
}
}
@@ -111,8 +108,8 @@ async function handleRemoveProblem(problemSetProblemId: number) {
message.success("题目移除成功")
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("移除题目失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("移除题目失败:" + errorMessage(err, "未知错误"))
}
}
@@ -122,10 +119,10 @@ async function handleEditProblem(data: UpdateProblemInSetRequest) {
try {
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
message.success("题目编辑成功")
showEditProblemModal.value = false
showProblemModal.value = false
loadProblems()
} catch (err: any) {
message.error("编辑题目失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
}
}
@@ -133,10 +130,10 @@ async function handleAddBadge(data: BadgeFormData) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功")
showAddBadgeModal.value = false
showBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("创建奖章失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
}
}
@@ -145,8 +142,8 @@ async function handleDeleteBadge(badgeId: number) {
await deleteProblemSetBadge(problemSetId.value, badgeId)
message.success("奖章删除成功")
loadBadges()
} catch (err: any) {
message.error("删除奖章失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("删除奖章失败:" + errorMessage(err, "未知错误"))
}
}
@@ -156,10 +153,10 @@ async function handleEditBadge(data: BadgeFormData) {
try {
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
message.success("奖章编辑成功")
showEditBadgeModal.value = false
showBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("编辑奖章失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
}
}
@@ -168,27 +165,29 @@ async function handleRemoveUser(userId: number) {
await removeUserFromProblemSet(problemSetId.value, userId)
message.success("用户移除成功")
loadProgress()
} catch (err: any) {
message.error("移除用户失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("移除用户失败:" + errorMessage(err, "未知错误"))
}
}
function openAddProblemModal() {
showAddProblemModal.value = true
editingProblem.value = null
showProblemModal.value = true
}
function openAddBadgeModal() {
showAddBadgeModal.value = true
editingBadge.value = null
showBadgeModal.value = true
}
function openEditProblemModal(problem: AdminProblemSetProblem) {
editingProblem.value = problem
showEditProblemModal.value = true
showProblemModal.value = true
}
function openEditBadgeModal(badge: AdminProblemSetBadge) {
editingBadge.value = badge
showEditBadgeModal.value = true
showBadgeModal.value = true
}
onMounted(() => {
@@ -246,23 +245,18 @@ onMounted(() => {
</n-tabs>
<!-- 模态框组件 -->
<AddProblemModal
v-model:show="showAddProblemModal"
@confirm="handleAddProblem"
/>
<EditProblemModal
v-model:show="showEditProblemModal"
<ProblemModal
v-model:show="showProblemModal"
:problem="editingProblem"
@confirm="handleEditProblem"
@create="handleAddProblem"
@update="handleEditProblem"
/>
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
<EditBadgeModal
v-model:show="showEditBadgeModal"
<BadgeModal
v-model:show="showBadgeModal"
:badge="editingBadge"
@confirm="handleEditBadge"
@create="handleAddBadge"
@update="handleEditBadge"
/>
</div>
</template>
+5 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
@@ -60,8 +61,8 @@ async function loadProblemSetDetail() {
visible: data.visible,
endTime: data.endTime ? new Date(data.endTime) : null,
}
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
}
@@ -87,11 +88,11 @@ async function handleSubmit() {
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err: any) {
} catch (err) {
message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
(err.data || "未知错误"),
errorMessage(err, "未知错误"),
)
} finally {
loading.value = false
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
@@ -210,8 +211,8 @@ async function saveWebsiteConfig() {
}
try {
await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
} catch (err) {
message.error("保存失败:" + errorMessage(err))
return
}
message.success("网站配置保存成功")
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import { deleteTutorial } from "admin/api"
interface Props {
@@ -22,8 +23,8 @@ async function handleDelete() {
await deleteTutorial(props.tutorialID)
message.success("删除成功")
emit("deleted")
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
</script>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type {
Exercise,
ExerciseType,
@@ -317,8 +318,8 @@ async function save() {
}
showForm.value = false
await load()
} catch (e: any) {
message.error(e.data ?? "保存失败")
} catch (e) {
message.error(errorMessage(e, "保存失败"))
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import CodeEditor from "shared/components/CodeEditor.vue"
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
import type { TutorialEdit } from "utils/types"
@@ -63,8 +64,8 @@ async function submit() {
await updateTutorial(tutorial)
message.success("修改已保存")
}
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NSwitch } from "naive-ui"
import { parseTime } from "utils/functions"
import type { TutorialListItem } from "utils/types"
@@ -63,8 +64,8 @@ async function toggleVisible(tutorial: TutorialListItem) {
try {
await setTutorialVisibility(tutorial.id, tutorial.isPublic)
message.success("更新成功")
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
tutorial.isPublic = !tutorial.isPublic
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
@@ -29,7 +30,7 @@ function generateUsers() {
)
return false
}
let className = !!prefix.value ? `ks${prefix.value}` : ""
let className = prefix.value ? `ks${prefix.value}` : ""
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重,而且库里同一个邮箱
// 出现两次的话,那两个账号在「编辑用户」里保存一次就撞 409,从此改不动。
// 原来只按「班级 + 批内序号」拼,同一个班分两批导入必然重号 —— 加一段每批随机的
@@ -66,8 +67,8 @@ async function uploadUsers() {
hiddenElement.download = prefix.value + ".csv"
hiddenElement.click()
hiddenElement.remove()
} catch (err: any) {
message.error("上传失败:" + err.data)
} catch (err) {
message.error("上传失败:" + errorMessage(err))
} finally {
toggleLoading(false)
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
@@ -250,8 +251,8 @@ async function handleEditUser() {
})
await editUser(user)
}
} catch (err: any) {
message.error("保存失败:" + err.data)
} catch (err) {
message.error("保存失败:" + errorMessage(err))
return
}
userEditing.value = null
+2 -2
View File
@@ -55,7 +55,7 @@ router.beforeEach(async (to, _from, next) => {
if (!userStore.user) {
try {
await userStore.getMyProfile()
} catch (error) {
} catch {
next("/")
return
}
@@ -85,7 +85,7 @@ router.beforeEach(async (to, _from, next) => {
app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
if (import.meta.env.PUBLIC_ICONIFY_URL) {
addAPIProvider("", {
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
})
-10
View File
@@ -27,7 +27,6 @@ import {
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type ProblemSet,
type ProblemSetBadge,
@@ -60,7 +59,6 @@ import type {
SubmissionListPayload,
SubmitCodePayload,
OnlineCount,
WebsiteConfig,
Tutorial,
TutorialProgress,
} from "utils/types"
@@ -77,10 +75,6 @@ function detailProblem(value: unknown): Problem {
return contract("GET /problems/:id", problemDetailSchema, value)
}
export function getWebsiteConfig() {
return api.get<WebsiteConfig>("site")
}
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
@@ -373,10 +367,6 @@ export function getAIHeatmapData(username?: string) {
})
}
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
+41 -230
View File
@@ -12,7 +12,7 @@ import { Bar, Radar } from "vue-chartjs"
import { useBreakpoints } from "shared/composables/breakpoints"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { useAIStream } from "shared/composables/aiStream"
import {
Chart as ChartJS,
CategoryScale,
@@ -55,10 +55,10 @@ const duration = ref<string>("")
const loading = ref(false)
const hasTimeRange = ref(false)
const aiLoading = ref(false)
const aiStream = useAIStream()
const aiLoading = aiStream.waiting
const aiContent = ref("")
const showAIModal = ref(false)
let aiController: AbortController | null = null
// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」
const timeRangeOptions: SelectOption[] = [
@@ -120,65 +120,20 @@ async function compare() {
}
async function analyzeWithAI() {
if (aiController) {
aiController.abort()
}
const controller = new AbortController()
aiController = controller
const timeRangeLabel =
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
"全部时间"
showAIModal.value = true
aiContent.value = ""
aiLoading.value = true
try {
const response = await fetch("/api/ai/class-pk-analysis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comparisons: comparisons.value,
timeRangeLabel,
}),
signal: controller.signal,
})
if (!response.ok) throw await aiStreamError(response)
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) aiLoading.value = false
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
aiLoading.value = false
}
aiContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
aiLoading.value = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) return
message.error(error?.message || "AI 分析失败,请稍后再试")
aiLoading.value = false
} finally {
if (aiController === controller) aiController = null
await aiStream.run(
"ai/class-pk-analysis",
{ comparisons: comparisons.value, timeRangeLabel },
{ onDelta: (content) => (aiContent.value += content) },
)
} catch (error) {
message.error((error as Error).message)
}
}
@@ -207,185 +162,41 @@ function getClassColor(index: number) {
return colors[index % colors.length]
}
// 综合分对比图
const compositeScoreChartData = computed(() => {
if (comparisons.value.length === 0) return null
// 值一定是数字的那些指标(recent* 是可选字段,不在其列)
type BarMetric = {
[K in keyof ClassComparison]-?: ClassComparison[K] extends number ? K : never
}[keyof ClassComparison]
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "综合分",
data: comparisons.value.map((c) => c.compositeScore),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
// 十张柱状对比图只差标题和取哪一列:每个班级一种颜色,和雷达图用同一套配色
function barChart(label: string, key: BarMetric) {
return computed(() => {
if (comparisons.value.length === 0) return null
return { labels, datasets }
})
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label,
data: comparisons.value.map((c) => c[key]),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
// 总AC数对比图 - 每个班级用不同颜色
const totalAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
return { labels, datasets }
})
}
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "总AC数",
data: comparisons.value.map((c) => c.totalAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 平均AC数对比图
const avgAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "平均AC数",
data: comparisons.value.map((c) => c.avgAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 中位数AC数对比图
const medianAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "中位数AC数",
data: comparisons.value.map((c) => c.medianAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 优秀率对比图
const excellentRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "优秀率",
data: comparisons.value.map((c) => c.excellentRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 及格率对比图
const passRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "及格率",
data: comparisons.value.map((c) => c.passRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 参与度对比图
const activeRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "参与度",
data: comparisons.value.map((c) => c.activeRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 前10%平均对比图
const top10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "前10%平均",
data: comparisons.value.map((c) => c.top10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 后10%平均对比图
const bottom10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "后10%平均",
data: comparisons.value.map((c) => c.bottom10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 中间80%均值对比图
const middle80AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "中间80%均值",
data: comparisons.value.map((c) => c.middle80Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
const compositeScoreChartData = barChart("综合分", "compositeScore")
const totalAcChartData = barChart("总AC数", "totalAc")
const avgAcChartData = barChart("平均AC数", "avgAc")
const medianAcChartData = barChart("中位数AC数", "medianAc")
const excellentRateChartData = barChart("优秀率", "excellentRate")
const passRateChartData = barChart("及格率", "passRate")
const activeRateChartData = barChart("参与度", "activeRate")
const top10AvgChartData = barChart("前10%平均", "top10Avg")
const bottom10AvgChartData = barChart("后10%平均", "bottom10Avg")
const middle80AvgChartData = barChart("中间80%均值", "middle80Avg")
// 雷达图数据 - 多维度综合对比
const radarChartData = computed(() => {
@@ -113,7 +113,7 @@ const chartData = computed(() => {
if (b.solved !== a.solved) return b.solved - a.solved
return a.penalty - b.penalty
})
const ranks = new Array(topUsers.length).fill(0)
const ranks = Array.from({ length: topUsers.length }, () => 0)
indexed.forEach((item, pos) => {
ranks[item.i] = pos + 1
})
+7 -2
View File
@@ -17,8 +17,13 @@ const { isDesktop } = useBreakpoints()
const password = ref("")
async function check() {
await contestStore.checkPassword(props.contestID, password.value)
if (!contestStore.access) {
const error = await contestStore.checkPassword(
props.contestID,
password.value,
)
if (error === "too-many-password-attempts") {
message.error("密码错误次数太多,请 10 分钟后再试")
} else if (!contestStore.access) {
message.error("密码错误")
}
}
@@ -12,7 +12,7 @@ import { copyToClipboard } from "utils/functions"
const message = useMessage()
const route = useRoute()
const contestID = !!route.params.contestID ? route.params.contestID : null
const contestID = route.params.contestID ? route.params.contestID : null
const codeStore = useCodeStore()
const problemStore = useProblemStore()
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract"
import type { JudgeCaseResult } from "@oj2/contract"
import type { AiHintDone, JudgeCaseResult } from "@oj2/contract"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
submissionCaseResults,
@@ -14,7 +14,7 @@ import {
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem"
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { useAIStream } from "shared/composables/aiStream"
import { submitHintFeedback } from "oj/api"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
@@ -34,7 +34,9 @@ const theme = useThemeVars()
// 逐字显示改在这边模拟。
const hintTarget = ref("")
const hintContent = ref("")
const hintLoading = ref(false)
const hintStream = useAIStream()
// 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态
const hintLoading = hintStream.running
const hintError = ref("")
// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
const hintId = ref<number | null>(null)
@@ -147,51 +149,34 @@ const showAIHint = computed(() => {
watch(
() => props.submission?.id,
() => {
// 上一次提交的提示还在生成的话掐掉,不然它会写进新提交的面板里
hintStream.abort()
resetHint()
hintLoading.value = false
},
)
// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看
// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准
async function fetchHint(submissionId: string, more = false) {
hintLoading.value = true
resetHint()
try {
const response = await fetch("/api/ai/hint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ submissionId, more }),
})
if (!response.ok) throw await aiStreamError(response)
await consumeJSONEventStream(response, {
onMessage: (data: {
type: string
content?: string
message?: string
hintId?: number | null
level?: number
canEscalate?: boolean
}) => {
if (data.type === "delta" && data.content) {
hintTarget.value += data.content
await hintStream.run<AiHintDone>(
"ai/hint",
{ submissionId, more },
{
onDelta(content) {
hintTarget.value += content
startTyping()
} else if (data.type === "done") {
},
onDone(data) {
hintId.value = data.hintId ?? null
hintLevel.value = data.level ?? null
hintCanEscalate.value = data.canEscalate === true
} else if (data.type === "error") {
hintError.value = data.message || "AI 提示生成失败"
}
},
},
})
} catch (e: any) {
hintError.value = e.message || "请求失败"
} finally {
hintLoading.value = false
)
} catch (error) {
hintError.value = (error as Error).message
}
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, errorMessage } from "utils/api"
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { formatCode, getReaction, submitCode } from "oj/api"
@@ -133,10 +134,10 @@ async function submit() {
language: formatLang,
})
codeStore.setCode(res.code)
} catch (e: any) {
if (e?.error === "format-error") {
} catch (e) {
if (errorCode(e) === "format-error") {
// 仅 Python 会出现:代码本身存在语法错误
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
message.warning(`代码格式化失败:${errorMessage(e)},请检查代码后重试`)
return
}
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorCode } from "utils/api"
import { toRefs } from "vue"
// 工具函数
@@ -256,13 +257,13 @@ async function submitFlowchartData() {
}
message.success("流程图已提交,请耐心等待评分")
} catch (error: any) {
} catch (error) {
loading.value = false
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
// 只说「提交失败」的话,学生会以为是自己的图有问题,然后反复点,越点越久
if (error?.error === "too-many-submissions") {
if (errorCode(error) === "too-many-submissions") {
message.warning("提交太频繁了,缓一会儿再交")
} else if (error?.error === "flowchart-not-allowed") {
} else if (errorCode(error) === "flowchart-not-allowed") {
message.error("这道题不接受流程图提交")
} else {
message.error("流程图提交失败")
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { getProblem } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { storeToRefs } from "pinia"
@@ -120,9 +121,9 @@ async function init() {
try {
const res = await getProblem(problemID, contestID)
problem.value = res
} catch (err: any) {
} catch (err) {
problem.value = null
if (err.error === "contest-not-started") {
if (errorCode(err) === "contest-not-started") {
errMsg.value = "比赛还没有开始"
}
}
@@ -46,8 +46,10 @@ export async function checkPythonSyntax(
try {
Sk.compile(code, "prog.py", "exec")
return null
} catch (e: any) {
const line: number = e?.traceback?.[0]?.lineno ?? 1
} catch (e) {
// Skulpt 的语法错误对象,不是 Error 的子类
const traceback = (e as { traceback?: { lineno?: number }[] }).traceback
const line = traceback?.[0]?.lineno ?? 1
return { line }
}
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
@@ -68,10 +69,10 @@ async function loadUserProgress() {
if (res.problems) {
allProblems.value = res.problems
}
} catch (err: any) {
} catch (err) {
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
// 请求一失败(403、断网)转圈就永远停不下来
message.error("加载用户进度失败:" + (err.data || "未知错误"))
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
} finally {
loading.value = false
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import {
getProblemSetDetail,
getProblemSetProblems,
@@ -90,8 +91,8 @@ async function handleJoinProblemSet() {
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err: any) {
message.error("加入题单失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
} finally {
isJoining.value = false
}
+12 -50
View File
@@ -29,7 +29,7 @@ import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { useAIStream } from "shared/composables/aiStream"
const GRADES = [20, 25]
@@ -102,10 +102,10 @@ const showClassDetailModal = ref(false)
const classDetailData = ref<ClassComparison | null>(null)
const classDetailLoading = ref(false)
const classDetailAiLoading = ref(false)
const classDetailAiStream = useAIStream()
const classDetailAiLoading = classDetailAiStream.waiting
const classDetailAiContent = ref("")
const showClassDetailAiModal = ref(false)
let classDetailAiController: AbortController | null = null
async function loadClassDetail(className: string) {
showClassDetailModal.value = true
@@ -123,55 +123,17 @@ async function loadClassDetail(className: string) {
async function analyzeSingleClassWithAI() {
if (!classDetailData.value) return
if (classDetailAiController) classDetailAiController.abort()
const controller = new AbortController()
classDetailAiController = controller
showClassDetailModal.value = false
showClassDetailAiModal.value = true
classDetailAiContent.value = ""
classDetailAiLoading.value = true
try {
const response = await fetch("/api/ai/class-analysis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comparison: classDetailData.value }),
signal: controller.signal,
})
if (!response.ok) throw await aiStreamError(response)
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
classDetailAiLoading.value = false
}
classDetailAiContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
classDetailAiLoading.value = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) return
message.error(error?.message || "AI 分析失败,请稍后再试")
classDetailAiLoading.value = false
} finally {
if (classDetailAiController === controller) classDetailAiController = null
await classDetailAiStream.run(
"ai/class-analysis",
{ comparison: classDetailData.value },
{ onDelta: (content) => (classDetailAiContent.value += content) },
)
} catch (error) {
message.error((error as Error).message)
}
}
@@ -543,7 +505,7 @@ async function listMyClassRank() {
if (myClassScope.value === "window") {
myClassQuery.page = 1
}
} catch (err: any) {
} catch (err) {
console.error(err)
}
}
@@ -555,7 +517,7 @@ async function listWeeklyRank() {
weeklyMe.value = res.me
weeklyTotal.value = res.total
weeklyStart.value = res.start
} catch (err: any) {
} catch (err) {
console.error(err)
}
}
+16 -49
View File
@@ -1,5 +1,5 @@
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { streamAI } from "utils/stream"
import {
getAIDetailData,
getAIDurationData,
@@ -128,10 +128,10 @@ export const useAIStore = defineStore("ai", () => {
let aiController: AbortController | null = null
// 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个
// loading 放在同一个 reactive 里给页面用
async function fetchAIAnalysis() {
if (aiController) {
aiController.abort()
}
aiController?.abort()
const controller = new AbortController()
aiController = controller
@@ -139,58 +139,25 @@ export const useAIStore = defineStore("ai", () => {
mdContent.value = ""
try {
const response = await fetch("/api/ai/analysis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
await streamAI(
"ai/analysis",
{
start: rangeStart.value,
end: rangeEnd.value,
duration: duration.value,
username: targetUsername.value || undefined,
}),
signal: controller.signal,
})
if (!response.ok) {
throw await aiStreamError(response)
}
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) {
loading.ai = false
}
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
loading.ai = false
}
mdContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
{
signal: controller.signal,
onDelta(content) {
loading.ai = false
}
mdContent.value += content
},
},
})
} catch (error: any) {
if (controller.signal.aborted) {
return
}
console.error("生成 AI 分析失败", error)
const message = error?.message || "生成失败,请稍后再试"
mdContent.value = `生成失败:${message}`
)
} catch (error) {
if (controller.signal.aborted) return
mdContent.value = `生成失败:${(error as Error).message}`
} finally {
if (aiController === controller) {
aiController = null
+5 -1
View File
@@ -1,3 +1,4 @@
import { errorCode } from "utils/api"
import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants"
@@ -87,6 +88,7 @@ export const useContestStore = defineStore("contest", () => {
timer = 0
}
/** 返回失败时的错误码(成功为 null),调用方据此给提示 */
async function checkPassword(contestID: string, password: string) {
try {
const res = await checkContestPassword(contestID, password)
@@ -94,15 +96,17 @@ export const useContestStore = defineStore("contest", () => {
if (res) {
_getProblems(contestID)
}
return null
} catch (err) {
toggleAccess(false)
return errorCode(err) ?? "unknown"
}
}
async function _getProblems(contestID: string) {
try {
problems.value = await getContestProblems(contestID)
} catch (err) {
} catch {
problems.value = []
toggleAccess(false)
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const useProblemStore = defineStore("problem", () => {
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allowFlowchart) {
return ["Flowchart", ...problem.value?.languages]
return ["Flowchart", ...problem.value.languages]
}
return problem.value?.languages ?? []
})
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { NButton, NFlex, NTag, NText } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import {
@@ -225,8 +226,8 @@ async function retryFlowchart(submissionId: string) {
}
try {
await retryFlowchartSubmission(submissionId)
} catch (err: any) {
message.error(retryTips[err?.error] ?? "重新评分失败")
} catch (err) {
message.error(retryTips[errorCode(err) ?? ""] ?? "重新评分失败")
return
}
message.success("重新评分已提交")
+20 -3
View File
@@ -1,6 +1,6 @@
import { userProfileSchema, type Quote } from "@oj2/contract"
import type { LoginSummary, Quote, WebsiteConfig } from "@oj2/contract"
import api from "utils/api"
import { contract } from "utils/contract"
import { contractDeferred } from "utils/contract"
import type { Profile, Tag } from "utils/types"
export function login(data: { username: string; password: string }) {
@@ -29,7 +29,12 @@ export async function getProfile(
// `userProfileSchema.parse(response) as Profile`,`as` 把校验结果又断言回本地
// 类型(Profile 把 user 收窄成 SessionUser、acmProblemsStatus 收窄成具体形状),
// 形状对不上时页面白屏。现在记一条分歧日志后放行原始数据。
return contract("GET /profiles/:username", userProfileSchema, response)
// 用 deferred 版:启动就要拉 /me,同步校验会把 zod 整个打进首屏包
return contractDeferred(
"GET /profiles/:username",
() => import("./profileSchema").then((m) => m.userProfileSchema),
response,
)
}
export function getProblemTagList() {
@@ -43,3 +48,15 @@ export function getHitokoto() {
export function getClassUsernames(classroom: string) {
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
}
// 下面两个是启动就要调的(config / loginSummary 两个 store),所以放在 shared 而不是
// oj/api.ts:oj/api.ts 同步 import 了题目详情、提交详情两个 schema(它们的契约闸门必须
// 同步,submissionDetailSchema 里有 `.catch()`,放行原文和解析结果不等价),入口一旦
// 引到它,zod 整个就进了首屏包。
export function getWebsiteConfig() {
return api.get<WebsiteConfig>("site")
}
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
+3 -1
View File
@@ -90,7 +90,9 @@ const active = computed(() => route.path.split("/")[1] || "problem")
async function handleLogout() {
await userStore.signOut()
router.replace("/")
// 整页跳转而不是 router.replace:AI 分析、学情小结这些 store 还装着这个人的数据,
// 机房下一个学生坐下来就能看到。重载一次把内存清干净,比挨个 reset 可靠。
window.location.replace("/")
}
function handleToggleDemoMode() {
+6 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { getClassUsernames, login } from "../api"
@@ -124,12 +125,14 @@ function submit() {
merged.username = form.value.class + merged.username
}
await login(merged)
} catch (err: any) {
} catch (err) {
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」
if (err.error === "account-disabled") {
if (errorCode(err) === "account-disabled") {
authStore.setLoginError("此账号已被封禁")
} else if (err.error === "invalid-credentials") {
} else if (errorCode(err) === "invalid-credentials") {
authStore.setLoginError("用户名或密码不正确")
} else if (errorCode(err) === "too-many-login-attempts") {
authStore.setLoginError("密码错误次数太多,请 15 分钟后再试")
} else {
authStore.setLoginError("无法登录")
}
+6 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { signup } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
@@ -47,11 +48,13 @@ function submit() {
email: form.value.email,
password: form.value.password,
})
} catch (err: any) {
if (err.error === "username-exists") {
} catch (err) {
if (errorCode(err) === "username-exists") {
authStore.setSignupError("用户名已存在")
} else if (err.error === "email-exists") {
} else if (errorCode(err) === "email-exists") {
authStore.setSignupError("邮箱已存在")
} else if (errorCode(err) === "too-many-registrations") {
authStore.setSignupError("注册太频繁,请稍后再试")
} else {
authStore.setSignupError("无法注册")
}
@@ -0,0 +1,62 @@
import { streamAI } from "utils/stream"
/**
* 组件里发 AI 流式请求:同一时刻只跑一条(再点一次会先掐掉上一条),组件卸载时
* 自动中止 —— 原来班级分析、班级 PK 两处离开页面后流还在跑,白占一个 AI 名额。
*
* - `waiting`:请求发出、第一个字还没到。按钮 loading、占位转圈用它
* - `running`:整条流还没结束
*
* `run` 失败时抛 Error(message 可直接给用户看);被中止时静默返回,不抛。
*/
export function useAIStream() {
const waiting = ref(false)
const running = ref(false)
let controller: AbortController | null = null
function abort() {
controller?.abort()
controller = null
waiting.value = false
running.value = false
}
async function run<Done = Record<string, unknown>>(
path: string,
body: unknown,
handlers: {
onDelta: (content: string) => void
onDone?: (data: Done) => void
},
) {
abort()
const own = new AbortController()
controller = own
waiting.value = true
running.value = true
try {
await streamAI<Done>(path, body, {
...handlers,
signal: own.signal,
onDelta(content) {
waiting.value = false
handlers.onDelta(content)
},
})
} catch (error) {
if (own.signal.aborted) return
throw error
} finally {
// 被下一次 run 顶掉的那条不碰状态,状态归新的那条管
if (controller === own) {
controller = null
waiting.value = false
running.value = false
}
}
}
if (getCurrentScope()) onScopeDispose(abort)
return { waiting, running, run, abort }
}
+4
View File
@@ -0,0 +1,4 @@
// 只给 shared/api.ts 的 getProfile 动态 import 用,见那边的注释。
// 单独一个模块而不是直接 import("@oj2/contract"):动态引入整个契约桶会让 rolldown
// 重排共享 chunk,把一批原本懒加载的页面依赖提进首屏预加载(实测首屏 703KB → 1811KB)。
export { userProfileSchema } from "@oj2/contract"
+1 -1
View File
@@ -1,4 +1,4 @@
import { getWebsiteConfig } from "oj/api"
import { getWebsiteConfig } from "shared/api"
import type { WebsiteConfig } from "utils/types"
export const useConfigStore = defineStore("config", () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { LoginSummary as ContractLoginSummary } from "@oj2/contract"
import { getAILoginSummary } from "oj/api"
import { getAILoginSummary } from "shared/api"
type LoginSummary = ContractLoginSummary["summary"]
@@ -34,7 +34,7 @@ export const useLoginSummaryStore = defineStore("loginSummary", () => {
summary.value = res.summary
analysis.value = res.analysis || ""
analysisError.value = res.analysisError || ""
} catch (err) {
} catch {
analysisError.value = "获取登录统计失败,请稍后再试"
} finally {
loading.value = false
+15 -1
View File
@@ -66,11 +66,24 @@ export const useUserStore = defineStore("user", () => {
// 登录后和改完设置仍然要能重新拉一份。
let inflight: Promise<void> | null = null
// 这个页面生命周期里最后一个登录过的人。**clearProfile 不清它**,就是为了下面那道闸。
let lastUserId: number | null = null
function getMyProfile() {
if (inflight) return inflight
isFinished.value = false
inflight = getProfile()
.then((res) => {
// 换了人就整页重载。会话过期、别的标签页登出这类被动下线是**原地**弹登录框
// 的(学生正在写的代码不能丢),可内存里的 store 全是上一个人的:AI 分析、
// 学情小结、协作状态……机房一台机器轮着用,下一个学生登进来就能看到。
// 挨个 store 去 reset 迟早漏一个,重载一次最彻底;同一个人重新登录不受影响。
const id = res?.user?.id ?? null
if (id !== null && lastUserId !== null && id !== lastUserId) {
window.location.reload()
return
}
if (id !== null) lastUserId = id
profile.value = res
isFinished.value = true
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
@@ -90,7 +103,8 @@ export const useUserStore = defineStore("user", () => {
}
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由。
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由 ——
// 调用方要**整页**跳走,别用 router:各个 store 里还留着这个人的数据。
async function signOut() {
await logout()
clearProfile()
+61 -20
View File
@@ -6,13 +6,45 @@ import storage from "./storage"
const { message: toast } = createDiscreteApi(["message"])
interface ApiError {
/** 后端失败响应体的形状,见 apps/api/src/http.ts 的 failure */
interface ErrorPayload {
error?: {
code?: string
message?: string
}
}
/**
* 拦截器 reject 出来的就是它:`error` 是错误码,`data` 是后端文案。
*
* 字段名沿用原来那个裸对象 `{ error, data }`,存量调用点读 `err.error` / `err.data`
* 的语义不变;做成 Error 的子类是为了在 catch 里能和代码 bug(TypeError 之类)分开,
* 也顺带有了堆栈。catch 块里别写 `err: any`,用下面的 errorCode / errorMessage。
*/
export class ApiError extends Error {
constructor(
readonly error: string,
readonly data: string,
) {
super(data)
this.name = "ApiError"
}
}
export function isApiError(err: unknown): err is ApiError {
return err instanceof ApiError
}
/** 接口错误的错误码;不是接口错误(代码 bug、被中止……)就是 undefined */
export function errorCode(err: unknown) {
return isApiError(err) ? err.error : undefined
}
/** 给用户看的文案:接口错误用后端文案,其它一律 fallback —— 别把 TypeError 的英文甩给学生 */
export function errorMessage(err: unknown, fallback = "未知错误") {
return isApiError(err) && err.data ? err.data : fallback
}
/**
* 成功时直接拿到业务数据本身。后端成功响应是 `{ data }`(见 apps/api/src/http.ts
* 的 success),拦截器把 axios 外层和这层信封一起剥掉。
@@ -27,6 +59,29 @@ interface ApiClient {
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>
}
/**
* 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
* 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
* 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
*
* 导出给不走 axios 的请求用(AI 的流式接口是裸 fetch,见 utils/stream.ts)——
* 同一个错误码从两条路进来,学生看到的结果不该有两个样子。
*/
export function handleGlobalApiError(code: string, message?: string) {
if (code === "login-required") {
storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
} else if (code === "account-disabled") {
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
storage.remove(STORAGE_KEY.AUTHED)
toast.error("账号已被禁用,请联系老师")
} else if (code === "permission-denied") {
toast.error(message || "权限不足")
}
}
const instance = axios.create({
baseURL: "/api",
withCredentials: true,
@@ -51,27 +106,13 @@ instance.interceptors.response.use(
response: AxiosResponse,
) => AxiosResponse,
(error) => {
const payload = error.response?.data as ApiError | undefined
const payload = error.response?.data as ErrorPayload | undefined
const code = payload?.error?.code ?? "network-error"
const message = payload?.error?.message ?? "Request failed"
// 没有响应体 = 请求根本没到后端(断网、反代挂了、超时),学生看到的就是这一句
const message = payload?.error?.message ?? "网络异常,请检查网络后重试"
// 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
// 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
// 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
if (code === "login-required") {
storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
} else if (code === "account-disabled") {
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
storage.remove(STORAGE_KEY.AUTHED)
toast.error("账号已被禁用,请联系老师")
} else if (code === "permission-denied") {
toast.error(message || "权限不足")
}
return Promise.reject({ error: code, data: message })
handleGlobalApiError(code, message)
return Promise.reject(new ApiError(code, message))
},
)
+21
View File
@@ -45,3 +45,24 @@ export function contract<T extends z.ZodType>(
// 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。
return value as z.infer<T>
}
/**
* 同 `contract`,但校验挪到后台:先原样交出数据,schema 按需加载之后再核、只记日志。
*
* 给**首屏路径**用(启动就要拉的 /me)。同步校验得在入口包里带上 schema,也就带上
* 整个 zod 运行时(~100KB);而这道闸本来就只记日志、不改数据 —— schema 里没有
* default / transform 时,放行原文和放行解析结果只差 zod 剥掉的多余字段,前端用不上。
* 给 schema 加 default / transform 之前,得先把调用方从这里挪回 `contract`。
*/
export function contractDeferred<T extends z.ZodType>(
endpoint: string,
loadSchema: () => Promise<T>,
value: unknown,
): z.infer<T> {
loadSchema()
.then((schema) => contract(endpoint, schema, value))
.catch(() => {
// schema 那个 chunk 加载失败(部署换了哈希之类)不影响页面,闸门本来就只记日志
})
return value as z.infer<T>
}
+62 -4
View File
@@ -1,3 +1,5 @@
import { handleGlobalApiError } from "./api"
export interface JSONEventStreamHandlers<T = any> {
onMessage: (data: T, event?: string) => void
onEvent?: (event: string) => void
@@ -45,7 +47,7 @@ export async function consumeJSONEventStream<T = any>(
let parsed: T
try {
parsed = JSON.parse(payloadStr)
} catch (error) {
} catch {
throw new Error(`无法解析服务端事件数据: ${payloadStr}`)
}
@@ -96,12 +98,21 @@ export async function consumeJSONEventStream<T = any>(
/**
* AI 端点的非 2xx 响应体是 JSON 不是 SSE,直接丢给上面的解析器只会抛
* 「无法解析服务端事件数据: {...}」。后端 error.message 是英文的,按 code 换成中文。
*
* 登录失效 / 账号禁用走和 axios 拦截器同一套处理(弹登录框 / 提示禁用)——
* 原来这里裸 fetch 绕过了拦截器,未登录时只显示一句「AI 分析生成失败」。
*/
export async function aiStreamError(response: Response) {
async function aiStreamError(response: Response) {
const body = (await response.json().catch(() => null)) as {
error?: { code?: string }
error?: { code?: string; message?: string }
} | null
switch (body?.error?.code) {
const code = body?.error?.code ?? "network-error"
handleGlobalApiError(code, body?.error?.message)
switch (code) {
case "login-required":
return new Error("请先登录")
case "account-disabled":
return new Error("账号已被禁用,请联系老师")
case "too-many-requests":
return new Error("AI 请求太频繁了,歇一会儿再试")
case "hint-locked":
@@ -114,3 +125,50 @@ export async function aiStreamError(response: Response) {
return new Error("AI 分析生成失败")
}
}
/** AI 流的事件形状,和 apps/api/src/services/ai.ts 的 streamChat / streamWhole 一致 */
type AIStreamEvent =
| { type: "delta"; content?: string }
| { type: "error"; message?: string }
| ({ type: "done" } & Record<string, unknown>)
export interface AIStreamHandlers<Done> {
onDelta: (content: string) => void
/** done 事件里后端并进来的额外字段(比如 AI 提示的 hintId / level) */
onDone?: (data: Done) => void
signal?: AbortSignal
}
/**
* 发起一次 AI 流式请求并读到结束。学情分析、班级分析、班级 PK、AI 提示四个入口共用 ——
* 原来是四份几乎一样的 fetch + SSE 解析,错误处理各漏各的。
*
* 失败抛 Error,`message` 是能直接给用户看的中文(后端下发的错误文案本来就是固定的
* 中文,见 services/ai.ts 的 CLIENT_ERROR_MESSAGE)。被 abort 时抛 AbortError,
* 调用方一般用 `useAIStream` 就不用自己分辨。
*/
export async function streamAI<Done = Record<string, unknown>>(
path: string,
body: unknown,
handlers: AIStreamHandlers<Done>,
) {
const response = await fetch(`/api/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: handlers.signal,
})
if (!response.ok) throw await aiStreamError(response)
await consumeJSONEventStream<AIStreamEvent>(response, {
signal: handlers.signal,
onMessage(event) {
if (event.type === "delta") {
if (event.content) handlers.onDelta(event.content)
} else if (event.type === "error") {
throw new Error(event.message || "AI 服务异常")
} else if (event.type === "done") {
handlers.onDone?.(event as Done)
}
},
})
}
+41
View File
@@ -6,6 +6,7 @@
"name": "oj2",
"devDependencies": {
"@types/bun": "latest",
"oxlint": "^1.85.0",
"prettier": "^3.9.6",
"typescript": "^5.9.3",
},
@@ -531,6 +532,44 @@
"@oxc-project/types": ["@oxc-project/types@0.146.0", "https://registry.npmjs.com/@oxc-project/types/-/types-0.146.0.tgz", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "https://registry.npmjs.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "https://registry.npmjs.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
@@ -1215,6 +1254,8 @@
"obug": ["obug@2.1.4", "https://registry.npmjs.com/obug/-/obug-2.1.4.tgz", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
"p-retry": ["p-retry@6.2.1", "https://registry.npmjs.com/p-retry/-/p-retry-6.2.1.tgz", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="],
"package-manager-detector": ["package-manager-detector@1.8.0", "https://registry.npmjs.com/package-manager-detector/-/package-manager-detector-1.8.0.tgz", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
+8
View File
@@ -12,6 +12,14 @@ POSTGRES_PASSWORD=
# openssl rand -hex 32
OJ2_JUDGE_TOKEN=
# Redis 口令。会话存在 Redis 里,能连上它就能伪造任意用户(包括超管)的登录态,
# 所以必须设。它会拼进 REDIS_URL,只能用 URL 安全的字符,自己生成:
# openssl rand -hex 32
# 自带数据形态下本栈的 oj-redis 直接用它;外接旧栈 redis 时,那边的 redis-server
# 也要带上同一个 --requirepass(见 docs/deploy.md「Redis 口令」)。
# 机房的 redis 不发布端口,只在 compose 网络里,不需要设。
REDIS_PASSWORD=
# 判题并发。受判题沙箱能力限制,不是越大越好。
# 服务器 2,机房机器宽裕可以给 4。
JUDGE_CONCURRENCY=2
+17 -5
View File
@@ -40,23 +40,35 @@ set -euo pipefail
CONTAINER="${CONTAINER:-oj-redis}"
# redis 带口令时(服务器那台),redis-cli 靠 REDISCLI_AUTH 认证。没显式给就从
# docker/.env 里读;机房和本机 dev 的 redis 不带口令,读不到就留空,不影响。
if [ -z "${REDIS_PASSWORD:-}" ] && [ -f "$(dirname "$0")/.env" ]; then
REDIS_PASSWORD=$(grep -E '^REDIS_PASSWORD=' "$(dirname "$0")/.env" | tail -1 | cut -d= -f2- || true)
fi
# 口令为空时连 -e 都不能带:空的 REDISCLI_AUTH 会让 redis-cli 发一条 AUTH "",
# 在不带口令的 redis 上报一行 AUTH failed(实测,虽然随后命令照常执行)。
EXEC=(docker exec)
[ -z "${REDIS_PASSWORD:-}" ] || EXEC+=(-e REDISCLI_AUTH="$REDIS_PASSWORD")
EXEC+=("$CONTAINER" redis-cli)
cli() { "${EXEC[@]}" "$@"; }
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
docker exec "$CONTAINER" redis-cli ping >/dev/null 2>&1 \
|| die "连不上容器 $CONTAINER 里的 redis(用 CONTAINER=... 指定容器名)"
[ "$(cli ping 2>/dev/null)" = PONG ] \
|| die "连不上容器 $CONTAINER 里的 redis(用 CONTAINER=... 指定容器名;带口令的话检查 REDIS_PASSWORD)"
# 用 SCAN 而不是 KEYS:KEYS 会阻塞住整个 Redis,而这上面还挂着判题队列和所有人的
# 会话读写。xargs 分批是因为单条 DEL 传太多 key 会顶到命令行长度上限;每批 DEL 返回
# 删掉的个数,累加起来就是总数。
purge() {
docker exec "$CONTAINER" redis-cli --scan --pattern "$1" 2>/dev/null \
| xargs -r -n 400 docker exec "$CONTAINER" redis-cli del \
cli --scan --pattern "$1" 2>/dev/null \
| xargs -r -n 400 "${EXEC[@]}" del \
| awk '{ sum += $1 } END { print sum + 0 }'
}
count() { docker exec "$CONTAINER" redis-cli --scan --pattern "$1" 2>/dev/null | wc -l; }
count() { cli --scan --pattern "$1" 2>/dev/null | wc -l; }
say "容器 $CONTAINER"
before_sessions=$(count 'session:*')
+9 -2
View File
@@ -61,10 +61,16 @@ services:
# redis 里没有非丢不可的东西(会话、判题队列),用旧的那个也无所谓 ——
# 旧后端已经停了,新后端独占它,剩下的 Django / dramatiq 残留键前缀不同,互不干扰。
profiles: ["local-data"]
# 会话存在这里:谁能连上它,谁就能写一条 session:<token> 冒充超管。所以必须带密码,
# 端口也只发布在本机(api / worker 走 compose 网络连 oj-redis,用不着宿主机端口)。
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?}"]
environment:
# redis-cli 认这个变量,healthcheck 和 docker exec 进来的 redis-cli 都不用再带 -a
REDISCLI_AUTH: ${REDIS_PASSWORD:?}
volumes:
- ${DATA_DIR:-../data}/redis:/data
ports:
- "5446:6379"
- "127.0.0.1:5446:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -134,7 +140,8 @@ services:
- ${DATA_DIR:-../data}/backend:/data
environment: &api-env
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@${DB_HOST:-oj-postgres}:${DB_PORT:-5432}/onlinejudge
REDIS_URL: redis://${REDIS_HOST:-oj-redis}:${REDIS_PORT:-6379}
# 密码拼进 URL,所以只能用 URL 安全的字符 —— 用 openssl rand -hex 32 生成
REDIS_URL: redis://:${REDIS_PASSWORD:?请在 docker/.env 里设置 REDIS_PASSWORD}@${REDIS_HOST:-oj-redis}:${REDIS_PORT:-6379}
JUDGE_SERVER_URL: http://oj-judge:8080
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
+3 -3
View File
@@ -35,7 +35,7 @@ services:
POSTGRES_USER: onlinejudge
POSTGRES_PASSWORD: onlinejudge
ports:
- "5433:5432" # 5433 避开可能存在的本机 5432
- "127.0.0.1:5433:5432" # 5433 避开可能存在的本机 5432
volumes:
- oj2-pgdata:/var/lib/postgresql/data
healthcheck:
@@ -49,7 +49,7 @@ services:
container_name: oj2-redis
restart: unless-stopped
ports:
- "6380:6379" # 6380 避开可能存在的本机 6379
- "127.0.0.1:6380:6379" # 6380 避开可能存在的本机 6379
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -75,7 +75,7 @@ services:
tmpfs:
- /tmp
ports:
- "8081:8080"
- "127.0.0.1:8081:8080"
volumes:
- ../data/test_case:/test_case:ro
- ../data/judge_server/log:/log
+11
View File
@@ -175,6 +175,17 @@ if [ "$LOCAL_DATA" -eq 0 ]; then
|| die "外接形态依赖的 $c 没在跑 —— 先 docker compose -f /root/OJDeploy/docker-compose.yml up -d $c"
done
ok "外接的 postgres / redis 都在跑"
# 外接的 redis 不归本栈管,口令是否真的生效只能在这里查。旧栈那份 compose 的 redis
# 原本没有口令、端口发布在公网上 —— 会话存在里面,等于人人可以伪造超管登录态。
# 不带口令能 PING 通就说明那边还没加 --requirepass,这种状态不许上线。
redis_pw=$(grep -E '^REDIS_PASSWORD=' docker/.env | tail -1 | cut -d= -f2- || true)
[ -n "$redis_pw" ] || die "docker/.env 里没有 REDIS_PASSWORD(openssl rand -hex 32 生成)"
[ "$(docker exec -e REDISCLI_AUTH= oj-redis redis-cli ping 2>/dev/null)" != PONG ] \
|| die "外接的 oj-redis 不带口令也能连上 —— 先给它加 --requirepass,见 docs/deploy.md「Redis 口令」"
[ "$(docker exec -e REDISCLI_AUTH="$redis_pw" oj-redis redis-cli ping 2>/dev/null)" = PONG ] \
|| die "用 docker/.env 的 REDIS_PASSWORD 连不上外接的 oj-redis —— 两边口令不一致"
ok "外接的 redis 要求口令,且和 docker/.env 一致"
else
ok "postgres / redis 由本栈起,不预检"
fi
+25
View File
@@ -33,6 +33,31 @@ Redis)和 WebSocket 推送(本地 pub/sub)是每站独立的 —— 学生
沿用旧数据却忘了设它,会静默挂上一堆空目录:空库、没测试点、题面图片 404,
**而且不报错**。这是整个部署里唯一会静默走歪的地方,`deploy.sh` 专门为它设了一道自检。
## Redis 口令
**会话存在 Redis 里**(`session:<token>` → 用户 id),谁能连上它,谁就能写一条会话
冒充任意用户,包括超管。所以服务器那台的 Redis **必须带口令**:`compose.debian.yml`
的 `REDIS_URL` 用 `${REDIS_PASSWORD:?}` 强制要求,`deploy.sh` 在外接形态下还会实测
「不带口令连不上、带 `docker/.env` 的口令连得上」,两条有一条不满足就中止部署。
2026-09-27 以前,旧栈 compose 起的那个 `oj-redis` 发布在 `0.0.0.0:5446`、不带口令,
**从公网直接 PING 得通**(实测过)。给它加口令的一次性步骤,在服务器上:
```bash
cd /root/OJDeploy
PW=$(openssl rand -hex 32)
echo "REDIS_PASSWORD=$PW" >> OJ2/docker/.env
# 编辑 docker-compose.yml 的 oj-redis,加一行($PW 换成上面的值,别提交到任何仓库):
# command: ["redis-server", "--requirepass", "<PW>"]
docker compose up -d oj-redis # 重建 redis,此刻起旧口令(无)的连接全部失败
cd OJ2 && docker/deploy.sh # 马上重新部署,api / worker 带上口令
```
两步之间 api 连不上 Redis,站点会 500 一两分钟,挑没人用的时候做。
会话会保留(redis 退出时落盘 RDB),学生不用重新登录。
机房那台的 `oj-redis` 不发布端口、只在 compose 网络里,不需要口令。
## 上线
### 服务器:push 就部署
+4 -2
View File
@@ -13,11 +13,13 @@
"db:generate": "bun run --filter '@oj2/api' db:generate",
"db:migrate": "bun run --filter '@oj2/api' db:migrate",
"db:down": "docker compose -f docker/compose.dev.yml down",
"lint": "oxlint --deny-warnings apps/api/src apps/web/src packages/contract/src",
"fmt": "prettier --write apps/api/src apps/web/src apps/web/tests packages/contract/src apps/api/drizzle.config.ts apps/web/vite.config.ts docker/judge/smoke.ts"
},
"devDependencies": {
"@types/bun": "latest",
"oxlint": "^1.85.0",
"prettier": "^3.9.6",
"typescript": "^5.9.3",
"@types/bun": "latest"
"typescript": "^5.9.3"
}
}
+5 -2
View File
@@ -5,8 +5,11 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"zod": "^4.4.3"
}
},
"sideEffects": false
}
+5 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
import { adminTypeSchema, problemPermissionSchema } from "./roles"
import { ADMIN_TYPES, PROBLEM_PERMISSIONS } from "./roles"
import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account"
@@ -14,6 +14,10 @@ import {
sqlDisplaySchema,
} from "./problem"
// 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头
export const adminTypeSchema = z.enum(ADMIN_TYPES)
export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS)
/**
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
* (后台要 `visible` 这类管理字段,oj 侧连键都不该出现),
+3 -4
View File
@@ -1,5 +1,3 @@
import { z } from "zod"
/**
* 角色与题目权限的字面量。**全仓唯一的定义处。**
*
@@ -10,6 +8,9 @@ import { z } from "zod"
* ADMIN_ROLES / TEACHER_ROLES,`["Regular User", "Student Admin"]` 这个学生口径
* 在四个文件里各写一遍,另有二十多处散落的 `=== "Super Admin"`。拼错一个字母
* TypeScript 一个字都不会说,只会在运行时静默放行或静默拒绝。
*
* **这个文件不能 import zod。** 前端入口要用这里的 USER_TYPE / toAdminType,
* 一 import 就把整个 zod 运行时(~100KB)拖进首屏包。对应的 zod schema 在 admin.ts。
*/
export const ADMIN_TYPES = [
"Regular User",
@@ -18,11 +19,9 @@ export const ADMIN_TYPES = [
"Super Admin",
] as const
export type AdminType = (typeof ADMIN_TYPES)[number]
export const adminTypeSchema = z.enum(ADMIN_TYPES)
export const PROBLEM_PERMISSIONS = ["None", "Own", "All"] as const
export type ProblemPermission = (typeof PROBLEM_PERMISSIONS)[number]
export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS)
/**
* 三个分组一律是**白名单**,对齐旧后端 `account/models.py:65-73` 的显式列举写法。