chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
Deploy / deploy (push) Has been cancelled

原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 08:27:34 -06:00
co-authored by Claude Opus 5
parent e600fd24cf
commit ed56a209ea
122 changed files with 10557 additions and 4843 deletions
+7
View File
@@ -0,0 +1,7 @@
# drizzle-kit 生成的迁移快照。内容等价的重排也别做 —— 这些文件是
# db:generate 拿来比对上一版结构的输入,只该由 drizzle-kit 写。
apps/api/src/db/meta/
# unplugin 每次 dev 都会重写,格式化了也留不住
apps/web/src/auto-imports.d.ts
apps/web/src/components.d.ts
+8
View File
@@ -56,8 +56,16 @@ bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路
bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑 bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑
cd apps/web && bun run type-check # 前端类型检查 cd apps/web && bun run type-check # 前端类型检查
cd apps/web && bun run build # 前端构建 cd apps/web && bun run build # 前端构建
bun run fmt # Prettier,全仓一把(只在根目录有)
``` ```
**格式化是全仓一套 Prettier**,配置只有根目录的 `.prettierrc.toml``semi=false`
其余全默认,printWidth 80)。`bun run fmt` 覆盖 `apps/*/src``packages/*/src` 和两个
构建配置;`.prettierignore` 挡掉 drizzle-kit 生成的 `src/db/meta/` 快照和 unplugin
每次 dev 都会重写的两个 `.d.ts`。后端和契约原来没进 Prettier(手写在 100 列上下),
2026-09-16 一次性全量格式化过 —— 之后**改完代码顺手跑一下 `bun run fmt`**
别再让两边的口径分叉。
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默 ⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默
通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` + 通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` +
references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build` 根本不做类型检查。 references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build` 根本不做类型检查。
+3 -1
View File
@@ -5,6 +5,8 @@ export default defineConfig({
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
out: "./src/db", out: "./src/db",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge", url:
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
}, },
}) })
+13 -5
View File
@@ -51,20 +51,27 @@ function requireRole(
return async (c, next) => { return async (c, next) => {
const session = await resolveSession(c) const session = await resolveSession(c)
if (!session.user) return denied(c, session.reason) if (!session.user) return denied(c, session.reason)
if (!allowed(session.user)) return failure(c, 403, "permission-denied", "权限不足") if (!allowed(session.user))
return failure(c, 403, "permission-denied", "权限不足")
c.set("user", session.user) c.set("user", session.user)
await next() await next()
} }
} }
/** 旧 `@admin_role_required` */ /** 旧 `@admin_role_required` */
export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.adminType)) export const requireAdmin = requireRole((user) =>
ADMIN_ROLES.includes(user.adminType),
)
/** 旧 `@teacher_admin_required` */ /** 旧 `@teacher_admin_required` */
export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType)) export const requireTeacher = requireRole((user) =>
TEACHER_ROLES.includes(user.adminType),
)
/** 旧 `@super_admin_required` */ /** 旧 `@super_admin_required` */
export const requireSuperAdmin = requireRole((user) => user.adminType === "Super Admin") export const requireSuperAdmin = requireRole(
(user) => user.adminType === "Super Admin",
)
/** /**
* 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。 * 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。
@@ -72,5 +79,6 @@ export const requireSuperAdmin = requireRole((user) => user.adminType === "Super
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。 * created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
*/ */
export const requireProblemPermission = requireRole( export const requireProblemPermission = requireRole(
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None", (user) =>
ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
) )
+5 -1
View File
@@ -16,7 +16,11 @@ async function verifyDjangoPbkdf2(password: string, encoded: string) {
const iterations = Number(iterationsText) const iterations = Number(iterationsText)
const expected = Buffer.from(digestText, "base64") const expected = Buffer.from(digestText, "base64")
if (!Number.isSafeInteger(iterations) || iterations <= 0 || expected.length === 0) { if (
!Number.isSafeInteger(iterations) ||
iterations <= 0 ||
expected.length === 0
) {
return false return false
} }
+14 -3
View File
@@ -77,7 +77,12 @@ export async function createSession(
// 全压在登录这一下上 // 全压在登录这一下上
const pipeline = redis const pipeline = redis
.pipeline() .pipeline()
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds) .set(
sessionKey(token),
JSON.stringify(value),
"EX",
config.sessionTtlSeconds,
)
.sadd(userSessionsKey(userId), token) .sadd(userSessionsKey(userId), token)
.expire(userSessionsKey(userId), config.sessionTtlSeconds) .expire(userSessionsKey(userId), config.sessionTtlSeconds)
markOnline(pipeline, userId) markOnline(pipeline, userId)
@@ -156,7 +161,9 @@ export type SessionResult =
| { user: AuthUser; reason?: undefined } | { user: AuthUser; reason?: undefined }
| { user: null; reason: "anonymous" | "disabled" } | { user: null; reason: "anonymous" | "disabled" }
async function getUserByToken(token: string | undefined): Promise<SessionResult> { async function getUserByToken(
token: string | undefined,
): Promise<SessionResult> {
if (!token) return { user: null, reason: "anonymous" } if (!token) return { user: null, reason: "anonymous" }
const raw = await redis.get(sessionKey(token)) const raw = await redis.get(sessionKey(token))
@@ -285,7 +292,11 @@ async function getStoredSession(c: Context) {
} }
} }
export async function setContestPassword(c: Context, contestId: number, password: string) { export async function setContestPassword(
c: Context,
contestId: number,
password: string,
) {
const session = await getStoredSession(c) const session = await getStoredSession(c)
if (!session) return false if (!session) return false
session.value.contestPasswords[String(contestId)] = password session.value.contestPasswords[String(contestId)] = password
+34 -9
View File
@@ -86,7 +86,10 @@ export function handleCollabOpen(ws: CollabSocket) {
addTeacher(ws) addTeacher(ws)
// 新上线的老师要立刻看到当前队列,不能等下一次变更 // 新上线的老师要立刻看到当前队列,不能等下一次变更
ws.send( ws.send(
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }), JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
}),
) )
return return
} }
@@ -159,7 +162,8 @@ export function handleCollabClose(ws: CollabSocket) {
closeRoom(room.studentId) closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket const peer =
ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" })) peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" }))
if (ws === room.teacherSocket) { if (ws === room.teacherSocket) {
@@ -197,7 +201,9 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
// 心跳不查库,和 /ws/submissions 的处理一致 // 心跳不查库,和 /ws/submissions 的处理一致
if (message.type === "ping") { if (message.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: (message as any).timestamp })) ws.send(
JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }),
)
return return
} }
@@ -261,7 +267,9 @@ async function handleHelpRequest(
) )
.limit(1) .limit(1)
if (!problem) { if (!problem) {
ws.send(JSON.stringify({ type: "error", message: "题目不存在或不支持求助" })) ws.send(
JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }),
)
return return
} }
@@ -339,7 +347,12 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
const [teacher] = await db const [teacher] = await db
.select({ adminType: schema.user.adminType }) .select({ adminType: schema.user.adminType })
.from(schema.user) .from(schema.user)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false))) .where(
and(
eq(schema.user.id, ws.data.userId),
eq(schema.user.isDisabled, false),
),
)
.limit(1) .limit(1)
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) { if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
ws.close(1008, "Permission revoked") ws.close(1008, "Permission revoked")
@@ -363,7 +376,10 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)—— // (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
// 回一份最新列表让老师端自己纠正 // 回一份最新列表让老师端自己纠正
ws.send( ws.send(
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }), JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
}),
) )
return return
} }
@@ -404,7 +420,12 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
const [teacher] = await db const [teacher] = await db
.select({ adminType: schema.user.adminType }) .select({ adminType: schema.user.adminType })
.from(schema.user) .from(schema.user)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false))) .where(
and(
eq(schema.user.id, ws.data.userId),
eq(schema.user.isDisabled, false),
),
)
.limit(1) .limit(1)
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) { if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
ws.close(1008, "Permission revoked") ws.close(1008, "Permission revoked")
@@ -461,7 +482,10 @@ function teardownRoom(
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间, * 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。 * 权限由 accept 时的库查询决定,与帧里装的是什么无关。
*/ */
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) { export function handleCollabBinary(
ws: CollabSocket,
data: Buffer | Uint8Array,
) {
// 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值, // 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值,
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。 // 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉 // 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
@@ -469,7 +493,8 @@ export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array)
const room = roomOf(ws) const room = roomOf(ws)
if (!room) return if (!room) return
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket const peer =
ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
const sent = peer.send(data) const sent = peer.send(data)
// Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB // Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
// 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0 // 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
+5 -4
View File
@@ -6,7 +6,9 @@
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。 * 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
*/ */
export type CollabSocket = Bun.ServerWebSocket<import("../websocket").SubmissionSocketData> export type CollabSocket = Bun.ServerWebSocket<
import("../websocket").SubmissionSocketData
>
/** /**
* 协作支持的语言。和前端 utils/types.ts 里的 LANGUAGE 对齐,去掉 Flowchart —— * 协作支持的语言。和前端 utils/types.ts 里的 LANGUAGE 对齐,去掉 Flowchart ——
@@ -69,7 +71,6 @@ export function removeRequest(studentId: number) {
return requests.delete(studentId) return requests.delete(studentId)
} }
/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */ /** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */
export function listRequests() { export function listRequests() {
return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt) return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt)
@@ -81,7 +82,8 @@ export function queueAheadOf(studentId: number) {
if (!self) return 0 if (!self) return 0
let ahead = 0 let ahead = 0
for (const request of requests.values()) { for (const request of requests.values()) {
if (request.status === "pending" && request.createdAt < self.createdAt) ahead += 1 if (request.status === "pending" && request.createdAt < self.createdAt)
ahead += 1
} }
return ahead return ahead
} }
@@ -132,4 +134,3 @@ export function roomOf(ws: CollabSocket) {
const ownerId = ws.data.roomOwnerId const ownerId = ws.data.roomOwnerId
return ownerId === undefined ? undefined : rooms.get(ownerId) return ownerId === undefined ? undefined : rooms.get(ownerId)
} }
+13 -4
View File
@@ -25,7 +25,10 @@ function loadRepoRootEnv() {
if (eq <= 0) continue if (eq <= 0) continue
const key = trimmed.slice(0, eq).trim() const key = trimmed.slice(0, eq).trim()
if (process.env[key] !== undefined) continue if (process.env[key] !== undefined) continue
process.env[key] = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "") process.env[key] = trimmed
.slice(eq + 1)
.trim()
.replace(/^["']|["']$/g, "")
} }
} catch { } catch {
// 根目录没有 .env 是正常情况(例如生产用真实环境变量注入),静默跳过 // 根目录没有 .env 是正常情况(例如生产用真实环境变量注入),静默跳过
@@ -64,18 +67,24 @@ export const config = {
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380", redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
sessionCookie: "oj2_session", sessionCookie: "oj2_session",
sessionTtlSeconds: Number(process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60), sessionTtlSeconds: Number(
process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60,
),
secureCookies: process.env.COOKIE_SECURE === "true", secureCookies: process.env.COOKIE_SECURE === "true",
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081", judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
judgeServerToken: judgeServerToken(), judgeServerToken: judgeServerToken(),
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2), judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"), avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处 // 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
testCaseDirectory: repoPath(process.env.TEST_CASE_DIRECTORY ?? "data/test_case"), testCaseDirectory: repoPath(
process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
),
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"), uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。 // 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。 // 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
hitokotoDirectory: repoPath(process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto"), hitokotoDirectory: repoPath(
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
),
/** /**
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。 * WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
* 同源本来就放行,只有前后端分处不同域名时才需要配。 * 同源本来就放行,只有前后端分处不同域名时才需要配。
+3 -1
View File
@@ -3,7 +3,9 @@ import postgres from "postgres"
import * as schema from "./schema" import * as schema from "./schema"
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge" const url =
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone``../time` 的 localTime), // 不设会话时区:日历语义的 SQL 一律显式 `at time zone``../time` 的 localTime),
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。 // 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。
+20 -6
View File
@@ -57,7 +57,9 @@ export async function runMigrations() {
process.exit(2) process.exit(2)
} }
if (files.length === 0) { if (files.length === 0) {
console.error(`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`) console.error(
`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`,
)
process.exit(2) process.exit(2)
} }
@@ -136,10 +138,16 @@ export async function runMigrations() {
// 自举时不拦:空库上没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转。 // 自举时不拦:空库上没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转。
// 拦下来只会逼着每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE,把这道闸训练成习惯动作 —— // 拦下来只会逼着每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE,把这道闸训练成习惯动作 ——
// 那正是它想避免的事。 // 那正是它想避免的事。
if (blocked.length > 0 && !bootstrapping && process.env.OJ2_ALLOW_DESTRUCTIVE !== "1") { if (
blocked.length > 0 &&
!bootstrapping &&
process.env.OJ2_ALLOW_DESTRUCTIVE !== "1"
) {
console.error( console.error(
"待执行的迁移里有破坏性语句,已停下:\n" + "待执行的迁移里有破坏性语句,已停下:\n" +
blocked.map(({ tag, reasons }) => ` · ${tag}${reasons.join(" / ")}`).join("\n") + blocked
.map(({ tag, reasons }) => ` · ${tag}${reasons.join(" / ")}`)
.join("\n") +
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" + "\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
"\n确认已经做过备份之后,用这个显式放行:\n\n" + "\n确认已经做过备份之后,用这个显式放行:\n\n" +
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n", " OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
@@ -180,7 +188,9 @@ export async function runMigrations() {
function destructiveReasons(sql: string) { function destructiveReasons(sql: string) {
const bare = stripComments(sql) const bare = stripComments(sql)
return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(([, label]) => label) return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(
([, label]) => label,
)
} }
/** /**
@@ -192,7 +202,9 @@ function destructiveReasons(sql: string) {
*/ */
function readMigrationTags(): Map<number, string> { function readMigrationTags(): Map<number, string> {
try { try {
const journal = JSON.parse(readFileSync(`${migrationsDir}/meta/_journal.json`, "utf8")) as { const journal = JSON.parse(
readFileSync(`${migrationsDir}/meta/_journal.json`, "utf8"),
) as {
entries?: Array<{ when: number; tag: string }> entries?: Array<{ when: number; tag: string }>
} }
return new Map((journal.entries ?? []).map((e) => [e.when, e.tag])) return new Map((journal.entries ?? []).map((e) => [e.when, e.tag]))
@@ -234,7 +246,9 @@ async function applyMigration(
) { ) {
// 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后 // 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后
// 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。 // 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。
const statements = migration.sql.filter((stmt) => stripComments(stmt).trim() !== "") const statements = migration.sql.filter(
(stmt) => stripComments(stmt).trim() !== "",
)
if (statements.length === 0) { if (statements.length === 0) {
// 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过 // 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过
throw new Error(`${tag} 没有任何可执行语句`) throw new Error(`${tag} 没有任何可执行语句`)
+739 -228
View File
File diff suppressed because it is too large Load Diff
+27 -8
View File
@@ -13,7 +13,10 @@ export const configUpdateChannel = "config:updates"
export const configTopic = "events:config" export const configTopic = "events:config"
export async function publishConfigUpdate(key: string, value: unknown) { export async function publishConfigUpdate(key: string, value: unknown) {
await redis.publish(configUpdateChannel, JSON.stringify({ type: "config_update", key, value })) await redis.publish(
configUpdateChannel,
JSON.stringify({ type: "config_update", key, value }),
)
} }
/** /**
@@ -39,14 +42,19 @@ export async function publishSessionRevoked(
target: { token: string } | { userId: number }, target: { token: string } | { userId: number },
reason: SessionRevokedReason, reason: SessionRevokedReason,
) { ) {
await redis.publish(sessionRevokedChannel, JSON.stringify({ ...target, reason })) await redis.publish(
sessionRevokedChannel,
JSON.stringify({ ...target, reason }),
)
} }
export function parseSessionRevoked(raw: string): SessionRevoked | null { export function parseSessionRevoked(raw: string): SessionRevoked | null {
try { try {
const value = JSON.parse(raw) as SessionRevoked const value = JSON.parse(raw) as SessionRevoked
if (typeof value.token !== "string" && !Number.isInteger(value.userId)) return null if (typeof value.token !== "string" && !Number.isInteger(value.userId))
if (value.reason !== "session-ended" && value.reason !== "account-disabled") return null return null
if (value.reason !== "session-ended" && value.reason !== "account-disabled")
return null
return value return value
} catch { } catch {
return null return null
@@ -71,7 +79,10 @@ export function userEventTopic(userId: number) {
return `events:user:${userId}` return `events:user:${userId}`
} }
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) { export async function publishFlowchartUpdate(
userId: number,
data: FlowchartUpdate,
) {
await redis.publish(userEventChannel, JSON.stringify({ userId, data })) await redis.publish(userEventChannel, JSON.stringify({ userId, data }))
} }
@@ -80,16 +91,24 @@ export async function publishAchievementNotification(
achievements: AchievementNotification[], achievements: AchievementNotification[],
) { ) {
if (!achievements.length) return if (!achievements.length) return
await redis.publish(userEventChannel, JSON.stringify({ await redis.publish(
userEventChannel,
JSON.stringify({
userId, userId,
data: { type: "achievement_unlocked", achievements }, data: { type: "achievement_unlocked", achievements },
})) }),
)
} }
export function parseUserEvent(raw: string): UserEvent | null { export function parseUserEvent(raw: string): UserEvent | null {
try { try {
const value = JSON.parse(raw) as UserEvent const value = JSON.parse(raw) as UserEvent
if (!Number.isInteger(value.userId) || !value.data || typeof value.data !== "object") return null if (
!Number.isInteger(value.userId) ||
!value.data ||
typeof value.data !== "object"
)
return null
return value return value
} catch { } catch {
return null return null
+35 -12
View File
@@ -32,14 +32,18 @@ function parseEvaluation(value: string) {
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0] const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
if (!json) throw new Error("AI response did not contain JSON") if (!json) throw new Error("AI response did not contain JSON")
const data = JSON.parse(json) as Record<string, unknown> const data = JSON.parse(json) as Record<string, unknown>
if (typeof data.score !== "number" || Number.isNaN(data.score)) throw new Error("AI response is missing score") if (typeof data.score !== "number" || Number.isNaN(data.score))
throw new Error("AI response is missing score")
const score = Math.max(0, Math.min(100, data.score)) const score = Math.max(0, Math.min(100, data.score))
return { return {
score, score,
grade: gradeForScore(score), grade: gradeForScore(score),
feedback: typeof data.feedback === "string" ? data.feedback : "", feedback: typeof data.feedback === "string" ? data.feedback : "",
suggestions: typeof data.suggestions === "string" ? data.suggestions : "", suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {}, criteria:
data.criteria_details && typeof data.criteria_details === "object"
? data.criteria_details
: {},
} }
} }
@@ -47,19 +51,34 @@ export async function evaluateFlowchart(
job: FlowchartJobData, job: FlowchartJobData,
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {}, { isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
) { ) {
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission) const [row] = await db
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1) .from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(eq(schema.flowchartSubmission.id, job.submissionId))
.limit(1)
if (!row || ![0, 1].includes(row.flowchart.status)) return if (!row || ![0, 1].includes(row.flowchart.status)) return
await db.update(schema.flowchartSubmission).set({ status: 1 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await db
.update(schema.flowchartSubmission)
.set({ status: 1 })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
const started = performance.now() const started = performance.now()
try { try {
const reference = row.problem.mermaidCode ? `\n标准答案参考:\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。" const reference = row.problem.mermaidCode
const result = parseEvaluation(await completeChat( ? `\n标准答案参考:\n${row.problem.mermaidCode}`
: "\n此题没有标准流程图。"
const result = parseEvaluation(
await completeChat(
evaluationPrompt(row.problem), evaluationPrompt(row.problem),
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`, `学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
)) ),
await db.update(schema.flowchartSubmission).set({ )
await db
.update(schema.flowchartSubmission)
.set({
status: 2, status: 2,
aiScore: result.score, aiScore: result.score,
aiGrade: result.grade, aiGrade: result.grade,
@@ -70,7 +89,8 @@ export async function evaluateFlowchart(
aiModel: process.env.AI_MODEL ?? "deepseek-flash", aiModel: process.env.AI_MODEL ?? "deepseek-flash",
processingTime: (performance.now() - started) / 1000, processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(), evaluationTime: new Date().toISOString(),
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, { await publishFlowchartUpdate(row.flowchart.userId, {
type: "flowchart_evaluation_completed", type: "flowchart_evaluation_completed",
submissionId: row.flowchart.id, submissionId: row.flowchart.id,
@@ -90,7 +110,10 @@ export async function evaluateFlowchart(
// 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做 // 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
// 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。 // 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
if (!isFinalAttempt) throw error if (!isFinalAttempt) throw error
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await db
.update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, { await publishFlowchartUpdate(row.flowchart.userId, {
type: "flowchart_evaluation_failed", type: "flowchart_evaluation_failed",
submissionId: row.flowchart.id, submissionId: row.flowchart.id,
+22 -6
View File
@@ -64,7 +64,11 @@ const DEFAULT_AVATAR_SVG =
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘: * 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。 * 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
*/ */
async function serveUpload(pathname: string, prefix: string, directory: string) { async function serveUpload(
pathname: string,
prefix: string,
directory: string,
) {
const decoded = decodeURIComponent(pathname) const decoded = decodeURIComponent(pathname)
const filename = basename(decoded) const filename = basename(decoded)
if (!filename || filename !== decoded.slice(prefix.length + 1)) { if (!filename || filename !== decoded.slice(prefix.length + 1)) {
@@ -73,7 +77,9 @@ async function serveUpload(pathname: string, prefix: string, directory: string)
const file = Bun.file(resolve(directory, filename)) const file = Bun.file(resolve(directory, filename))
if (await file.exists()) { if (await file.exists()) {
// 文件名由后端生成且内容不变,可以放心长缓存 // 文件名由后端生成且内容不变,可以放心长缓存
return new Response(file, { headers: { "cache-control": "public, max-age=86400" } }) return new Response(file, {
headers: { "cache-control": "public, max-age=86400" },
})
} }
return null return null
} }
@@ -83,11 +89,18 @@ const server = Bun.serve<SubmissionSocketData>({
async fetch(request, bunServer) { async fetch(request, bunServer) {
const url = new URL(request.url) const url = new URL(request.url)
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) { if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory) const hit = await serveUpload(
url.pathname,
config.avatarUriPrefix,
config.avatarDirectory,
)
if (hit) return hit if (hit) return hit
if (basename(decodeURIComponent(url.pathname)) === "default.png") { if (basename(decodeURIComponent(url.pathname)) === "default.png") {
return new Response(DEFAULT_AVATAR_SVG, { return new Response(DEFAULT_AVATAR_SVG, {
headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" }, headers: {
"content-type": "image/svg+xml",
"cache-control": "public, max-age=3600",
},
}) })
} }
return new Response("Not found", { status: 404 }) return new Response("Not found", { status: 404 })
@@ -96,8 +109,11 @@ const server = Bun.serve<SubmissionSocketData>({
// 但没有任何路由伺服它,题面图片一律 404。 // 但没有任何路由伺服它,题面图片一律 404。
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) { if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
return ( return (
(await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ?? (await serveUpload(
new Response("Not found", { status: 404 }) url.pathname,
config.uploadUriPrefix,
config.uploadDirectory,
)) ?? new Response("Not found", { status: 404 })
) )
} }
if ( if (
+25 -10
View File
@@ -39,13 +39,15 @@ async function loadLanguage(language: string) {
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null
// locateFile 指到内嵌的 tree-sitter.wasmemscripten 默认按脚本所在目录找, // locateFile 指到内嵌的 tree-sitter.wasmemscripten 默认按脚本所在目录找,
// 单二进制里那个目录是 /$bunfs/root,它自己找不着 // 单二进制里那个目录是 /$bunfs/root,它自己找不着
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath }) if (!initPromise)
initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
await initPromise await initPromise
const cached = languages.get(language) const cached = languages.get(language)
if (cached) return cached if (cached) return cached
const wasmPath = language === "C" const wasmPath =
language === "C"
? cWasmPath ? cWasmPath
: language === "C++" : language === "C++"
? cppWasmPath ? cppWasmPath
@@ -136,7 +138,8 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。 * checkAst 直接放行 —— 学生看得见要求,判题从不检查。
*/ */
export function astRequirements(value: unknown): AstRequirements | null { export function astRequirements(value: unknown): AstRequirements | null {
const grouped = value && typeof value === "object" && !Array.isArray(value) const grouped =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
: null : null
if (!grouped) return null if (!grouped) return null
@@ -148,10 +151,12 @@ export function astRequirements(value: unknown): AstRequirements | null {
const parsed = astRuleSchema.safeParse(rule) const parsed = astRuleSchema.safeParse(rule)
if (!parsed.success) return [] if (!parsed.success) return []
if (!astRuleIsMeaningful(parsed.data)) return [] if (!astRuleIsMeaningful(parsed.data)) return []
return [{ return [
{
description: describeAstRule(parsed.data, language), description: describeAstRule(parsed.data, language),
kind: requirementKind(parsed.data.engine), kind: requirementKind(parsed.data.engine),
}] },
]
}) })
if (items.length > 0) out[language] = items if (items.length > 0) out[language] = items
} }
@@ -180,12 +185,15 @@ export function astRulesError(astRules: AstRules | null): string | null {
const at = `代码规则 ${language}${index + 1}` const at = `代码规则 ${language}${index + 1}`
const target = rule.target ?? "" const target = rule.target ?? ""
if (rule.engine.endsWith("_node")) { if (rule.engine.endsWith("_node")) {
if (!(target in nodes)) return `${at}${language} 没有「${target}」这种语法` if (!(target in nodes))
return `${at}${language} 没有「${target}」这种语法`
} else if (rule.engine === "must_use_operator") { } else if (rule.engine === "must_use_operator") {
if (!(target in operators)) return `${at}${language} 没有「${target}」运算符` if (!(target in operators))
return `${at}${language} 没有「${target}」运算符`
} else if (rule.engine === "must_have_nesting") { } else if (rule.engine === "must_have_nesting") {
for (const value of [rule.outer ?? "", rule.inner ?? ""]) { for (const value of [rule.outer ?? "", rule.inner ?? ""]) {
if (!(value in nodes)) return `${at}${language} 没有「${value}」这种语法` if (!(value in nodes))
return `${at}${language} 没有「${value}」这种语法`
} }
} else if (!target.trim()) { } else if (!target.trim()) {
return `${at}:要检查的函数名/方法名不能为空` return `${at}:要检查的函数名/方法名不能为空`
@@ -204,7 +212,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
* 早年配过 C++ 规则,如今 tab 里看不到那组规则,保存却被「暂不支持 C++」拦下, * 早年配过 C++ 规则,如今 tab 里看不到那组规则,保存却被「暂不支持 C++」拦下,
* 老师在界面上无从修改。 * 老师在界面上无从修改。
*/ */
export function pickAstRules(astRules: AstRules | null, languages: string[]): AstRules | null { export function pickAstRules(
astRules: AstRules | null,
languages: string[],
): AstRules | null {
if (!astRules) return null if (!astRules) return null
const out: AstRules = {} const out: AstRules = {}
for (const [language, rules] of Object.entries(astRules)) { for (const [language, rules] of Object.entries(astRules)) {
@@ -266,7 +277,11 @@ function methodCalls(root: Node, target: string, language: string) {
}) })
} }
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null { function evaluateRule(
root: Node,
rule: AstRule,
language: string,
): AstResult | null {
const target = rule.target ?? "" const target = rule.target ?? ""
const nodeType = astTargetNodeType(target, language) const nodeType = astTargetNodeType(target, language)
+5 -1
View File
@@ -1,4 +1,8 @@
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"] const defaultEnv = [
"LANG=en_US.UTF-8",
"LANGUAGE=en_US:en",
"LC_ALL=en_US.UTF-8",
]
export const languageConfigs: Record<string, Record<string, unknown>> = { export const languageConfigs: Record<string, Record<string, unknown>> = {
C: { C: {
+92 -36
View File
@@ -6,17 +6,16 @@ import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events" import { publishAchievementNotification } from "../events"
import { updateAchievementsForProblemSet, updateAchievementsForSubmission } from "../services/achievements" import {
updateAchievementsForProblemSet,
updateAchievementsForSubmission,
} from "../services/achievements"
import { recordSolvedProblem } from "../services/problemset" import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job" import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages" import { languageConfigs } from "./languages"
import { import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
isAccepted,
JudgeStatus,
type JudgeStatusValue,
} from "./status"
import { parseProblemTemplate } from "./template" import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql" import { runSqlCase } from "./sql"
import { readInfo } from "../services/test-case" import { readInfo } from "../services/test-case"
@@ -79,7 +78,8 @@ async function requestJudge(
testCaseId: string, testCaseId: string,
) { ) {
const languageConfig = languageConfigs[language] const languageConfig = languageConfigs[language]
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`) if (!languageConfig)
throw new Error(`Unsupported judge language: ${language}`)
const token = createHash("sha256") const token = createHash("sha256")
.update(config.judgeServerToken) .update(config.judgeServerToken)
@@ -173,8 +173,7 @@ async function persistResult(
.update(schema.problem) .update(schema.problem)
.set({ .set({
submissionNumber: problem.submissionNumber + 1, submissionNumber: problem.submissionNumber + 1,
acceptedNumber: acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
statisticInfo: problemStatistics, statisticInfo: problemStatistics,
}) })
.where(eq(schema.problem.id, problemId)) .where(eq(schema.problem.id, problemId))
@@ -233,7 +232,10 @@ async function persistResult(
submissionInfo: {}, submissionInfo: {},
}) })
.onConflictDoNothing({ .onConflictDoNothing({
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId], target: [
schema.acmContestRank.contestId,
schema.acmContestRank.userId,
],
}) })
const [rank] = await tx const [rank] = await tx
@@ -267,7 +269,8 @@ async function persistResult(
const acTime = Math.max( const acTime = Math.max(
0, 0,
Math.floor( Math.floor(
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) / (Date.parse(submissionCreateTime) -
Date.parse(contest.startTime)) /
1000, 1000,
), ),
) )
@@ -293,7 +296,11 @@ async function persistResult(
}) })
} }
async function markSystemError(submissionId: string, userId: number, error: unknown) { async function markSystemError(
submissionId: string,
userId: number,
error: unknown,
) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
const updated = await db const updated = await db
.update(schema.submission) .update(schema.submission)
@@ -337,7 +344,10 @@ async function markSystemError(submissionId: string, userId: number, error: unkn
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的 * 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。 * 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
*/ */
export async function failAbandonedSubmission(submissionId: string, error: unknown) { export async function failAbandonedSubmission(
submissionId: string,
error: unknown,
) {
const [row] = await db const [row] = await db
.select({ userId: schema.submission.userId }) .select({ userId: schema.submission.userId })
.from(schema.submission) .from(schema.submission)
@@ -354,7 +364,10 @@ export async function judgeSubmission(job: JudgeJobData) {
problem: schema.problem, problem: schema.problem,
}) })
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where( .where(
and( and(
eq(schema.submission.id, job.submissionId), eq(schema.submission.id, job.submissionId),
@@ -364,7 +377,11 @@ export async function judgeSubmission(job: JudgeJobData) {
.limit(1) .limit(1)
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`) if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) { if (
![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(
row.submission.result as 6 | 7,
)
) {
return return
} }
@@ -391,7 +408,8 @@ export async function judgeSubmission(job: JudgeJobData) {
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集, // SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。 // 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
const response = row.submission.language === "SQL" const response =
row.submission.language === "SQL"
? await judgeSqlSubmission(row.problem, row.submission.code) ? await judgeSqlSubmission(row.problem, row.submission.code)
: await requestJudge( : await requestJudge(
row.submission.language, row.submission.language,
@@ -422,11 +440,19 @@ export async function judgeSubmission(job: JudgeJobData) {
(left, right) => Number(left.test_case) - Number(right.test_case), (left, right) => Number(left.test_case) - Number(right.test_case),
) )
info = { err: null, data: cases } info = { err: null, data: cases }
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED) const firstFailure = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED,
)
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED) result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
statisticInfo = { statisticInfo = {
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)), time_cost: Math.max(
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)), 0,
...cases.map((item) => Number(item.cpu_time) || 0),
),
memory_cost: Math.max(
0,
...cases.map((item) => Number(item.memory) || 0),
),
score: 0, score: 0,
} }
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的 // SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
@@ -435,7 +461,8 @@ export async function judgeSubmission(job: JudgeJobData) {
const failedMessage = cases.find( const failedMessage = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message, (item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
)?.error_message )?.error_message
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage if (typeof failedMessage === "string")
statisticInfo.err_info = failedMessage
if (result === JudgeStatus.ACCEPTED) { if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage( const rules = astRulesForLanguage(
@@ -483,43 +510,60 @@ export async function judgeSubmission(job: JudgeJobData) {
row.submission.createTime, row.submission.createTime,
) )
if (earned.length > 0) { if (earned.length > 0) {
await publishAchievementNotification(row.submission.userId, earned.map((badge) => ({ await publishAchievementNotification(
row.submission.userId,
earned.map((badge) => ({
id: badge.id, id: badge.id,
name: badge.name, name: badge.name,
description: badge.description, description: badge.description,
icon: badge.icon, icon: badge.icon,
rarity: "bronze", rarity: "bronze",
kind: "badge", kind: "badge",
}))) })),
)
} }
if (updated > 0) { if (updated > 0) {
const unlocked = await updateAchievementsForProblemSet(row.submission.userId) const unlocked = await updateAchievementsForProblemSet(
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({ row.submission.userId,
)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id, id: achievement.id,
name: achievement.name, name: achievement.name,
description: achievement.description, description: achievement.description,
icon: achievement.icon, icon: achievement.icon,
rarity: achievement.rarity, rarity: achievement.rarity,
kind: "achievement", kind: "achievement",
}))) })),
)
} }
} catch (error) { } catch (error) {
console.error(`Failed to record problem set progress for ${row.submission.id}`, error) console.error(
`Failed to record problem set progress for ${row.submission.id}`,
error,
)
} }
} }
try { try {
const unlocked = await updateAchievementsForSubmission(row.submission.id) const unlocked = await updateAchievementsForSubmission(row.submission.id)
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({ await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id, id: achievement.id,
name: achievement.name, name: achievement.name,
description: achievement.description, description: achievement.description,
icon: achievement.icon, icon: achievement.icon,
rarity: achievement.rarity, rarity: achievement.rarity,
kind: "achievement", kind: "achievement",
}))) })),
)
} catch (error) { } catch (error) {
console.error(`Failed to update achievements for ${row.submission.id}`, error) console.error(
`Failed to update achievements for ${row.submission.id}`,
error,
)
} }
await publishSubmissionUpdate(row.submission.userId, { await publishSubmissionUpdate(row.submission.userId, {
@@ -528,7 +572,9 @@ export async function judgeSubmission(job: JudgeJobData) {
result, result,
status: "finished", status: "finished",
score: score:
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined, typeof statisticInfo.score === "number"
? statisticInfo.score
: undefined,
}) })
} catch (error) { } catch (error) {
console.error(`Failed to judge submission ${row.submission.id}`, error) console.error(`Failed to judge submission ${row.submission.id}`, error)
@@ -536,7 +582,6 @@ export async function judgeSubmission(job: JudgeJobData) {
} }
} }
/** /**
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构, * SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。 * 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
@@ -554,15 +599,23 @@ async function judgeSqlSubmission(
const answers = Array.isArray(problem.answers) ? problem.answers : [] const answers = Array.isArray(problem.answers) ? problem.answers : []
const refSql = answers const refSql = answers
.map((item) => objectValue(item)) .map((item) => objectValue(item))
.find((item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim())?.code .find(
(item) =>
item.language === "SQL" &&
typeof item.code === "string" &&
item.code.trim(),
)?.code
if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案") if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案")
const info = await readInfo(problem.testCaseId) const info = await readInfo(problem.testCaseId)
if (!info) throw new Error("测试点信息读取失败") if (!info) throw new Error("测试点信息读取失败")
if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包") if (!info.sql)
throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
// 按 "1","2",… 的数字序遍历,保证测试点顺序稳定 // 按 "1","2",… 的数字序遍历,保证测试点顺序稳定
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b)) const keys = Object.keys(info.test_cases ?? {}).sort(
(a, b) => Number(a) - Number(b),
)
if (keys.length === 0) throw new Error("题目没有任何测试点") if (keys.length === 0) throw new Error("题目没有任何测试点")
const cases: JudgeCase[] = [] const cases: JudgeCase[] = []
@@ -571,7 +624,9 @@ async function judgeSqlSubmission(
const initSql = await readFile( const initSql = await readFile(
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName), resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
"utf8", "utf8",
).catch(() => { throw new Error(`测试点脚本 ${inputName} 读取失败`) }) ).catch(() => {
throw new Error(`测试点脚本 ${inputName} 读取失败`)
})
const outcome = await runSqlCase({ const outcome = await runSqlCase({
kind: "judge", kind: "judge",
@@ -585,7 +640,8 @@ async function judgeSqlSubmission(
}) })
if (!outcome.ok) { if (!outcome.ok) {
// 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR // 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR
if (outcome.result === JudgeStatus.SYSTEM_ERROR) throw new Error(outcome.message) if (outcome.result === JudgeStatus.SYSTEM_ERROR)
throw new Error(outcome.message)
// 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点 // 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点
cases.push({ cases.push({
test_case: String(index + 1), test_case: String(index + 1),
+11 -3
View File
@@ -29,7 +29,12 @@ export type SqlJob =
timeLimitMs: number timeLimitMs: number
memoryLimitMb: number memoryLimitMb: number
} }
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" } | {
kind: "display"
initSql: string
refSql: string
mode: "query" | "modify"
}
/** /**
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」 * 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
@@ -90,10 +95,13 @@ export async function runSqlChild() {
// WASM 堆触顶时 emscripten 抛的是普通 Error"Aborted"/"out of memory"), // WASM 堆触顶时 emscripten 抛的是普通 Error"Aborted"/"out of memory"),
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错 // 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
const message = String((error as Error)?.message ?? error) const message = String((error as Error)?.message ?? error)
const memoryish = message.includes("out of memory") || message.includes("Aborted") const memoryish =
message.includes("out of memory") || message.includes("Aborted")
finish({ finish({
ok: false, ok: false,
result: memoryish ? JudgeStatus.MEMORY_LIMIT_EXCEEDED : JudgeStatus.SYSTEM_ERROR, result: memoryish
? JudgeStatus.MEMORY_LIMIT_EXCEEDED
: JudgeStatus.SYSTEM_ERROR,
message: memoryish ? "内存超出限制" : message.slice(0, 200), message: memoryish ? "内存超出限制" : message.slice(0, 200),
}) })
} }
+185 -46
View File
@@ -45,10 +45,17 @@ const DISPLAY_ROW_LIMIT = 20
const ERROR_MESSAGE_MAX_LEN = 200 const ERROR_MESSAGE_MAX_LEN = 200
/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */ /** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */
const SYNTAX_ERROR_MARKERS = ["syntax error", "unrecognized token", "incomplete input"] const SYNTAX_ERROR_MARKERS = [
"syntax error",
"unrecognized token",
"incomplete input",
]
export class SqlCaseError extends Error { export class SqlCaseError extends Error {
constructor(readonly result: JudgeStatusValue, readonly detail: string) { constructor(
readonly result: JudgeStatusValue,
readonly detail: string,
) {
super(detail) super(detail)
} }
} }
@@ -82,9 +89,11 @@ type Canonical = string
*/ */
function canonicalValue(value: unknown): Canonical { function canonicalValue(value: unknown): Canonical {
if (value === null || value === undefined) return "null" if (value === null || value === undefined) return "null"
if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}` if (value instanceof Uint8Array)
return `blob:${Buffer.from(value).toString("hex")}`
if (typeof value === "number") { if (typeof value === "number") {
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53) return `num:${value}` if (Number.isInteger(value) && Math.abs(value) < 2 ** 53)
return `num:${value}`
// Python 的 format(v, ".6g") // Python 的 format(v, ".6g")
return `num:${formatG6(value)}` return `num:${formatG6(value)}`
} }
@@ -96,7 +105,10 @@ function canonicalValue(value: unknown): Canonical {
function formatG6(value: number) { function formatG6(value: number) {
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value))) const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
if (exponent < -4 || exponent >= 6) { if (exponent < -4 || exponent >= 6) {
return value.toExponential(5).replace(/\.?0+e/, "e").replace(/e([+-])(\d)$/, "e$10$2") return value
.toExponential(5)
.replace(/\.?0+e/, "e")
.replace(/e([+-])(\d)$/, "e$10$2")
} }
const text = value.toPrecision(6) const text = value.toPrecision(6)
return text.includes(".") ? text.replace(/\.?0+$/, "") : text return text.includes(".") ? text.replace(/\.?0+$/, "") : text
@@ -138,9 +150,11 @@ interface PreparedStatement {
} }
function iterate(db: Database, script: string): Iterable<PreparedStatement> { function iterate(db: Database, script: string): Iterable<PreparedStatement> {
return (db as unknown as { return (
db as unknown as {
iterateStatements(sql: string): Iterable<PreparedStatement> iterateStatements(sql: string): Iterable<PreparedStatement>
}).iterateStatements(script) }
).iterateStatements(script)
} }
/** /**
@@ -157,9 +171,17 @@ function leadingKeyword(statement: PreparedStatement) {
} }
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释 // 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
if (!text) { if (!text) {
text = statement.getSQL().replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ") text = statement
.getSQL()
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\n]*/g, " ")
} }
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? "" return (
text
.trimStart()
.split(/[\s(;]/, 1)[0]
?.toUpperCase() ?? ""
)
} }
/** /**
@@ -187,11 +209,17 @@ class ByteBudget {
? Buffer.byteLength(value) ? Buffer.byteLength(value)
: 8 // 数字和 NULL 按定长算,撑不出内存 : 8 // 数字和 NULL 按定长算,撑不出内存
if (bytes > this.maxBytes) { if (bytes > this.maxBytes) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制") throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"单个数据值超出内存限制",
)
} }
this.used += bytes this.used += bytes
if (this.used > this.maxBytes) { if (this.used > this.maxBytes) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "查询结果超出内存限制") throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"查询结果超出内存限制",
)
} }
} }
} }
@@ -226,12 +254,17 @@ function executeStatements(
budget?.charge(row) budget?.charge(row)
rows.push(canonicalRow(row)) rows.push(canonicalRow(row))
if (rows.length > ROW_LIMIT) { if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT}`) throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
`查询结果超过 ${ROW_LIMIT}`,
)
} }
} }
last = { columns: names.length, rows } last = { columns: names.length, rows }
} else { } else {
while (statement.step()) { /* 无结果集语句,推进到结束 */ } while (statement.step()) {
/* 无结果集语句,推进到结束 */
}
} }
} finally { } finally {
statement.free() statement.free()
@@ -242,7 +275,10 @@ function executeStatements(
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */ /** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
function dumpTables(db: Database, budget?: ByteBudget) { function dumpTables(db: Database, budget?: ByteBudget) {
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") const names = queryColumn(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
const state: Record<string, { columns: number; rows: string[] }> = {} const state: Record<string, { columns: number; rows: string[] }> = {}
for (const table of names) { for (const table of names) {
const quoted = String(table).replaceAll('"', '""') const quoted = String(table).replaceAll('"', '""')
@@ -253,7 +289,10 @@ function dumpTables(db: Database, budget?: ByteBudget) {
return canonicalRow(row as unknown[]) return canonicalRow(row as unknown[])
}) })
if (rows.length > ROW_LIMIT) { if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `${table} 超过 ${ROW_LIMIT}`) throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
`${table} 超过 ${ROW_LIMIT}`,
)
} }
state[String(table)] = { state[String(table)] = {
// 空表 exec 不返回结果,列数用 table_info 兜底 // 空表 exec 不返回结果,列数用 table_info 兜底
@@ -278,14 +317,25 @@ function trustedErrorText(message: string) {
} }
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */ /** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) { function executeTrusted(
db: Database,
script: string,
deadline: number,
prefix: string,
) {
try { try {
return executeStatements(db, script, deadline) return executeStatements(db, script, deadline)
} catch (error) { } catch (error) {
if (error instanceof SqlCaseError) { if (error instanceof SqlCaseError) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${error.detail}`) throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`${prefix}: ${error.detail}`,
)
} }
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${trustedErrorText(String((error as Error).message))}`) throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`${prefix}: ${trustedErrorText(String((error as Error).message))}`,
)
} }
} }
@@ -300,36 +350,63 @@ function runStudent(
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单 // 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
if (mode === "query") db.run("PRAGMA query_only=1") if (mode === "query") db.run("PRAGMA query_only=1")
// 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH) // 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH)
const budget = new ByteBudget(Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024) const budget = new ByteBudget(
Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024,
)
try { try {
const last = executeStatements(db, script, deadline, (statement) => { const last = executeStatements(
db,
script,
deadline,
(statement) => {
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0` // query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。 // 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。 // 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
if (leadingKeyword(statement) === "PRAGMA") { if (leadingKeyword(statement) === "PRAGMA") {
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句") throw new SqlCaseError(
JudgeStatus.RUNTIME_ERROR,
"禁止使用 PRAGMA 语句",
)
} }
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍 // 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb) applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1") if (mode === "query") db.run("PRAGMA query_only=1")
}, budget) },
budget,
)
if (mode === "query") return last if (mode === "query") return last
return dumpTables(db, budget) return dumpTables(db, budget)
} catch (error) { } catch (error) {
if (error instanceof SqlCaseError) throw error if (error instanceof SqlCaseError) throw error
const message = String((error as Error).message) const message = String((error as Error).message)
if (message.includes("interrupted")) { if (message.includes("interrupted")) {
throw new SqlCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时") throw new SqlCaseError(
JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
"SQL 执行超时",
)
} }
if (message.includes("database or disk is full")) { if (message.includes("database or disk is full")) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制") throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"数据量超出内存限制",
)
} }
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限 // WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) { if (
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制") message.includes("too big") ||
message.includes("out of memory") ||
message.includes("Aborted")
) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"单个数据值超出内存限制",
)
} }
if (message.includes("readonly database")) { if (message.includes("readonly database")) {
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)") throw new SqlCaseError(
JudgeStatus.RUNTIME_ERROR,
"本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)",
)
} }
if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) { if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) {
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message)) throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
@@ -337,7 +414,11 @@ function runStudent(
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message)) throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
} finally { } finally {
if (mode === "query") { if (mode === "query") {
try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ } try {
db.run("PRAGMA query_only=0")
} catch {
/* 连接可能已不可用 */
}
} }
} }
} }
@@ -412,14 +493,22 @@ export async function runCase(
const refDb = newDatabase(SQL, options.memoryLimitMb) const refDb = newDatabase(SQL, options.memoryLimitMb)
try { try {
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败") executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败") const last = executeTrusted(
refDb,
refSql,
trustedDeadline,
"标准答案执行失败",
)
if (options.mode === "query") { if (options.mode === "query") {
expected = last expected = last
} else { } else {
try { try {
expected = dumpTables(refDb) expected = dumpTables(refDb)
} catch (error) { } catch (error) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`) throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案结果超出限制: ${(error as SqlCaseError).detail}`,
)
} }
} }
} finally { } finally {
@@ -460,7 +549,13 @@ export async function runCase(
} catch (error) { } catch (error) {
elapsed = Date.now() - start elapsed = Date.now() - start
const failure = error as SqlCaseError const failure = error as SqlCaseError
return { ...result, result: failure.result, error_message: failure.detail, cpu_time: elapsed, real_time: elapsed } return {
...result,
result: failure.result,
error_message: failure.detail,
cpu_time: elapsed,
real_time: elapsed,
}
} }
elapsed = Date.now() - start elapsed = Date.now() - start
} finally { } finally {
@@ -496,20 +591,35 @@ interface DisplayTable {
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */ /** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] { function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] {
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") const names = queryColumn(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
)
const tables: DisplayTable[] = [] const tables: DisplayTable[] = []
for (const raw of names) { for (const raw of names) {
const name = String(raw) const name = String(raw)
if (only && !only.has(name)) continue if (only && !only.has(name)) continue
const quoted = name.replaceAll('"', '""') const quoted = name.replaceAll('"', '""')
const columns = (db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []).map((row) => ({ const columns = (
db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []
).map((row) => ({
name: String(row[1]), name: String(row[1]),
type: String(row[2] ?? ""), type: String(row[2] ?? ""),
})) }))
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0) const total = Number(
const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? []) db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0,
.map((row) => (row as unknown[]).map(displayValue)) )
tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT }) const rows = (
db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]
?.values ?? []
).map((row) => (row as unknown[]).map(displayValue))
tables.push({
name,
columns,
rows,
total_rows: total,
truncated: total > DISPLAY_ROW_LIMIT,
})
} }
return tables return tables
} }
@@ -546,17 +656,27 @@ export async function buildDisplay(
for (const statement of iterate(db, refSql)) { for (const statement of iterate(db, refSql)) {
try { try {
const names = statement.getColumnNames() const names = statement.getColumnNames()
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue } if (names.length === 0) {
while (statement.step()) {
/* 无结果集 */
}
continue
}
const rows: unknown[][] = [] const rows: unknown[][] = []
while (statement.step()) { while (statement.step()) {
rows.push(statement.get()) rows.push(statement.get())
if (rows.length > ROW_LIMIT) { if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT}`) throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案结果超过 ${ROW_LIMIT}`,
)
} }
} }
expected = { expected = {
columns: queryResultColumns(names, tables), columns: queryResultColumns(names, tables),
rows: rows.slice(0, DISPLAY_ROW_LIMIT).map((row) => row.map(displayValue)), rows: rows
.slice(0, DISPLAY_ROW_LIMIT)
.map((row) => row.map(displayValue)),
total_rows: rows.length, total_rows: rows.length,
truncated: rows.length > DISPLAY_ROW_LIMIT, truncated: rows.length > DISPLAY_ROW_LIMIT,
} }
@@ -566,10 +686,16 @@ export async function buildDisplay(
} }
} catch (error) { } catch (error) {
if (error instanceof SqlCaseError) throw error if (error instanceof SqlCaseError) throw error
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`) throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`,
)
} }
if (expected === null) { if (expected === null) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集") throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
"标准答案未产生查询结果集",
)
} }
return { tables, expected } return { tables, expected }
} }
@@ -578,18 +704,31 @@ export async function buildDisplay(
executeTrusted(db, refSql, deadline, "标准答案执行失败") executeTrusted(db, refSql, deadline, "标准答案执行失败")
const after = dumpTables(db) const after = dumpTables(db)
const changed = new Set<string>() const changed = new Set<string>()
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) { for (const name of new Set([
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name) ...Object.keys(before),
...Object.keys(after),
])) {
if (JSON.stringify(before[name]) !== JSON.stringify(after[name]))
changed.add(name)
} }
if (changed.size === 0) { if (changed.size === 0) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置") throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
"标准答案未修改任何表数据,请检查题目配置",
)
} }
const changedTables = dumpDisplayTables(db, changed) const changedTables = dumpDisplayTables(db, changed)
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」) // 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
const existing = new Set(changedTables.map((table) => table.name)) const existing = new Set(changedTables.map((table) => table.name))
for (const table of tables) { for (const table of tables) {
if (changed.has(table.name) && !existing.has(table.name)) { if (changed.has(table.name) && !existing.has(table.name)) {
changedTables.push({ ...table, rows: [], total_rows: 0, truncated: false, dropped: true }) changedTables.push({
...table,
rows: [],
total_rows: 0,
truncated: false,
dropped: true,
})
} }
} }
return { tables, expected: { changed_tables: changedTables } } return { tables, expected: { changed_tables: changedTables } }
+34 -10
View File
@@ -85,16 +85,24 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
result: JudgeStatus.SYSTEM_ERROR, result: JudgeStatus.SYSTEM_ERROR,
message: "初始化脚本或标准答案超时/内存超限,请检查题目配置", message: "初始化脚本或标准答案超时/内存超限,请检查题目配置",
}, },
student: { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" }, student: {
ok: false,
result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
message: "SQL 执行超时",
},
} }
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> { async function runJob<T>(
job: SqlJob,
budget: JobBudget,
): Promise<SqlJobOutcome<T>> {
// 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。 // 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。
if (process.env[CHILD_MARKER]) { if (process.env[CHILD_MARKER]) {
return { return {
ok: false, ok: false,
result: JudgeStatus.SYSTEM_ERROR, result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)", message:
"SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
} }
} }
@@ -115,7 +123,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
child.stdin.write(JSON.stringify(job)) child.stdin.write(JSON.stringify(job))
await child.stdin.end() await child.stdin.end()
let timer = setTimeout(() => child.kill("SIGKILL"), budget.trustedMs + STARTUP_SLACK_MS) let timer = setTimeout(
() => child.kill("SIGKILL"),
budget.trustedMs + STARTUP_SLACK_MS,
)
let phase = "" let phase = ""
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了 // stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
const readStderr = (async () => { const readStderr = (async () => {
@@ -132,7 +143,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
phase = latest phase = latest
if (phase === "student" && budget.studentMs !== null) { if (phase === "student" && budget.studentMs !== null) {
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(() => child.kill("SIGKILL"), budget.studentMs + STUDENT_SLACK_MS) timer = setTimeout(
() => child.kill("SIGKILL"),
budget.studentMs + STUDENT_SLACK_MS,
)
} }
} }
} }
@@ -140,7 +154,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
let stdout = "" let stdout = ""
try { try {
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr]) ;[stdout] = await Promise.all([
new Response(child.stdout).text(),
readStderr,
])
await child.exited await child.exited
} finally { } finally {
clearTimeout(timer) clearTimeout(timer)
@@ -160,12 +177,15 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
try { try {
const parsed = JSON.parse(stdout) as const parsed = JSON.parse(stdout) as
| { ok: true; case?: CaseResult; display?: unknown } { ok: true; case?: CaseResult; display?: unknown } | SqlJobFailure
| SqlJobFailure
if (!parsed.ok) return parsed if (!parsed.ok) return parsed
return { ok: true, value: (parsed.case ?? parsed.display) as T } return { ok: true, value: (parsed.case ?? parsed.display) as T }
} catch { } catch {
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "SQL 判题子进程返回了无法解析的结果" } return {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程返回了无法解析的结果",
}
} }
} }
@@ -176,7 +196,11 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
}) })
} }
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") { export function buildSqlDisplay(
initSql: string,
refSql: string,
mode: "query" | "modify",
) {
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的 // 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍 // SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
return runJob<SqlDisplay>( return runJob<SqlDisplay>(
+7 -2
View File
@@ -16,7 +16,9 @@ export const JudgeStatus = {
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus] export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
export function isAccepted(result: number) { export function isAccepted(result: number) {
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED return (
result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
)
} }
/** /**
@@ -48,7 +50,10 @@ export function judgeStatusName(result: number) {
* *
* *
*/ */
export const UNJUDGED_RESULTS: JudgeStatusValue[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING] export const UNJUDGED_RESULTS: JudgeStatusValue[] = [
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]
/** /**
* **** AST_CHECK_FAILED * **** AST_CHECK_FAILED
+6 -2
View File
@@ -38,7 +38,9 @@ switch (command) {
// 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。 // 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。
case "recount": { case "recount": {
const { recount } = await import("./scripts/recount") const { recount } = await import("./scripts/recount")
process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") })) process.exit(
await recount({ apply: process.argv.slice(3).includes("--apply") }),
)
} }
case "sql-child": { case "sql-child": {
const { runSqlChild } = await import("./judge/sql/child") const { runSqlChild } = await import("./judge/sql/child")
@@ -60,6 +62,8 @@ switch (command) {
} }
} }
default: default:
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`) console.error(
`未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`,
)
process.exit(2) process.exit(2)
} }
+237 -64
View File
@@ -42,15 +42,28 @@ import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { getUserProfileById } from "../services/profile" import { getUserProfileById } from "../services/profile"
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers" import {
isTeacherOrAbove,
objectValue,
queryInteger,
sampleUser,
} from "./helpers"
export const accountRoutes = new Hono<AppEnv>() export const accountRoutes = new Hono<AppEnv>()
accountRoutes.post("/users", async (c) => { accountRoutes.post("/users", async (c) => {
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = registerRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid registration payload")
if (!(await getBooleanOption("allow_register", true))) { if (!(await getBooleanOption("allow_register", true))) {
return failure(c, 403, "registration-disabled", "Register function has been disabled by admin") return failure(
c,
403,
"registration-disabled",
"Register function has been disabled by admin",
)
} }
const username = parsed.data.username.toLowerCase() const username = parsed.data.username.toLowerCase()
@@ -58,7 +71,12 @@ accountRoutes.post("/users", async (c) => {
const [duplicate] = await db const [duplicate] = await db
.select({ username: schema.user.username, email: schema.user.email }) .select({ username: schema.user.username, email: schema.user.email })
.from(schema.user) .from(schema.user)
.where(or(sql`lower(${schema.user.username}) = ${username}`, sql`lower(${schema.user.email}) = ${email}`)) .where(
or(
sql`lower(${schema.user.username}) = ${username}`,
sql`lower(${schema.user.email}) = ${email}`,
),
)
.limit(1) .limit(1)
if (duplicate?.username.toLowerCase() === username) { if (duplicate?.username.toLowerCase() === username) {
return failure(c, 409, "username-exists", "Username already exists") return failure(c, 409, "username-exists", "Username already exists")
@@ -70,7 +88,9 @@ accountRoutes.post("/users", async (c) => {
const now = new Date().toISOString() const now = new Date().toISOString()
const password = await hashPassword(parsed.data.password) const password = await hashPassword(parsed.data.password)
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [created] = await tx.insert(schema.user).values({ const [created] = await tx
.insert(schema.user)
.values({
username, username,
email, email,
password, password,
@@ -81,7 +101,8 @@ accountRoutes.post("/users", async (c) => {
isDisabled: false, isDisabled: false,
problemPermission: "None", problemPermission: "None",
className: null, className: null,
}).returning({ id: schema.user.id }) })
.returning({ id: schema.user.id })
if (!created) throw new Error("User insert did not return an id") if (!created) throw new Error("User insert did not return an id")
await tx.insert(schema.userProfile).values({ await tx.insert(schema.userProfile).values({
userId: created.id, userId: created.id,
@@ -101,31 +122,57 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空, // `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。 // 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
if (!c.get("user")) return success(c, null) if (!c.get("user")) return success(c, null)
const [target] = await db.select({ id: schema.user.id }).from(schema.user) const [target] = await db
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1) .select({ id: schema.user.id })
.from(schema.user)
.where(
and(
sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`,
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!target) return failure(c, 404, "user-not-found", "User does not exist") if (!target) return failure(c, 404, "user-not-found", "User does not exist")
const profile = await getUserProfileById(target.id, c.get("user")?.id === target.id) const profile = await getUserProfileById(
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist") target.id,
c.get("user")?.id === target.id,
)
if (!profile)
return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, profile) return success(c, profile)
}) })
accountRoutes.put("/me/profile", requireAuth, async (c) => { accountRoutes.put("/me/profile", requireAuth, async (c) => {
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateProfileRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid profile payload") await c.req.json().catch(() => null),
const values = Object.fromEntries(
Object.entries(parsed.data).map(([key, value]) => [key, value === "" ? null : value]),
) )
await db.update(schema.userProfile).set(values).where(eq(schema.userProfile.userId, c.get("user")!.id)) if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid profile payload")
const values = Object.fromEntries(
Object.entries(parsed.data).map(([key, value]) => [
key,
value === "" ? null : value,
]),
)
await db
.update(schema.userProfile)
.set(values)
.where(eq(schema.userProfile.userId, c.get("user")!.id))
const profile = await getUserProfileById(c.get("user")!.id, true) const profile = await getUserProfileById(c.get("user")!.id, true)
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist") if (!profile)
return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, profile) return success(c, profile)
}) })
accountRoutes.post("/me/avatar", requireAuth, async (c) => { accountRoutes.post("/me/avatar", requireAuth, async (c) => {
const body: Record<string, string | File> = await c.req.parseBody().catch(() => ({})) const body: Record<string, string | File> = await c.req
.parseBody()
.catch(() => ({}))
const image = body.image const image = body.image
if (!(image instanceof File)) return failure(c, 400, "invalid-file", "Invalid file content") if (!(image instanceof File))
if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large") 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() const extension = extname(image.name).toLowerCase()
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) { if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
return failure(c, 400, "unsupported-file", "Unsupported file format") return failure(c, 400, "unsupported-file", "Unsupported file format")
@@ -135,17 +182,35 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
await Bun.$`mkdir -p ${directory}`.quiet() await Bun.$`mkdir -p ${directory}`.quiet()
await Bun.write(resolve(directory, filename), image) await Bun.write(resolve(directory, filename), image)
const avatar = `${config.avatarUriPrefix}/${filename}` const avatar = `${config.avatarUriPrefix}/${filename}`
await db.update(schema.userProfile).set({ avatar }).where(eq(schema.userProfile.userId, c.get("user")!.id)) await db
.update(schema.userProfile)
.set({ avatar })
.where(eq(schema.userProfile.userId, c.get("user")!.id))
return success(c, { avatar }) return success(c, { avatar })
}) })
accountRoutes.get("/users/:id/metrics", async (c) => { accountRoutes.get("/users/:id/metrics", async (c) => {
const userId = queryInteger(c.req.param("id"), 0, { min: 1 }) const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db.select({ total: count(), first: min(schema.submission.createTime), latest: sql<string>`max(${schema.submission.createTime})` }) const [row] = await db
.select({
total: count(),
first: min(schema.submission.createTime),
latest: sql<string>`max(${schema.submission.createTime})`,
})
.from(schema.submission) .from(schema.submission)
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId))) .where(
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交") and(
return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics) eq(schema.submission.userId, userId),
isNull(schema.submission.contestId),
),
)
if (!row?.total || !row.first || !row.latest)
return failure(c, 404, "no-submissions", "暂无提交")
return success(c, {
now: new Date().toISOString(),
first: row.first,
latest: row.latest,
} satisfies Metrics)
}) })
/** /**
@@ -178,7 +243,10 @@ const leaderboardOrder = [
] ]
accountRoutes.get("/rankings/users", optionalAuth, async (c) => { accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE }) const limit = queryInteger(c.req.query("limit"), 10, {
min: 1,
max: LEADERBOARD_SIZE,
})
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** —— // 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
@@ -188,14 +256,22 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline // 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline
const [totalRow, rows, me, online] = await Promise.all([ const [totalRow, rows, me, online] = await Promise.all([
db.select({ value: count() }).from(schema.userProfile) db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere).then(([row]) => row), .where(leaderboardWhere)
pageLimit === 0 ? [] : db .then(([row]) => row),
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile) pageLimit === 0
? []
: db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere).orderBy(...leaderboardOrder) .where(leaderboardWhere)
.limit(pageLimit).offset(offset), .orderBy(...leaderboardOrder)
.limit(pageLimit)
.offset(offset),
myLeaderboardRank(c.get("user")?.id), myLeaderboardRank(c.get("user")?.id),
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null, isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
]) ])
@@ -207,10 +283,16 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
} satisfies UserRank) } satisfies UserRank)
}) })
function serializeRankRow({ profile, user }: { function serializeRankRow(
{
profile,
user,
}: {
profile: typeof schema.userProfile.$inferSelect profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect user: typeof schema.user.$inferSelect
}, online: Set<number> | null = null) { },
online: Set<number> | null = null,
) {
return { return {
id: profile.id, id: profile.id,
user: sampleUser(user, profile.realName), user: sampleUser(user, profile.realName),
@@ -231,15 +313,22 @@ function serializeRankRow({ profile, user }: {
async function myLeaderboardRank(userId: number | undefined) { async function myLeaderboardRank(userId: number | undefined) {
if (!userId) return null if (!userId) return null
const [mine] = await db const [mine] = await db
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile) .select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(and(leaderboardWhere, eq(schema.user.id, userId))).limit(1) .where(and(leaderboardWhere, eq(schema.user.id, userId)))
.limit(1)
if (!mine) return null if (!mine) return null
const { acceptedNumber, submissionNumber } = mine.profile const { acceptedNumber, submissionNumber } = mine.profile
const [ahead] = await db.select({ value: count() }).from(schema.userProfile) const [ahead] = await db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(and(leaderboardWhere, or( .where(
and(
leaderboardWhere,
or(
gt(schema.userProfile.acceptedNumber, acceptedNumber), gt(schema.userProfile.acceptedNumber, acceptedNumber),
and( and(
eq(schema.userProfile.acceptedNumber, acceptedNumber), eq(schema.userProfile.acceptedNumber, acceptedNumber),
@@ -250,7 +339,9 @@ async function myLeaderboardRank(userId: number | undefined) {
eq(schema.userProfile.submissionNumber, submissionNumber), eq(schema.userProfile.submissionNumber, submissionNumber),
lt(schema.user.id, userId), lt(schema.user.id, userId),
), ),
))) ),
),
)
return { return {
...serializeRankRow(mine), ...serializeRankRow(mine),
@@ -260,7 +351,8 @@ async function myLeaderboardRank(userId: number | undefined) {
accountRoutes.get("/rankings/activity", async (c) => { accountRoutes.get("/rankings/activity", async (c) => {
const start = c.req.query("start") const start = c.req.query("start")
if (!start || Number.isNaN(Date.parse(start))) return failure(c, 400, "invalid-start", "start time is required") if (!start || Number.isNaN(Date.parse(start)))
return failure(c, 400, "invalid-start", "start time is required")
/** /**
* **user_id** user `submission.username` * **user_id** user `submission.username`
* AC * AC
@@ -268,43 +360,105 @@ accountRoutes.get("/rankings/activity", async (c) => {
* *
* innerJoin user * innerJoin user
*/ */
const rows = await db.select({ username: schema.user.username, value: countDistinct(schema.submission.problemId) }) const rows = await db
.select({
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
.where(and( .where(
and(
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
gte(schema.submission.createTime, start), gte(schema.submission.createTime, start),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]), inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
ne(schema.user.adminType, "Super Admin"), ne(schema.user.adminType, "Super Admin"),
)) ),
)
.groupBy(schema.submission.userId, schema.user.username) .groupBy(schema.submission.userId, schema.user.username)
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10) .orderBy(desc(countDistinct(schema.submission.problemId)))
return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem))) .limit(10)
return success(
c,
rows.map(
(row) =>
({
username: row.username,
count: row.value,
}) satisfies ActivityRankItem,
),
)
}) })
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem) const [problem] = await db
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1) .select({ id: schema.problem.id })
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") .from(schema.problem)
const accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10])) .where(
const [all] = await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(accepted) and(
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const accepted = and(
eq(schema.submission.problemId, problem.id),
inArray(schema.submission.result, [0, 10]),
)
const [all] = await db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(accepted)
const className = user.className ?? "" const className = user.className ?? ""
const classWhere = className const classWhere = className
? and(accepted, inArray(schema.submission.userId, db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false))))) ? and(
accepted,
inArray(
schema.submission.userId,
db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
),
),
),
)
: accepted : accepted
const [classCount] = className const [classCount] = className
? await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(classWhere) ? await db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(classWhere)
: [{ value: 0 }] : [{ value: 0 }]
const [first] = await db.select({ value: min(schema.submission.createTime) }).from(schema.submission) const [first] = await db
.select({ value: min(schema.submission.createTime) })
.from(schema.submission)
.where(and(classWhere, eq(schema.submission.userId, user.id))) .where(and(classWhere, eq(schema.submission.userId, user.id)))
let rank = -1 let rank = -1
if (first?.value) { if (first?.value) {
const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value))) const [rankRow] = await db
.select({ value: count() })
.from(schema.submission)
.where(and(classWhere, lte(schema.submission.createTime, first.value)))
rank = rankRow?.value ?? -1 rank = rankRow?.value ?? -1
} }
return success(c, { className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 } satisfies ProblemRank) return success(c, {
className,
rank,
classAcCount: classCount?.value ?? 0,
allAcCount: all?.value ?? 0,
} satisfies ProblemRank)
}) })
/** /**
@@ -319,17 +473,32 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
* display_ids ids * display_ids ids
* `id_map[k]` KeyError id Map * `id_map[k]` KeyError id Map
*/ */
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => { accountRoutes.post(
"/me/problem-display-ids/refresh",
requireAuth,
async (c) => {
const user = c.get("user")! const user = c.get("user")!
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile) const [profile] = await db
.where(eq(schema.userProfile.userId, user.id)).limit(1) .select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
const status = objectValue(profile?.value) const status = objectValue(profile?.value)
const problems = objectValue(status.problems) const problems = objectValue(status.problems)
const ids = Object.keys(problems).map(Number).filter(Number.isInteger) const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
if (ids.length > 0) { if (ids.length > 0) {
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.displayId }).from(schema.problem) const rows = await db
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true))) .select({ id: schema.problem.id, displayId: schema.problem.displayId })
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId])) .from(schema.problem)
.where(
and(
inArray(schema.problem.id, ids),
eq(schema.problem.visible, true),
),
)
const displayIds = new Map(
rows.map((row) => [String(row.id), row.displayId]),
)
for (const [id, value] of Object.entries(problems)) { for (const [id, value] of Object.entries(problems)) {
const item = objectValue(value) const item = objectValue(value)
const displayId = displayIds.get(id) const displayId = displayIds.get(id)
@@ -337,7 +506,11 @@ accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) =>
problems[id] = item problems[id] = item
} }
status.problems = problems status.problems = problems
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id)) await db
.update(schema.userProfile)
.set({ acmProblemsStatus: status })
.where(eq(schema.userProfile.userId, user.id))
} }
return success(c, null) return success(c, null)
}) },
)
+101 -25
View File
@@ -17,16 +17,29 @@ export const achievementRoutes = new Hono<AppEnv>()
async function resolveUser(requested: string | undefined, currentId: number) { async function resolveUser(requested: string | undefined, currentId: number) {
if (!requested) { if (!requested) {
const [current] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user) const [current] = await db
.where(eq(schema.user.id, currentId)).limit(1) .select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(eq(schema.user.id, currentId))
.limit(1)
return current ?? null return current ?? null
} }
const [target] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user) const [target] = await db
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false))).limit(1) .select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.username, requested),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
return target ?? null return target ?? null
} }
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) { function pendingData(row: {
achievement: typeof schema.achievement.$inferSelect
}) {
return { return {
id: row.achievement.id, id: row.achievement.id,
name: row.achievement.name, name: row.achievement.name,
@@ -40,10 +53,24 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
const target = await resolveUser(c.req.query("username"), c.get("user")!.id) const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
if (!target) return failure(c, 404, "user-not-found", "用户不存在") if (!target) return failure(c, 404, "user-not-found", "用户不存在")
const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([ const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([
db.select().from(schema.achievement).where(eq(schema.achievement.visible, true)).orderBy(asc(schema.achievement.order), asc(schema.achievement.id)), db
db.select().from(schema.userAchievement).where(eq(schema.userAchievement.userId, target.id)), .select()
db.select({ metrics: schema.userStat.metrics }).from(schema.userStat).where(eq(schema.userStat.userId, target.id)).limit(1), .from(schema.achievement)
db.select({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)), .where(eq(schema.achievement.visible, true))
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id)),
db
.select()
.from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, target.id)),
db
.select({ metrics: schema.userStat.metrics })
.from(schema.userStat)
.where(eq(schema.userStat.userId, target.id))
.limit(1),
db
.select({ value: count() })
.from(schema.user)
.where(eq(schema.user.isDisabled, false)),
]) ])
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row])) const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
const metrics = objectValue(statRows[0]?.metrics) const metrics = objectValue(statRows[0]?.metrics)
@@ -66,22 +93,50 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
unlockTime: record?.unlockTime ?? null, unlockTime: record?.unlockTime ?? null,
backfilled: record?.backfilled ?? false, backfilled: record?.backfilled ?? false,
progress: masked ? null : typeof progress === "number" ? progress : 0, progress: masked ? null : typeof progress === "number" ? progress : 0,
unlockRate: active > 0 ? Math.round(achievement.unlockCount / active * 1000) / 10 : 0, unlockRate:
active > 0
? Math.round((achievement.unlockCount / active) * 1000) / 10
: 0,
} satisfies Achievement } satisfies Achievement
}) })
return success(c, { username: target.username, achievements: result } satisfies AchievementList) return success(c, {
username: target.username,
achievements: result,
} satisfies AchievementList)
}) })
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => { achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
const target = await resolveUser(c.req.query("username"), c.get("user")!.id) const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
if (!target) return failure(c, 404, "user-not-found", "用户不存在") if (!target) return failure(c, 404, "user-not-found", "用户不存在")
const [achievements, unlockedRows] = await Promise.all([ const [achievements, unlockedRows] = await Promise.all([
db.select({ id: schema.achievement.id, rarity: schema.achievement.rarity }).from(schema.achievement).where(eq(schema.achievement.visible, true)), db
db.select({ record: schema.userAchievement, achievement: schema.achievement }).from(schema.userAchievement) .select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id)) .from(schema.achievement)
.where(and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true))).orderBy(desc(schema.userAchievement.unlockTime)), .where(eq(schema.achievement.visible, true)),
db
.select({
record: schema.userAchievement,
achievement: schema.achievement,
})
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, target.id),
eq(schema.achievement.visible, true),
),
)
.orderBy(desc(schema.userAchievement.unlockTime)),
]) ])
const labels = { bronze: "青铜", silver: "白银", gold: "黄金", platinum: "白金" } const labels = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
const rarities = ["bronze", "silver", "gold", "platinum"] as const const rarities = ["bronze", "silver", "gold", "platinum"] as const
const total = achievements.length const total = achievements.length
const unlocked = unlockedRows.length const unlocked = unlockedRows.length
@@ -89,33 +144,54 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
username: target.username, username: target.username,
total, total,
unlocked, unlocked,
percent: total > 0 ? Math.round(unlocked / total * 1000) / 10 : 0, percent: total > 0 ? Math.round((unlocked / total) * 1000) / 10 : 0,
rarity: rarities.map((rarity) => ({ rarity: rarities.map((rarity) => ({
rarity, rarity,
label: labels[rarity], label: labels[rarity],
total: achievements.filter((item) => item.rarity === rarity).length, total: achievements.filter((item) => item.rarity === rarity).length,
unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length, unlocked: unlockedRows.filter(
(item) => item.achievement.rarity === rarity,
).length,
})), })),
recent: unlockedRows.slice(0, 10).map(pendingData), recent: unlockedRows.slice(0, 10).map(pendingData),
} satisfies AchievementSummary) } satisfies AchievementSummary)
}) })
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => { achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
const rows = await db.select({ record: schema.userAchievement, achievement: schema.achievement }) const rows = await db
.from(schema.userAchievement).innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id)) .select({ record: schema.userAchievement, achievement: schema.achievement })
.where(and(eq(schema.userAchievement.userId, c.get("user")!.id), eq(schema.userAchievement.notified, false), eq(schema.achievement.visible, true))) .from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, c.get("user")!.id),
eq(schema.userAchievement.notified, false),
eq(schema.achievement.visible, true),
),
)
.orderBy(asc(schema.userAchievement.unlockTime)) .orderBy(asc(schema.userAchievement.unlockTime))
return success(c, rows.map(pendingData)) return success(c, rows.map(pendingData))
}) })
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => { achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
const parsed = markAchievementsReadSchema.safeParse(await c.req.json().catch(() => null)) const parsed = markAchievementsReadSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid achievement ids") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid achievement ids")
if (parsed.data.ids.length > 0) { if (parsed.data.ids.length > 0) {
await db.update(schema.userAchievement).set({ notified: true }).where(and( await db
.update(schema.userAchievement)
.set({ notified: true })
.where(
and(
eq(schema.userAchievement.userId, c.get("user")!.id), eq(schema.userAchievement.userId, c.get("user")!.id),
inArray(schema.userAchievement.achievementId, parsed.data.ids), inArray(schema.userAchievement.achievementId, parsed.data.ids),
)) ),
)
} }
return success(c, null) return success(c, null)
}) })
+261 -74
View File
@@ -14,7 +14,18 @@ import {
} from "@oj2/contract" } from "@oj2/contract"
import { randomInt } from "node:crypto" import { randomInt } from "node:crypto"
import { z } from "zod" import { z } from "zod"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm" import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { hashPassword } from "../../auth/password" import { hashPassword } from "../../auth/password"
@@ -38,11 +49,16 @@ const CLASS_NAME_MAX_DIGITS = 4
* `ks251001` 4 * `ks251001` 4
* `account/views/admin.py:get_class_name` * `account/views/admin.py:get_class_name`
*/ */
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } { function classNameOf(
username: string,
): { ok: true; value: string | null } | { ok: false; message: string } {
const matched = /^ks(\d+)/.exec(username) const matched = /^ks(\d+)/.exec(username)
if (!matched) return { ok: true, value: null } if (!matched) return { ok: true, value: null }
const digits = matched[1]! const digits = matched[1]!
if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) { if (
digits.length < CLASS_NAME_MIN_DIGITS ||
digits.length > CLASS_NAME_MAX_DIGITS
) {
return { return {
ok: false, ok: false,
message: `用户名 ${username} 的班级号 ${digits}${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`, message: `用户名 ${username} 的班级号 ${digits}${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
@@ -56,16 +72,22 @@ function classNameOf(username: string): { ok: true; value: string | null } | { o
* All None Own * All None Own
* All * All
*/ */
function normalizePermission(adminType: AdminType, requested: ProblemPermission): ProblemPermission { function normalizePermission(
adminType: AdminType,
requested: ProblemPermission,
): ProblemPermission {
if (adminType === "Super Admin") return "All" if (adminType === "Super Admin") return "All"
if (adminType === "Regular User") return "None" if (adminType === "Regular User") return "None"
return requested || "Own" return requested || "Own"
} }
function serialize(row: { function serialize(
row: {
user: typeof schema.user.$inferSelect user: typeof schema.user.$inferSelect
realName: string | null realName: string | null
}, isOnline: boolean) { },
isOnline: boolean,
) {
return { return {
id: row.user.id, id: row.user.id,
username: row.user.username, username: row.user.username,
@@ -83,10 +105,12 @@ function serialize(row: {
} }
function selectUser(id: number) { function selectUser(id: number) {
return db.select({ user: schema.user, realName: schema.userProfile.realName }) return db
.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user) .from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, id)).limit(1) .where(eq(schema.user.id, id))
.limit(1)
} }
/** /**
@@ -110,20 +134,29 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
) )
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.userProfile) db
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where), .select({ value: count() })
db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile) .from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where) .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(where),
db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy( .orderBy(
desc(schema.userProfile.acceptedNumber), desc(schema.userProfile.acceptedNumber),
asc(schema.userProfile.submissionNumber), asc(schema.userProfile.submissionNumber),
asc(schema.user.id), asc(schema.user.id),
) )
.limit(limit).offset(offset), .limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: rows.map(({ profile, user }) => ({ results: rows.map(
({ profile, user }) =>
({
id: profile.id, id: profile.id,
user: sampleUser(user, profile.realName), user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber, acceptedNumber: profile.acceptedNumber,
@@ -132,7 +165,8 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。 // 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。 // 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
isOnline: null, isOnline: null,
} satisfies RankProfile)), }) satisfies RankProfile,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies AdminUserRank) } satisfies AdminUserRank)
}) })
@@ -147,15 +181,18 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
// 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。 // 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。
// 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。 // 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。
const parsedType = adminTypeSchema.safeParse(type) const parsedType = adminTypeSchema.safeParse(type)
if (!parsedType.success) return failure(c, 400, "invalid-request", "角色筛选值不合法") if (!parsedType.success)
return failure(c, 400, "invalid-request", "角色筛选值不合法")
filters.push(eq(schema.user.adminType, parsedType.data)) filters.push(eq(schema.user.adminType, parsedType.data))
} }
if (keyword) { if (keyword) {
filters.push(or( filters.push(
or(
ilike(schema.user.username, `%${keyword}%`), ilike(schema.user.username, `%${keyword}%`),
ilike(schema.userProfile.realName, `%${keyword}%`), ilike(schema.userProfile.realName, `%${keyword}%`),
ilike(schema.user.email, `%${keyword}%`), ilike(schema.user.email, `%${keyword}%`),
)!) )!,
)
} }
const where = filters.length ? and(...filters) : undefined const where = filters.length ? and(...filters) : undefined
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次 // 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
@@ -166,10 +203,13 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
// 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来 // 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来
// 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。 // 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。
// 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。 // 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。
const order = orderBy === "-online" const order =
orderBy === "-online"
? [ ? [
...(online.size ...(online.size
? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`] ? [
sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`,
]
: []), : []),
sql`${schema.user.lastLogin} desc nulls last`, sql`${schema.user.lastLogin} desc nulls last`,
] ]
@@ -178,11 +218,25 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
: [desc(schema.user.createTime)] : [desc(schema.user.createTime)]
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.user) db
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where), .select({ value: count() })
db.select({ user: schema.user, realName: schema.userProfile.realName }).from(schema.user) .from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where) .leftJoin(
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset), schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where),
db
.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(...order, asc(schema.user.id))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: rows.map((row) => serialize(row, online.has(row.user.id))), results: rows.map((row) => serialize(row, online.has(row.user.id))),
@@ -198,9 +252,16 @@ adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => { adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateUserRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateUserRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const data = parsed.data const data = parsed.data
const [existing] = await selectUser(id) const [existing] = await selectUser(id)
@@ -209,14 +270,24 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const username = data.username.trim().toLowerCase() const username = data.username.trim().toLowerCase()
const email = data.email.trim().toLowerCase() const email = data.email.trim().toLowerCase()
const className = classNameOf(username) const className = classNameOf(username)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message) if (!className.ok)
return failure(c, 400, "invalid-class-name", className.message)
const [dupUsername] = await db.select({ id: schema.user.id }).from(schema.user) const [dupUsername] = await db
.where(and(eq(schema.user.username, username), ne(schema.user.id, id))).limit(1) .select({ id: schema.user.id })
if (dupUsername) return failure(c, 409, "username-exists", "Username already exists") .from(schema.user)
.where(and(eq(schema.user.username, username), ne(schema.user.id, id)))
.limit(1)
if (dupUsername)
return failure(c, 409, "username-exists", "Username already exists")
// 比 lower(email):存量数据里有大小写混着的邮箱,按原值比会漏掉冲突 // 比 lower(email):存量数据里有大小写混着的邮箱,按原值比会漏掉冲突
const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user) const [dupEmail] = await db
.where(and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id))).limit(1) .select({ id: schema.user.id })
.from(schema.user)
.where(
and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id)),
)
.limit(1)
if (dupEmail) return failure(c, 409, "email-exists", "Email already exists") if (dupEmail) return failure(c, 409, "email-exists", "Email already exists")
const patch: Partial<typeof schema.user.$inferInsert> = { const patch: Partial<typeof schema.user.$inferInsert> = {
@@ -225,7 +296,10 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
className: className.value, className: className.value,
adminType: data.adminType, adminType: data.adminType,
isDisabled: data.isDisabled, isDisabled: data.isDisabled,
problemPermission: normalizePermission(data.adminType, data.problemPermission), problemPermission: normalizePermission(
data.adminType,
data.problemPermission,
),
} }
if (data.password) { if (data.password) {
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求, // 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
@@ -248,10 +322,14 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
* 退 * 退
*/ */
if (existing.user.username !== username) { if (existing.user.username !== username) {
await tx.update(schema.submission).set({ username }) await tx
.update(schema.submission)
.set({ username })
.where(eq(schema.submission.userId, id)) .where(eq(schema.submission.userId, id))
} }
await tx.update(schema.userProfile).set({ realName: data.realName }) await tx
.update(schema.userProfile)
.set({ realName: data.realName })
.where(eq(schema.userProfile.userId, id)) .where(eq(schema.userProfile.userId, id))
}) })
@@ -272,12 +350,26 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
}) })
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
const parsed = importUsersRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = importUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const rows = parsed.data.users const rows = parsed.data.users
type Prepared = { username: string; password: string; raw: string; email: string; realName: string; className: string | null } type Prepared = {
username: string
password: string
raw: string
email: string
realName: string
className: string | null
}
// 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数 // 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数
// (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。 // (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。
@@ -289,48 +381,94 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
for (const [username, password, email, realName] of rows) { for (const [username, password, email, realName] of rows) {
const name = username.toLowerCase() const name = username.toLowerCase()
const className = classNameOf(name) const className = classNameOf(name)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message) if (!className.ok)
return failure(c, 400, "invalid-class-name", className.message)
const mail = email.trim().toLowerCase() const mail = email.trim().toLowerCase()
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前 // 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批 // 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
// 必然重号。存进去不会报错(库里没有唯一约束),但这两个账号从此**编辑不了**: // 必然重号。存进去不会报错(库里没有唯一约束),但这两个账号从此**编辑不了**:
// PUT 一保存就撞自己的查重回 409,老师只看到「Email already exists」。 // PUT 一保存就撞自己的查重回 409,老师只看到「Email already exists」。
if (!z.email().max(64).safeParse(mail).success) { if (!z.email().max(64).safeParse(mail).success) {
return failure(c, 400, "invalid-email", `用户 ${name} 的邮箱 ${mail || "(空)"} 不是合法邮箱`) return failure(
c,
400,
"invalid-email",
`用户 ${name} 的邮箱 ${mail || "(空)"} 不是合法邮箱`,
)
} }
prepared.push({ username: name, password: "", raw: password, email: mail, realName, className: className.value }) prepared.push({
username: name,
password: "",
raw: password,
email: mail,
realName,
className: className.value,
})
} }
const dupInBatch = (values: string[]) => { const dupInBatch = (values: string[]) => {
const seen = new Set<string>() const seen = new Set<string>()
return [...new Set(values.filter((value) => seen.size === seen.add(value).size))] return [
...new Set(values.filter((value) => seen.size === seen.add(value).size)),
]
} }
const batchNames = dupInBatch(prepared.map((item) => item.username)) const batchNames = dupInBatch(prepared.map((item) => item.username))
if (batchNames.length) { if (batchNames.length) {
return failure(c, 409, "username-exists", `这批名单里用户名重复:${batchNames.join("、")}`) return failure(
c,
409,
"username-exists",
`这批名单里用户名重复:${batchNames.join("、")}`,
)
} }
const batchMails = dupInBatch(prepared.map((item) => item.email)) const batchMails = dupInBatch(prepared.map((item) => item.email))
if (batchMails.length) { if (batchMails.length) {
return failure(c, 409, "email-exists", `这批名单里邮箱重复:${batchMails.join("、")}`) return failure(
c,
409,
"email-exists",
`这批名单里邮箱重复:${batchMails.join("、")}`,
)
} }
const existing = await db.select({ username: schema.user.username, email: schema.user.email }) const existing = await db
.select({ username: schema.user.username, email: schema.user.email })
.from(schema.user) .from(schema.user)
.where(or( .where(
inArray(schema.user.username, prepared.map((item) => item.username)), or(
inArray(sql`lower(${schema.user.email})`, prepared.map((item) => item.email)), inArray(
)) schema.user.username,
prepared.map((item) => item.username),
),
inArray(
sql`lower(${schema.user.email})`,
prepared.map((item) => item.email),
),
),
)
const takenNames = new Set(prepared.map((item) => item.username)) const takenNames = new Set(prepared.map((item) => item.username))
const clashNames = existing.filter((row) => takenNames.has(row.username)).map((row) => row.username) const clashNames = existing
.filter((row) => takenNames.has(row.username))
.map((row) => row.username)
if (clashNames.length) { if (clashNames.length) {
return failure(c, 409, "username-exists", `用户名已存在:${clashNames.join("、")}`) return failure(
c,
409,
"username-exists",
`用户名已存在:${clashNames.join("、")}`,
)
} }
const takenMails = new Set(prepared.map((item) => item.email)) const takenMails = new Set(prepared.map((item) => item.email))
const clashMails = existing const clashMails = existing
.map((row) => row.email?.toLowerCase()) .map((row) => row.email?.toLowerCase())
.filter((mail): mail is string => !!mail && takenMails.has(mail)) .filter((mail): mail is string => !!mail && takenMails.has(mail))
if (clashMails.length) { if (clashMails.length) {
return failure(c, 409, "email-exists", `邮箱已被占用:${[...new Set(clashMails)].join("、")}`) return failure(
c,
409,
"email-exists",
`邮箱已被占用:${[...new Set(clashMails)].join("、")}`,
)
} }
// argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all // argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all
@@ -339,16 +477,24 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
// 固定 4 路并发,瞬时峰值 76MiB 封顶。 // 固定 4 路并发,瞬时峰值 76MiB 封顶。
const HASH_CONCURRENCY = 4 const HASH_CONCURRENCY = 4
let cursor = 0 let cursor = 0
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => { await Promise.all(
Array.from(
{ length: Math.min(HASH_CONCURRENCY, prepared.length) },
async () => {
while (cursor < prepared.length) { while (cursor < prepared.length) {
const item = prepared[cursor++]! const item = prepared[cursor++]!
item.password = await hashPassword(item.raw) item.password = await hashPassword(item.raw)
} }
})) },
),
)
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在 // 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
const created = await db.transaction(async (tx) => { const created = await db.transaction(async (tx) => {
const users = await tx.insert(schema.user).values(prepared.map((item) => ({ const users = await tx
.insert(schema.user)
.values(
prepared.map((item) => ({
username: item.username, username: item.username,
password: item.password, password: item.password,
rawPassword: item.raw, rawPassword: item.raw,
@@ -358,9 +504,12 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
problemPermission: "None" as const, problemPermission: "None" as const,
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
isDisabled: false, isDisabled: false,
}))).returning({ id: schema.user.id, username: schema.user.username }) })),
)
.returning({ id: schema.user.id, username: schema.user.username })
const byName = new Map(users.map((row) => [row.username, row.id])) const byName = new Map(users.map((row) => [row.username, row.id]))
await tx.insert(schema.userProfile).values(prepared.map((item) => ({ await tx.insert(schema.userProfile).values(
prepared.map((item) => ({
userId: byName.get(item.username)!, userId: byName.get(item.username)!,
realName: item.realName, realName: item.realName,
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致 // avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
@@ -368,7 +517,8 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
acmProblemsStatus: {}, acmProblemsStatus: {},
submissionNumber: 0, submissionNumber: 0,
acceptedNumber: 0, acceptedNumber: 0,
}))) })),
)
return users.length return users.length
}) })
return success(c, { imported: created }, 201) return success(c, { imported: created }, 201)
@@ -380,7 +530,11 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
* 500 * 500
*/ */
function isForeignKeyViolation(error: unknown) { function isForeignKeyViolation(error: unknown) {
for (let current = error; current; current = (current as { cause?: unknown }).cause) { for (
let current = error;
current;
current = (current as { cause?: unknown }).cause
) {
if ((current as { code?: string }).code === "23503") return true if ((current as { code?: string }).code === "23503") return true
} }
return false return false
@@ -390,11 +544,19 @@ function isForeignKeyViolation(error: unknown) {
class UserHasSubmissionsError extends Error {} class UserHasSubmissionsError extends Error {}
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => { adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = deleteUsersRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "ids is required")
const me = c.get("user")!.id const me = c.get("user")!.id
if (parsed.data.ids.includes(me)) { if (parsed.data.ids.includes(me)) {
return failure(c, 400, "cannot-delete-self", "Current user can not be deleted") return failure(
c,
400,
"cannot-delete-self",
"Current user can not be deleted",
)
} }
// 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大, // 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大,
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来: // 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
@@ -425,30 +587,55 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
.limit(1) .limit(1)
if (withSubmission) throw new UserHasSubmissionsError() if (withSubmission) throw new UserHasSubmissionsError()
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids)) return tx
.delete(schema.user)
.where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id }) .returning({ id: schema.user.id })
}) })
return success(c, { deleted: deleted.length }) return success(c, { deleted: deleted.length })
} catch (error) { } catch (error) {
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸 // 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见 // catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error if (
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号") !(error instanceof UserHasSubmissionsError) &&
!isForeignKeyViolation(error)
)
throw error
return failure(
c,
409,
"user-in-use",
"该用户还有提交、题目等历史数据,无法删除;请改为禁用账号",
)
} }
}) })
adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c) => { adminAccountRoutes.post(
"/users/:id/reset-password",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db.select({ id: schema.user.id }).from(schema.user) const [existing] = await db
.where(eq(schema.user.id, id)).limit(1) .select({ id: schema.user.id })
if (!existing) return failure(c, 404, "user-not-found", "User does not exist") .from(schema.user)
.where(eq(schema.user.id, id))
.limit(1)
if (!existing)
return failure(c, 404, "user-not-found", "User does not exist")
// 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清 // 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清
const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("") const password = Array.from(
await db.update(schema.user).set({ { length: 6 },
() => "123456789"[randomInt(9)],
).join("")
await db
.update(schema.user)
.set({
password: await hashPassword(password), password: await hashPassword(password),
rawPassword: password, rawPassword: password,
}).where(eq(schema.user.id, id)) })
.where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id // 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended") await revokeUserSessions(id, "session-ended")
return success(c, { password } satisfies ResetPasswordResponse) return success(c, { password } satisfies ResetPasswordResponse)
}) },
)
+90 -26
View File
@@ -10,7 +10,11 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db" import { db, schema } from "../../db"
import { failure, success } from "../../http" import { failure, success } from "../../http"
import { ACHIEVEMENT_METRICS, findMetric, metricName } from "../../services/achievement-metrics" import {
ACHIEVEMENT_METRICS,
findMetric,
metricName,
} from "../../services/achievement-metrics"
import { rescanAchievement } from "../../services/achievements" import { rescanAchievement } from "../../services/achievements"
import { queryInteger } from "../helpers" import { queryInteger } from "../helpers"
@@ -37,54 +41,102 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */ /** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) => adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[])) success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]),
)
adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => { adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.achievement) const rows = await db
.select()
.from(schema.achievement)
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id)) .orderBy(asc(schema.achievement.order), asc(schema.achievement.id))
return success(c, rows.map(serialize)) return success(c, rows.map(serialize))
}) })
adminAchievementRoutes.get("/achievements/:id", requireSuperAdmin, async (c) => { adminAchievementRoutes.get(
const [row] = await db.select().from(schema.achievement) "/achievements/:id",
.where(eq(schema.achievement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1) requireSuperAdmin,
async (c) => {
const [row] = await db
.select()
.from(schema.achievement)
.where(
eq(
schema.achievement.id,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.limit(1)
if (!row) return failure(c, 404, "achievement-not-found", "成就不存在") if (!row) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, serialize(row)) return success(c, serialize(row))
}) },
)
adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => { adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
const parsed = createAchievementRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在") if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
const [created] = await db.insert(schema.achievement).values({ const [created] = await db
.insert(schema.achievement)
.values({
...parsed.data, ...parsed.data,
unlockCount: 0, unlockCount: 0,
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
}).returning() })
.returning()
// 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就 // 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就
// 只有从今往后的提交才算,老用户永远拿不到 // 只有从今往后的提交才算,老用户永远拿不到
await rescanAchievement(created!.id) await rescanAchievement(created!.id)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, created!.id)).limit(1) const [row] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, created!.id))
.limit(1)
return success(c, serialize(row!), 201) return success(c, serialize(row!), 201)
}) })
adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => { adminAchievementRoutes.put(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在") if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
const [before] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1) const [before] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, id))
.limit(1)
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在") if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
const [after] = await db.update(schema.achievement).set(parsed.data) const [after] = await db
.where(eq(schema.achievement.id, id)).returning() .update(schema.achievement)
.set(parsed.data)
.where(eq(schema.achievement.id, id))
.returning()
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略), // 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。 // 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
@@ -97,15 +149,27 @@ adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) =>
before.visible !== after!.visible before.visible !== after!.visible
if (after!.visible && changed) await rescanAchievement(id) if (after!.visible && changed) await rescanAchievement(id)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1) const [row] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, id))
.limit(1)
return success(c, serialize(row!)) return success(c, serialize(row!))
}) },
)
adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => { adminAchievementRoutes.delete(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE0010 // 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE0010
const deleted = await db.delete(schema.achievement).where(eq(schema.achievement.id, id)) const deleted = await db
.delete(schema.achievement)
.where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id }) .returning({ id: schema.achievement.id })
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在") if (deleted.length === 0)
return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, null) return success(c, null)
}) },
)
+58 -17
View File
@@ -21,7 +21,13 @@ function excerpt(analysis: string | null) {
return text.length <= 120 ? text : `${text.slice(0, 120)}` return text.length <= 120 ? text : `${text.slice(0, 120)}`
} }
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) { function listItem(row: {
id: number
username: string
createTime: string
analysis: string
isPinned: boolean
}) {
return { return {
id: row.id, id: row.id,
username: row.username, username: row.username,
@@ -41,7 +47,9 @@ const listColumns = {
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => { adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim() const username = c.req.query("username")?.trim()
const where = username ? ilike(schema.user.username, `%${username}%`) : undefined const where = username
? ilike(schema.user.username, `%${username}%`)
: undefined
// 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走。 // 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走。
// 但**形状必须和分页那支一样**:同一个 URL 返回两种形状,调用方没法照着一个类型写。 // 但**形状必须和分页那支一样**:同一个 URL 返回两种形状,调用方没法照着一个类型写。
@@ -49,7 +57,9 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
// 读的是 res.results,于是拿到 undefined`pinnedReports.length` 在渲染时抛 // 读的是 res.results,于是拿到 undefined`pinnedReports.length` 在渲染时抛
// 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。 // 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。
if (c.req.query("pinnedOnly") === "true") { if (c.req.query("pinnedOnly") === "true") {
const rows = await db.select(listColumns).from(schema.aiAnalysis) const rows = await db
.select(listColumns)
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.isPinned, true), where)) .where(and(eq(schema.aiAnalysis.isPinned, true), where))
.orderBy(desc(schema.aiAnalysis.createTime)) .orderBy(desc(schema.aiAnalysis.createTime))
@@ -62,11 +72,19 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.aiAnalysis) db
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where), .select({ value: count() })
db.select(listColumns).from(schema.aiAnalysis) .from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset), .where(where),
db
.select(listColumns)
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.aiAnalysis.createTime))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: rows.map(listItem), results: rows.map(listItem),
@@ -75,15 +93,20 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
}) })
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => { adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
const [row] = await db.select({ const [row] = await db
.select({
id: schema.aiAnalysis.id, id: schema.aiAnalysis.id,
username: schema.user.username, username: schema.user.username,
className: schema.user.className, className: schema.user.className,
createTime: schema.aiAnalysis.createTime, createTime: schema.aiAnalysis.createTime,
analysis: schema.aiAnalysis.analysis, analysis: schema.aiAnalysis.analysis,
}).from(schema.aiAnalysis) })
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1) .where(
eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.limit(1)
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found") if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词 // data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
return success(c, row satisfies AdminAiReport) return success(c, row satisfies AdminAiReport)
@@ -91,18 +114,36 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => { adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [report] = await db.select({ id: schema.aiAnalysis.id, userId: schema.aiAnalysis.userId, isPinned: schema.aiAnalysis.isPinned }) const [report] = await db
.from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1) .select({
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found") id: schema.aiAnalysis.id,
userId: schema.aiAnalysis.userId,
isPinned: schema.aiAnalysis.isPinned,
})
.from(schema.aiAnalysis)
.where(eq(schema.aiAnalysis.id, id))
.limit(1)
if (!report)
return failure(c, 404, "report-not-found", "AIAnalysis not found")
// 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份 // 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份
const next = !report.isPinned const next = !report.isPinned
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
if (next) { if (next) {
await tx.update(schema.aiAnalysis).set({ isPinned: false }) await tx
.where(and(eq(schema.aiAnalysis.userId, report.userId), eq(schema.aiAnalysis.isPinned, true))) .update(schema.aiAnalysis)
.set({ isPinned: false })
.where(
and(
eq(schema.aiAnalysis.userId, report.userId),
eq(schema.aiAnalysis.isPinned, true),
),
)
} }
await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id)) await tx
.update(schema.aiAnalysis)
.set({ isPinned: next })
.where(eq(schema.aiAnalysis.id, id))
}) })
return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse) return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
}) })
+92 -23
View File
@@ -34,7 +34,11 @@ function serialize(row: {
function selectOne(id: number) { function selectOne(id: number) {
return db return db
.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName }) .select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement) .from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -47,10 +51,21 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.announcement), db.select({ value: count() }).from(schema.announcement),
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName }) db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement) .from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id)) .innerJoin(
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) schema.user,
eq(schema.announcement.createdById, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.orderBy(desc(schema.announcement.createTime)) .orderBy(desc(schema.announcement.createTime))
.limit(limit) .limit(limit)
.offset(offset), .offset(offset),
@@ -63,52 +78,106 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
}) })
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => { adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
const parsed = createAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const now = new Date().toISOString() const now = new Date().toISOString()
const [created] = await db.insert(schema.announcement).values({ const [created] = await db
.insert(schema.announcement)
.values({
...parsed.data, ...parsed.data,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
}).returning({ id: schema.announcement.id }) })
.returning({ id: schema.announcement.id })
const [row] = await selectOne(created!.id) const [row] = await selectOne(created!.id)
return success(c, serialize(row!), 201) return success(c, serialize(row!), 201)
}) })
adminAnnouncementRoutes.get("/announcements/:id", requireSuperAdmin, async (c) => { adminAnnouncementRoutes.get(
const [row] = await selectOne(queryInteger(c.req.param("id"), 0, { min: 1 })) "/announcements/:id",
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist") requireSuperAdmin,
async (c) => {
const [row] = await selectOne(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row)
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
return success(c, serialize(row)) return success(c, serialize(row))
}) },
)
adminAnnouncementRoutes.put("/announcements/:id", requireSuperAdmin, async (c) => { adminAnnouncementRoutes.put(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const updated = await db.update(schema.announcement) const updated = await db
.update(schema.announcement)
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() }) .set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.announcement.id, id)) .where(eq(schema.announcement.id, id))
.returning({ id: schema.announcement.id }) .returning({ id: schema.announcement.id })
if (updated.length === 0) { if (updated.length === 0) {
return failure(c, 404, "announcement-not-found", "Announcement does not exist") return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
} }
const [row] = await selectOne(id) const [row] = await selectOne(id)
return success(c, serialize(row!)) return success(c, serialize(row!))
}) },
)
adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => { adminAnnouncementRoutes.delete(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404: // 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。 // 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
const deleted = await db.delete(schema.announcement) const deleted = await db
.where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .delete(schema.announcement)
.where(
eq(
schema.announcement.id,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.returning({ id: schema.announcement.id }) .returning({ id: schema.announcement.id })
if (deleted.length === 0) { if (deleted.length === 0) {
return failure(c, 404, "announcement-not-found", "Announcement does not exist") return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
} }
return success(c, null) return success(c, null)
}) },
)
+132 -39
View File
@@ -14,7 +14,11 @@ import { resolve } from "node:path"
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm" import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware" import {
requireAdmin,
requireSuperAdmin,
type AppEnv,
} from "../../auth/middleware"
import { config } from "../../config" import { config } from "../../config"
import { db, schema } from "../../db" import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events" import { publishConfigUpdate } from "../../events"
@@ -39,7 +43,9 @@ function aliveSince() {
* **线** * **线**
*/ */
function isAlive(lastHeartbeat: string) { function isAlive(lastHeartbeat: string) {
return Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000 return (
Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
)
} }
// ---------------------------------------------------------------- 网站配置 // ---------------------------------------------------------------- 网站配置
@@ -71,14 +77,24 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
}) })
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => { adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
const parsed = updateWebsiteConfigRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateWebsiteConfigRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const entries = (Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]) const entries = (
.map(([field, key]) => ({ field, key, value: parsed.data[field] })) Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
// 8 个键一条 upsert 写完,不再一个键一次往返 // 8 个键一条 upsert 写完,不再一个键一次往返
await db.insert(schema.optionsSysoptions).values(entries.map(({ key, value }) => ({ key, value }))) await db
.insert(schema.optionsSysoptions)
.values(entries.map(({ key, value }) => ({ key, value })))
.onConflictDoUpdate({ .onConflictDoUpdate({
target: schema.optionsSysoptions.key, target: schema.optionsSysoptions.key,
set: { value: sql`excluded.value` }, set: { value: sql`excluded.value` },
@@ -89,45 +105,75 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边 // snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段, // 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段,
// 一条也命中不了,整个「改完不必刷新」空转了很久。 // 一条也命中不了,整个「改完不必刷新」空转了很久。
for (const entry of entries) await publishConfigUpdate(entry.field, entry.value) for (const entry of entries)
await publishConfigUpdate(entry.field, entry.value)
return success(c, null) return success(c, null)
}) })
// ---------------------------------------------------------------- 判题机 // ---------------------------------------------------------------- 判题机
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => { adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat)) const rows = await db
.select()
.from(schema.judgeServer)
.orderBy(desc(schema.judgeServer.lastHeartbeat))
return success(c, { return success(c, {
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的 // 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
token: config.judgeServerToken, token: config.judgeServerToken,
servers: rows.map((row) => ({ servers: rows.map(
(row) =>
({
...row, ...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal", status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
} satisfies JudgeServer)), }) satisfies JudgeServer,
),
} satisfies JudgeServerList) } satisfies JudgeServerList)
}) })
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => { adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
const parsed = updateJudgeServerRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateJudgeServerRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required") await c.req.json().catch(() => null),
const updated = await db.update(schema.judgeServer) )
if (!parsed.success)
return failure(c, 400, "invalid-request", "isDisabled is required")
const updated = await db
.update(schema.judgeServer)
.set({ isDisabled: parsed.data.isDisabled }) .set({ isDisabled: parsed.data.isDisabled })
.where(eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .where(
eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.returning({ id: schema.judgeServer.id }) .returning({ id: schema.judgeServer.id })
if (updated.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist") if (updated.length === 0)
return failure(
c,
404,
"judge-server-not-found",
"Judge server does not exist",
)
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。 // 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交 // 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。 // 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
return success(c, null) return success(c, null)
}) })
adminConfRoutes.delete("/judge-servers/:hostname", requireSuperAdmin, async (c) => { adminConfRoutes.delete(
const deleted = await db.delete(schema.judgeServer) "/judge-servers/:hostname",
requireSuperAdmin,
async (c) => {
const deleted = await db
.delete(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, c.req.param("hostname"))) .where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
.returning({ id: schema.judgeServer.id }) .returning({ id: schema.judgeServer.id })
if (deleted.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist") if (deleted.length === 0)
return failure(
c,
404,
"judge-server-not-found",
"Judge server does not exist",
)
return success(c, null) return success(c, null)
}) },
)
// ---------------------------------------------------------------- 孤儿测试用例 // ---------------------------------------------------------------- 孤儿测试用例
@@ -140,15 +186,24 @@ async function orphanTestCaseIds() {
db.select({ id: schema.problem.testCaseId }).from(schema.problem), db.select({ id: schema.problem.testCaseId }).from(schema.problem),
]) ])
const referenced = new Set(inDb.map((row) => row.id)) const referenced = new Set(inDb.map((row) => row.id))
return onDisk.filter((name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name)) return onDisk.filter(
(name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name),
)
} }
adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => { adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
const ids = await orphanTestCaseIds() const ids = await orphanTestCaseIds()
const rows = await Promise.all(ids.map(async (id) => { const rows = await Promise.all(
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null) ids.map(async (id) => {
return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase const info = await stat(resolve(config.testCaseDirectory, id)).catch(
})) () => null,
)
return {
id,
createTime: info ? info.mtimeMs / 1000 : 0,
} satisfies OrphanTestCase
}),
)
return success(c, rows) return success(c, rows)
}) })
@@ -159,10 +214,18 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
// 而测试数据没有别处备份 —— 旧后端这里是不校验的。 // 而测试数据没有别处备份 —— 旧后端这里是不校验的。
const targets = requested ? orphans.filter((id) => id === requested) : orphans const targets = requested ? orphans.filter((id) => id === requested) : orphans
if (requested && targets.length === 0) { if (requested && targets.length === 0) {
return failure(c, 404, "not-an-orphan", "该用例目录不存在或仍被题目引用,未删除") return failure(
c,
404,
"not-an-orphan",
"该用例目录不存在或仍被题目引用,未删除",
)
} }
for (const id of targets) { for (const id of targets) {
await rm(resolve(config.testCaseDirectory, id), { recursive: true, force: true }) await rm(resolve(config.testCaseDirectory, id), {
recursive: true,
force: true,
})
} }
return success(c, { deleted: targets.length }) return success(c, { deleted: targets.length })
}) })
@@ -173,11 +236,17 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
const now = new Date().toISOString() const now = new Date().toISOString()
const [[users], [submissions], [contests], [servers]] = await Promise.all([ const [[users], [submissions], [contests], [servers]] = await Promise.all([
db.select({ value: count() }).from(schema.user), db.select({ value: count() }).from(schema.user),
db.select({ value: count() }).from(schema.submission) db
.select({ value: count() })
.from(schema.submission)
.where(gte(schema.submission.createTime, todayStart())), .where(gte(schema.submission.createTime, todayStart())),
db.select({ value: count() }).from(schema.contest) db
.select({ value: count() })
.from(schema.contest)
.where(not(sql`${schema.contest.endTime} < ${now}`)), .where(not(sql`${schema.contest.endTime} < ${now}`)),
db.select({ value: count() }).from(schema.judgeServer) db
.select({ value: count() })
.from(schema.judgeServer)
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())), .where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
]) ])
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发 // 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
@@ -195,10 +264,16 @@ adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松 // 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
const classroom = c.req.query("classroom")?.trim() const classroom = c.req.query("classroom")?.trim()
if (!classroom) return failure(c, 400, "invalid-request", "需要班级号") if (!classroom) return failure(c, 400, "invalid-request", "需要班级号")
const rows = await db.select({ username: schema.user.username }).from(schema.user) const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(ilike(schema.user.username, `${classroom}%`)) .where(ilike(schema.user.username, `${classroom}%`))
.orderBy(sql`random()`).limit(10) .orderBy(sql`random()`)
return success(c, rows.map((row) => row.username)) .limit(10)
return success(
c,
rows.map((row) => row.username),
)
}) })
// ---------------------------------------------------------------- 富文本图片上传 // ---------------------------------------------------------------- 富文本图片上传
@@ -219,16 +294,28 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
const form = await c.req.formData().catch(() => null) const form = await c.req.formData().catch(() => null)
const image = form?.get("image") const image = form?.get("image")
if (!(image instanceof File)) { if (!(image instanceof File)) {
return success(c, { success: false, msg: "Upload failed", filePath: "" } satisfies UploadImageResponse) return success(c, {
success: false,
msg: "Upload failed",
filePath: "",
} satisfies UploadImageResponse)
} }
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase() const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
if (!IMAGE_SUFFIXES.includes(suffix)) { if (!IMAGE_SUFFIXES.includes(suffix)) {
return success(c, { success: false, msg: "Unsupported file format", filePath: "" } satisfies UploadImageResponse) return success(c, {
success: false,
msg: "Unsupported file format",
filePath: "",
} satisfies UploadImageResponse)
} }
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上, // 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂 // 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
if (image.size > MAX_IMAGE_BYTES) { if (image.size > MAX_IMAGE_BYTES) {
return success(c, { success: false, msg: "图片不能超过 10MB", filePath: "" } satisfies UploadImageResponse) return success(c, {
success: false,
msg: "图片不能超过 10MB",
filePath: "",
} satisfies UploadImageResponse)
} }
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来 // 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
const name = `${randomFileName()}${suffix}` const name = `${randomFileName()}${suffix}`
@@ -237,7 +324,11 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
await Bun.write(resolve(config.uploadDirectory, name), image) await Bun.write(resolve(config.uploadDirectory, name), image)
} catch (error) { } catch (error) {
console.error("Failed to save uploaded image", error) console.error("Failed to save uploaded image", error)
return success(c, { success: false, msg: "Upload Error", filePath: "" } satisfies UploadImageResponse) return success(c, {
success: false,
msg: "Upload Error",
filePath: "",
} satisfies UploadImageResponse)
} }
return success(c, { return success(c, {
success: true, success: true,
@@ -247,6 +338,8 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
}) })
function randomFileName() { function randomFileName() {
return Array.from({ length: 10 }, () => return Array.from(
"abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("") { length: 10 },
() => "abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)],
).join("")
} }
+168 -50
View File
@@ -49,18 +49,25 @@ async function serialize(row: {
} }
function selectContest(id: number) { function selectContest(id: number) {
return db.select({ contest: schema.contest, user: schema.user, realName: schema.userProfile.realName }) return db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.contest) .from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.contest.id, id)).limit(1) .where(eq(schema.contest.id, id))
.limit(1)
} }
/** 请求体里的时间校验,创建和编辑共用 */ /** 请求体里的时间校验,创建和编辑共用 */
function validatePayload(data: { startTime: string; endTime: string }) { function validatePayload(data: { startTime: string; endTime: string }) {
const start = Date.parse(data.startTime) const start = Date.parse(data.startTime)
const end = Date.parse(data.endTime) const end = Date.parse(data.endTime)
if (!Number.isFinite(start) || !Number.isFinite(end)) return "开始或结束时间不是合法的时间格式" if (!Number.isFinite(start) || !Number.isFinite(end))
return "开始或结束时间不是合法的时间格式"
if (end <= start) return "Start time must occur earlier than end time" if (end <= start) return "Start time must occur earlier than end time"
return null return null
} }
@@ -71,18 +78,30 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const filters = [] const filters = []
// 非超管只看得到自己建的比赛,与旧后端一致 // 非超管只看得到自己建的比赛,与旧后端一致
if (user.adminType !== "Super Admin") filters.push(eq(schema.contest.createdById, user.id)) if (user.adminType !== "Super Admin")
filters.push(eq(schema.contest.createdById, user.id))
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`)) if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
const where = filters.length ? and(...filters) : undefined const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.contest).where(where), db.select({ value: count() }).from(schema.contest).where(where),
db.select({ contest: schema.contest, user: schema.user, realName: schema.userProfile.realName }) db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.contest) .from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset), schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(desc(schema.contest.createTime))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: await Promise.all(rows.map(serialize)), results: await Promise.all(rows.map(serialize)),
@@ -91,7 +110,9 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
}) })
adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => { adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
const [row] = await selectContest(queryInteger(c.req.param("id"), 0, { min: 1 })) const [row] = await selectContest(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row || !ownedBy(c.get("user")!, row.contest)) { if (!row || !ownedBy(c.get("user")!, row.contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist") return failure(c, 404, "contest-not-found", "Contest does not exist")
} }
@@ -99,15 +120,24 @@ adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
}) })
adminContestRoutes.post("/contests", requireTeacher, async (c) => { adminContestRoutes.post("/contests", requireTeacher, async (c) => {
const parsed = createContestRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const error = validatePayload(parsed.data) const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error) if (error) return failure(c, 400, "invalid-contest", error)
const now = new Date().toISOString() const now = new Date().toISOString()
const [created] = await db.insert(schema.contest).values({ const [created] = await db
.insert(schema.contest)
.values({
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description, description: parsed.data.description,
tag: parsed.data.tag, tag: parsed.data.tag,
@@ -119,16 +149,24 @@ adminContestRoutes.post("/contests", requireTeacher, async (c) => {
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
}).returning({ id: schema.contest.id }) })
.returning({ id: schema.contest.id })
const [row] = await selectContest(created!.id) const [row] = await selectContest(created!.id)
return success(c, await serialize(row!), 201) return success(c, await serialize(row!), 201)
}) })
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => { adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateContestRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const [existing] = await selectContest(id) const [existing] = await selectContest(id)
if (!existing || !ownedBy(c.get("user")!, existing.contest)) { if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
@@ -137,7 +175,9 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const error = validatePayload(parsed.data) const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error) if (error) return failure(c, 400, "invalid-contest", error)
await db.update(schema.contest).set({ await db
.update(schema.contest)
.set({
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description, description: parsed.data.description,
tag: parsed.data.tag, tag: parsed.data.tag,
@@ -146,7 +186,8 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
password: parsed.data.password || null, password: parsed.data.password || null,
visible: parsed.data.visible, visible: parsed.data.visible,
lastUpdateTime: new Date().toISOString(), lastUpdateTime: new Date().toISOString(),
}).where(eq(schema.contest.id, id)) })
.where(eq(schema.contest.id, id))
const [row] = await selectContest(id) const [row] = await selectContest(id)
return success(c, await serialize(row!)) return success(c, await serialize(row!))
}) })
@@ -169,9 +210,12 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
// //
// 已知的副作用,别当成 bug 去"修":副本和原题共用同一个测试点目录(testCaseId 原样复制), // 已知的副作用,别当成 bug 去"修":副本和原题共用同一个测试点目录(testCaseId 原样复制),
// 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。 // 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!original)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const duration = Date.parse(original.contest.endTime) - Date.parse(original.contest.startTime) const duration =
Date.parse(original.contest.endTime) -
Date.parse(original.contest.startTime)
// 新比赛从 10 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地, // 新比赛从 10 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
// 又不至于建出一个已经结束的比赛 // 又不至于建出一个已经结束的比赛
const start = new Date(Date.now() + 10 * 60 * 1000) const start = new Date(Date.now() + 10 * 60 * 1000)
@@ -180,7 +224,9 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
const me = c.get("user")!.id const me = c.get("user")!.id
const cloned = await db.transaction(async (tx) => { const cloned = await db.transaction(async (tx) => {
const [contest] = await tx.insert(schema.contest).values({ const [contest] = await tx
.insert(schema.contest)
.values({
title: original.contest.title, title: original.contest.title,
description: original.contest.description, description: original.contest.description,
tag: original.contest.tag, tag: original.contest.tag,
@@ -196,15 +242,21 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
createdById: me, createdById: me,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
}).returning({ id: schema.contest.id }) })
.returning({ id: schema.contest.id })
const problems = await tx.select().from(schema.problem) const problems = await tx
.select()
.from(schema.problem)
.where(eq(schema.problem.contestId, id)) .where(eq(schema.problem.contestId, id))
if (problems.length === 0) return contest!.id if (problems.length === 0) return contest!.id
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认: // 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。 // 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
const copies = await tx.insert(schema.problem).values(problems.map(({ id: _oldId, ...rest }) => ({ const copies = await tx
.insert(schema.problem)
.values(
problems.map(({ id: _oldId, ...rest }) => ({
...rest, ...rest,
contestId: contest!.id, contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩 // 计数器归零:克隆的是题面,不是历史战绩
@@ -214,17 +266,37 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
createdById: me, createdById: me,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
}))).returning({ id: schema.problem.id, displayId: schema.problem.displayId }) })),
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id])) )
.returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(
copies.map((copy) => [copy.displayId, copy.id]),
)
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行 // 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx.select({ problemId: schema.problemTags.problemId, tagId: schema.problemTags.problemtagId }) const tags = await tx
.from(schema.problemTags).where(inArray(schema.problemTags.problemId, problems.map((problem) => problem.id))) .select({
problemId: schema.problemTags.problemId,
tagId: schema.problemTags.problemtagId,
})
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemId,
problems.map((problem) => problem.id),
),
)
if (tags.length) { if (tags.length) {
const displayIdByOldId = new Map(problems.map((problem) => [problem.id, problem.displayId])) const displayIdByOldId = new Map(
problems.map((problem) => [problem.id, problem.displayId]),
)
const links = tags.flatMap((tag) => { const links = tags.flatMap((tag) => {
const newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? "") const newId = newIdByDisplayId.get(
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }] displayIdByOldId.get(tag.problemId) ?? "",
)
return newId === undefined
? []
: [{ problemId: newId, problemtagId: tag.tagId }]
}) })
if (links.length) await tx.insert(schema.problemTags).values(links) if (links.length) await tx.insert(schema.problemTags).values(links)
} }
@@ -237,31 +309,49 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
// ---------------------------------------------------------------- ACM 赛后核查 // ---------------------------------------------------------------- ACM 赛后核查
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => { adminContestRoutes.get(
"/contests/:id/acm-helper",
requireTeacher,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的 // 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」 // PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
const [contest] = await db.select().from(schema.contest) const [contest] = await db
.where(eq(schema.contest.id, id)).limit(1) .select()
.from(schema.contest)
.where(eq(schema.contest.id, id))
.limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) { if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist") return failure(c, 404, "contest-not-found", "Contest does not exist")
} }
const [problems, ranks] = await Promise.all([ const [problems, ranks] = await Promise.all([
db.select({ id: schema.problem.id, displayId: schema.problem.displayId }) db
.from(schema.problem).where(eq(schema.problem.contestId, id)), .select({ id: schema.problem.id, displayId: schema.problem.displayId })
db.select({ .from(schema.problem)
.where(eq(schema.problem.contestId, id)),
db
.select({
id: schema.acmContestRank.id, id: schema.acmContestRank.id,
username: schema.user.username, username: schema.user.username,
realName: schema.userProfile.realName, realName: schema.userProfile.realName,
submissionInfo: schema.acmContestRank.submissionInfo, submissionInfo: schema.acmContestRank.submissionInfo,
acceptedNumber: schema.acmContestRank.acceptedNumber, acceptedNumber: schema.acmContestRank.acceptedNumber,
}).from(schema.acmContestRank) })
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)) .from(schema.acmContestRank)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(eq(schema.acmContestRank.contestId, id)), .where(eq(schema.acmContestRank.contestId, id)),
]) ])
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId])) const displayIds = new Map(
problems.map((problem) => [String(problem.id), problem.displayId]),
)
const results = [] const results = []
for (const rank of ranks) { for (const rank of ranks) {
@@ -284,26 +374,51 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
} }
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的 // 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime) results.sort((left, right) => right._acTime - left._acTime)
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[]) return success(
}) c,
results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[],
)
},
)
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => { adminContestRoutes.put(
"/contests/:id/acm-helper",
requireTeacher,
async (c) => {
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 }) const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAcmHelperRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateAcmHelperRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1) const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) { if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist") return failure(c, 404, "contest-not-found", "Contest does not exist")
} }
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 —— // rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
// 那样带上任意 rank_id 就能改别的比赛的核查标记 // 那样带上任意 rank_id 就能改别的比赛的核查标记
const [rank] = await db.select().from(schema.acmContestRank).where(and( const [rank] = await db
.select()
.from(schema.acmContestRank)
.where(
and(
eq(schema.acmContestRank.id, parsed.data.rankId), eq(schema.acmContestRank.id, parsed.data.rankId),
eq(schema.acmContestRank.contestId, contestId), eq(schema.acmContestRank.contestId, contestId),
)).limit(1) ),
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist") )
.limit(1)
if (!rank)
return failure(c, 404, "rank-not-found", "Rank id does not exist")
const info = rank.submissionInfo const info = rank.submissionInfo
const entry = info[parsed.data.problemId] const entry = info[parsed.data.problemId]
@@ -312,7 +427,10 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) =>
} }
entry.checked = parsed.data.checked entry.checked = parsed.data.checked
info[parsed.data.problemId] = entry info[parsed.data.problemId] = entry
await db.update(schema.acmContestRank).set({ submissionInfo: info }) await db
.update(schema.acmContestRank)
.set({ submissionInfo: info })
.where(eq(schema.acmContestRank.id, rank.id)) .where(eq(schema.acmContestRank.id, rank.id))
return success(c, null) return success(c, null)
}) },
)
+187 -58
View File
@@ -71,8 +71,12 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 —— // 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完 // 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
const tutorials = await db.select({ id: schema.tutorial.id }).from(schema.tutorial) const tutorials = await db
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) .select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
const tutorialIds = tutorials.map((row) => row.id) const tutorialIds = tutorials.map((row) => row.id)
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点 // 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
@@ -87,93 +91,160 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
// 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上 // 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上
// 就是这个下场,用 filter 也救不回来 // 就是这个下场,用 filter 也救不回来
const [rows, exerciseRows] = await Promise.all([ const [rows, exerciseRows] = await Promise.all([
db.select({ db
.select({
userId: schema.user.id, userId: schema.user.id,
username: schema.user.username, username: schema.user.username,
realName: schema.userProfile.realName, realName: schema.userProfile.realName,
className: schema.user.className, className: schema.user.className,
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。 // 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况 // 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number), readCount:
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number), sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`, Number,
}).from(schema.user) ),
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(
Number,
),
lastViewedAt: sql<
string | null
>`max(${schema.tutorialProgress.lastViewedAt})`,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.leftJoin(schema.tutorialProgress, progressJoin) .leftJoin(schema.tutorialProgress, progressJoin)
.where(studentCondition(className.value)) .where(studentCondition(className.value))
.groupBy(schema.user.id, schema.user.username, schema.userProfile.realName, schema.user.className), .groupBy(
db.select({ schema.user.id,
schema.user.username,
schema.userProfile.realName,
schema.user.className,
),
db
.select({
userId: schema.exerciseAttempt.userId, userId: schema.exerciseAttempt.userId,
tried: count(), tried: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number), solved:
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number), sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
}).from(schema.exerciseAttempt) Number,
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId)) ),
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId)) attempts:
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(
Number,
),
})
.from(schema.exerciseAttempt)
.innerJoin(
schema.exercise,
eq(schema.exercise.id, schema.exerciseAttempt.exerciseId),
)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(schema.exerciseAttempt.userId), .groupBy(schema.exerciseAttempt.userId),
]) ])
const attempts = new Map(exerciseRows.map((row) => [row.userId, row])) const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
const [exerciseCountRow] = tutorialIds.length const [exerciseCountRow] = tutorialIds.length
? await db.select({ value: count() }).from(schema.exercise) ? await db
.select({ value: count() })
.from(schema.exercise)
.where(inArray(schema.exercise.tutorialId, tutorialIds)) .where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }] : [{ value: 0 }]
return success(c, { return success(c, {
tutorialCount: tutorialIds.length, tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0, exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => ({ results: rows.map(
(row) =>
({
...row, ...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0, exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0, exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0, exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
} satisfies LearnStudentProgress)), }) satisfies LearnStudentProgress,
),
} satisfies LearnStudentProgressList) } satisfies LearnStudentProgressList)
}) })
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => { adminLearnRoutes.get(
"/learn-analytics/tutorials",
requireTeacher,
async (c) => {
const type = tutorialTypeOf(c.req.query("type")) const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className")) const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字") if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user) const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
.where(studentCondition(className.value)) .where(studentCondition(className.value))
const studentCount = studentCountRow?.value ?? 0 const studentCount = studentCountRow?.value ?? 0
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面 // 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
const rows = await db.select({ const rows = await db
.select({
tutorialId: schema.tutorial.id, tutorialId: schema.tutorial.id,
title: schema.tutorial.title, title: schema.tutorial.title,
order: schema.tutorial.order, order: schema.tutorial.order,
// 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、 // 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它, // 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生 // 而 progress.user_id 那边永远非空,会把过滤当没发生
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number), readers:
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number), sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长, // 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高 // 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number), readSeconds:
}).from(schema.tutorial) sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id)) Number,
.leftJoin(schema.user, and( ),
})
.from(schema.tutorial)
.leftJoin(
schema.tutorialProgress,
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
)
.leftJoin(
schema.user,
and(
eq(schema.user.id, schema.tutorialProgress.userId), eq(schema.user.id, schema.tutorialProgress.userId),
studentCondition(className.value), studentCondition(className.value),
)) ),
)
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉, // 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行 // 而「一节课一个人都没读」恰恰是老师最需要看见的一行
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) .where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order)) .orderBy(asc(schema.tutorial.order))
return success(c, { return success(c, {
studentCount, studentCount,
results: rows.map(({ readSeconds, ...row }) => ({ results: rows.map(
({ readSeconds, ...row }) =>
({
...row, ...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0, avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
} satisfies LearnTutorialProgress)), }) satisfies LearnTutorialProgress,
),
} satisfies LearnTutorialProgressList) } satisfies LearnTutorialProgressList)
}) },
)
/** /**
* *
@@ -181,15 +252,21 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
* / * /
* *
*/ */
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => { adminLearnRoutes.get(
"/learn-analytics/exercises",
requireTeacher,
async (c) => {
const type = tutorialTypeOf(c.req.query("type")) const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className")) const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字") if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user) const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
.where(studentCondition(className.value)) .where(studentCondition(className.value))
const rows = await db.select({ const rows = await db
.select({
exerciseId: schema.exercise.id, exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id, tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title, tutorialTitle: schema.tutorial.title,
@@ -198,40 +275,79 @@ adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) =>
order: schema.exercise.order, order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉 // 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`, question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number), triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(
solvedUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number), Number,
firstTryUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(Number), ),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number), solvedUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
firstTryUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(
Number,
),
attempts:
sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花 // 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve: sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number), avgAttemptsToSolve:
}).from(schema.exercise) sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId)) Number,
.leftJoin(schema.exerciseAttempt, eq(schema.exerciseAttempt.exerciseId, schema.exercise.id)) ),
})
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.leftJoin(
schema.exerciseAttempt,
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
)
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉 // 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(schema.user, and( .leftJoin(
schema.user,
and(
eq(schema.user.id, schema.exerciseAttempt.userId), eq(schema.user.id, schema.exerciseAttempt.userId),
studentCondition(className.value), studentCondition(className.value),
)) ),
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) )
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(
schema.exercise.id,
schema.tutorial.id,
schema.tutorial.title,
schema.tutorial.order,
)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order)) .orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, { return success(c, {
studentCount: studentCountRow?.value ?? 0, studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => ({ results: rows.map(
(row) =>
({
...row, ...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1), avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
} satisfies LearnExerciseProgress)), }) satisfies LearnExerciseProgress,
),
} satisfies LearnExerciseProgressList) } satisfies LearnExerciseProgressList)
}) },
)
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */ /** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => { adminLearnRoutes.get(
"/learn-analytics/exercises/:id/attempts",
requireTeacher,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className")) const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字") if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const rows = await db.select({ const rows = await db
.select({
userId: schema.user.id, userId: schema.user.id,
username: schema.user.username, username: schema.user.username,
realName: schema.userProfile.realName, realName: schema.userProfile.realName,
@@ -242,12 +358,25 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher,
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve, attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer, lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt, lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
}).from(schema.exerciseAttempt) })
.from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId)) .innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(
.where(and(eq(schema.exerciseAttempt.exerciseId, id), studentCondition(className.value))) schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.exerciseAttempt.exerciseId, id),
studentCondition(className.value),
),
)
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的 // 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts)) .orderBy(
asc(schema.exerciseAttempt.solved),
desc(schema.exerciseAttempt.wrongAttempts),
)
return success(c, rows satisfies LearnExerciseAttempt[]) return success(c, rows satisfies LearnExerciseAttempt[])
}) },
)
File diff suppressed because it is too large Load Diff
+401 -121
View File
@@ -12,7 +12,18 @@ import {
type AdminProblemSetProblem, type AdminProblemSetProblem,
type AdminProblemSetProgress, type AdminProblemSetProgress,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware" import { requireTeacher, type AppEnv } from "../../auth/middleware"
@@ -35,9 +46,16 @@ function ownedBy(user: AuthUser, row: { createdById: number }) {
* // * //
* try/except 14 * try/except 14
*/ */
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) { async function loadOwned(
c: { req: { param(name: string): string } },
user: AuthUser,
) {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db.select().from(schema.problemset).where(eq(schema.problemset.id, id)).limit(1) const [row] = await db
.select()
.from(schema.problemset)
.where(eq(schema.problemset.id, id))
.limit(1)
return row && ownedBy(user, row) ? row : null return row && ownedBy(user, row) ? row : null
} }
@@ -50,18 +68,45 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
if (rows.length === 0) return [] if (rows.length === 0) return []
const ids = rows.map((row) => row.id) const ids = rows.map((row) => row.id)
const [problems, participants, creators] = await Promise.all([ const [problems, participants, creators] = await Promise.all([
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() }) db
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids)) .select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId), .groupBy(schema.problemsetProblem.problemsetId),
db.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() }) db
.from(schema.problemsetProgress).where(inArray(schema.problemsetProgress.problemsetId, ids)) .select({
problemsetId: schema.problemsetProgress.problemsetId,
value: count(),
})
.from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId), .groupBy(schema.problemsetProgress.problemsetId),
db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName }) db
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .select({
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])), id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
inArray(schema.user.id, [
...new Set(rows.map((row) => row.createdById)),
]),
),
]) ])
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value])) const problemsBySet = new Map(
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value])) problems.map((item) => [item.problemsetId, item.value]),
)
const participantsBySet = new Map(
participants.map((item) => [item.problemsetId, item.value]),
)
const creatorById = new Map(creators.map((item) => [item.id, item])) const creatorById = new Map(creators.map((item) => [item.id, item]))
return rows.map((row) => { return rows.map((row) => {
const creator = creatorById.get(row.createdById) const creator = creatorById.get(row.createdById)
@@ -73,7 +118,10 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
status: row.status, status: row.status,
endTime: row.endTime, endTime: row.endTime,
visible: row.visible, visible: row.visible,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName), createdBy: sampleUser(
creator ?? { id: row.createdById, username: "" },
creator?.realName,
),
createTime: row.createTime, createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime, lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.get(row.id) ?? 0, problemsCount: problemsBySet.get(row.id) ?? 0,
@@ -92,24 +140,33 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供 // 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失, // 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失,
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。 // 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
if (user.adminType !== "Super Admin") filters.push(eq(schema.problemset.createdById, user.id)) if (user.adminType !== "Super Admin")
filters.push(eq(schema.problemset.createdById, user.id))
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim() const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim() const status = c.req.query("status")?.trim()
if (keyword) { if (keyword) {
filters.push(or( filters.push(
or(
ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`),
)!) )!,
)
} }
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty))) if (difficulty)
filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status))) if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = filters.length ? and(...filters) : undefined const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where), db.select({ value: count() }).from(schema.problemset).where(where),
db.select().from(schema.problemset).where(where) db
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset), .select()
.from(schema.problemset)
.where(where)
.orderBy(desc(schema.problemset.createTime))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: await serializeMany(rows), results: await serializeMany(rows),
@@ -118,18 +175,30 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
}) })
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => { adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
const parsed = createProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
const now = new Date().toISOString() const now = new Date().toISOString()
const [created] = await db.insert(schema.problemset).values({ const [created] = await db
.insert(schema.problemset)
.values({
...parsed.data, ...parsed.data,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null, endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
}).returning() })
.returning()
return success(c, await serialize(created!), 201) return success(c, await serialize(created!), 201)
}) })
@@ -142,38 +211,69 @@ adminProblemSetRoutes.get("/problem-sets/:id", requireTeacher, async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => { adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
const [updated] = await db.update(schema.problemset).set({ const [updated] = await db
.update(schema.problemset)
.set({
...parsed.data, ...parsed.data,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null, endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
lastUpdateTime: new Date().toISOString(), lastUpdateTime: new Date().toISOString(),
}).where(eq(schema.problemset.id, row.id)).returning() })
.where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!)) return success(c, await serialize(updated!))
}) })
adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async (c) => { adminProblemSetRoutes.put(
"/problem-sets/:id/visibility",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关 // 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
const [updated] = await db.update(schema.problemset) const [updated] = await db
.update(schema.problemset)
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() }) .set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.problemset.id, row.id)).returning() .where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!)) return success(c, await serialize(updated!))
}) },
)
adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => { adminProblemSetRoutes.put(
"/problem-sets/:id/status",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetStatusRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateProblemSetStatusRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "status 不合法") await c.req.json().catch(() => null),
const [updated] = await db.update(schema.problemset) )
.set({ status: parsed.data.status, lastUpdateTime: new Date().toISOString() }) if (!parsed.success)
.where(eq(schema.problemset.id, row.id)).returning() return failure(c, 400, "invalid-request", "status 不合法")
const [updated] = await db
.update(schema.problemset)
.set({
status: parsed.data.status,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!)) return success(c, await serialize(updated!))
}) },
)
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => { adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
@@ -186,15 +286,29 @@ adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
// ---------------------------------------------------------------- 题单里的题目 // ---------------------------------------------------------------- 题单里的题目
adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c) => { adminProblemSetRoutes.get(
"/problem-sets/:id/problems",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db.select({ item: schema.problemsetProblem, problem: schema.problem }) const rows = await db
.select({ item: schema.problemsetProblem, problem: schema.problem })
.from(schema.problemsetProblem) .from(schema.problemsetProblem)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
.where(eq(schema.problemsetProblem.problemsetId, row.id)) .where(eq(schema.problemsetProblem.problemsetId, row.id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) .orderBy(
return success(c, rows.map(({ item, problem }) => ({ asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
)
return success(
c,
rows.map(
({ item, problem }) =>
({
id: item.id, id: item.id,
problemsetId: item.problemsetId, problemsetId: item.problemsetId,
problemId: item.problemId, problemId: item.problemId,
@@ -205,76 +319,142 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
isRequired: item.isRequired, isRequired: item.isRequired,
score: item.score, score: item.score,
hint: item.hint, hint: item.hint,
} satisfies AdminProblemSetProblem))) }) satisfies AdminProblemSetProblem,
}) ),
)
},
)
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => { adminProblemSetRoutes.post(
"/problem-sets/:id/problems",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = addProblemToSetRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = addProblemToSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem).where(and( const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`, sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
)).limit(1) ),
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见") )
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "题目不存在或不可见")
const [duplicate] = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem) const [duplicate] = await db
.where(and( .select({ id: schema.problemsetProblem.id })
.from(schema.problemsetProblem)
.where(
and(
eq(schema.problemsetProblem.problemsetId, row.id), eq(schema.problemsetProblem.problemsetId, row.id),
eq(schema.problemsetProblem.problemId, problem.id), eq(schema.problemsetProblem.problemId, problem.id),
)).limit(1) ),
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中") )
.limit(1)
if (duplicate)
return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
const [created] = await db.insert(schema.problemsetProblem).values({ const [created] = await db
.insert(schema.problemsetProblem)
.values({
problemsetId: row.id, problemsetId: row.id,
problemId: problem.id, problemId: problem.id,
order: parsed.data.order, order: parsed.data.order,
isRequired: parsed.data.isRequired, isRequired: parsed.data.isRequired,
score: parsed.data.score, score: parsed.data.score,
hint: parsed.data.hint, hint: parsed.data.hint,
}).returning({ id: schema.problemsetProblem.id }) })
.returning({ id: schema.problemsetProblem.id })
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变, // 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的, // 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。 // 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
await resyncProgress(row.id) await resyncProgress(row.id)
return success(c, { id: created!.id }, 201) return success(c, { id: created!.id }, 201)
}) },
)
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => { adminProblemSetRoutes.put(
"/problem-sets/:id/problems/:itemId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemInSetRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateProblemInSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误") if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
const updated = await db.update(schema.problemsetProblem).set(parsed.data).where(and( const updated = await db
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })), .update(schema.problemsetProblem)
.set(parsed.data)
.where(
and(
eq(
schema.problemsetProblem.id,
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
),
eq(schema.problemsetProblem.problemsetId, row.id), eq(schema.problemsetProblem.problemsetId, row.id),
)).returning({ id: schema.problemsetProblem.id }) ),
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中") )
.returning({ id: schema.problemsetProblem.id })
if (updated.length === 0)
return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
if (parsed.data.score !== undefined) await resyncProgress(row.id) if (parsed.data.score !== undefined) await resyncProgress(row.id)
return success(c, null) return success(c, null)
}) },
)
adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => { adminProblemSetRoutes.delete(
"/problem-sets/:id/problems/:itemId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const deleted = await db.delete(schema.problemsetProblem).where(and( const deleted = await db
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })), .delete(schema.problemsetProblem)
.where(
and(
eq(
schema.problemsetProblem.id,
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
),
eq(schema.problemsetProblem.problemsetId, row.id), eq(schema.problemsetProblem.problemsetId, row.id),
)).returning({ id: schema.problemsetProblem.id, problemId: schema.problemsetProblem.problemId }) ),
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中") )
.returning({
id: schema.problemsetProblem.id,
problemId: schema.problemsetProblem.problemId,
})
if (deleted.length === 0)
return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。 // 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。 // 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
await db.delete(schema.problemsetSubmission).where(and( await db
.delete(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, row.id), eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId), eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
)) ),
)
await resyncProgress(row.id) await resyncProgress(row.id)
return success(c, null) return success(c, null)
}) },
)
// ---------------------------------------------------------------- 奖章 // ---------------------------------------------------------------- 奖章
@@ -285,11 +465,20 @@ async function badgeWithCount(badge: BadgeRow) {
/** 批量版:一条 group by 数完整批奖章的获得人数 */ /** 批量版:一条 group by 数完整批奖章的获得人数 */
async function badgesWithCount(badges: BadgeRow[]) { async function badgesWithCount(badges: BadgeRow[]) {
if (badges.length === 0) return [] if (badges.length === 0) return []
const earned = await db.select({ badgeId: schema.userBadge.badgeId, value: count() }) const earned = await db
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id))) .select({ badgeId: schema.userBadge.badgeId, value: count() })
.from(schema.userBadge)
.where(
inArray(
schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
)
.groupBy(schema.userBadge.badgeId) .groupBy(schema.userBadge.badgeId)
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value])) const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
return badges.map((badge) => ({ return badges.map(
(badge) =>
({
id: badge.id, id: badge.id,
problemsetId: badge.problemsetId, problemsetId: badge.problemsetId,
name: badge.name, name: badge.name,
@@ -298,50 +487,95 @@ async function badgesWithCount(badges: BadgeRow[]) {
conditionType: badge.conditionType, conditionType: badge.conditionType,
conditionValue: badge.conditionValue, conditionValue: badge.conditionValue,
earnedCount: countByBadge.get(badge.id) ?? 0, earnedCount: countByBadge.get(badge.id) ?? 0,
} satisfies AdminProblemSetBadge)) }) satisfies AdminProblemSetBadge,
)
} }
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => { adminProblemSetRoutes.get(
"/problem-sets/:id/badges",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db.select().from(schema.problemsetBadge) const badges = await db
.where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id)) .select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
.orderBy(asc(schema.problemsetBadge.id))
return success(c, await badgesWithCount(badges)) return success(c, await badgesWithCount(badges))
}) },
)
adminProblemSetRoutes.post("/problem-sets/:id/badges", requireTeacher, async (c) => { adminProblemSetRoutes.post(
"/problem-sets/:id/badges",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = createProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
const [created] = await db.insert(schema.problemsetBadge).values({ const [created] = await db
.insert(schema.problemsetBadge)
.values({
...parsed.data, ...parsed.data,
problemsetId: row.id, problemsetId: row.id,
}).returning() })
.returning()
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调 // 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
await recalculateBadge(created!) await recalculateBadge(created!)
return success(c, await badgeWithCount(created!), 201) return success(c, await badgeWithCount(created!), 201)
}) },
)
adminProblemSetRoutes.put("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => { adminProblemSetRoutes.put(
"/problem-sets/:id/badges/:badgeId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
const [updated] = await db.update(schema.problemsetBadge).set(parsed.data).where(and( const [updated] = await db
eq(schema.problemsetBadge.id, queryInteger(c.req.param("badgeId"), 0, { min: 1 })), .update(schema.problemsetBadge)
.set(parsed.data)
.where(
and(
eq(
schema.problemsetBadge.id,
queryInteger(c.req.param("badgeId"), 0, { min: 1 }),
),
eq(schema.problemsetBadge.problemsetId, row.id), eq(schema.problemsetBadge.problemsetId, row.id),
)).returning() ),
)
.returning()
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在") if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
await recalculateBadge(updated) await recalculateBadge(updated)
return success(c, await badgeWithCount(updated)) return success(c, await badgeWithCount(updated))
}) },
)
adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => { adminProblemSetRoutes.delete(
"/problem-sets/:id/badges/:badgeId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 }) const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
@@ -349,32 +583,54 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单, // 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录, // 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。 // 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
const [badge] = await db.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge) const [badge] = await db
.where(and( .select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
.where(
and(
eq(schema.problemsetBadge.id, badgeId), eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id), eq(schema.problemsetBadge.problemsetId, row.id),
)).limit(1) ),
)
.limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在") if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
// 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE0010 // 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE0010
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id)) await db
.delete(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.id, badge.id))
return success(c, null) return success(c, null)
}) },
)
// ---------------------------------------------------------------- 学生进度 // ---------------------------------------------------------------- 学生进度
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => { adminProblemSetRoutes.get(
"/problem-sets/:id/progress",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db.select({ const rows = await db
.select({
progress: schema.problemsetProgress, progress: schema.problemsetProgress,
username: schema.user.username, username: schema.user.username,
realName: schema.userProfile.realName, realName: schema.userProfile.realName,
}).from(schema.problemsetProgress) })
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)) .from(schema.problemsetProgress)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(eq(schema.problemsetProgress.problemsetId, row.id)) .where(eq(schema.problemsetProgress.problemsetId, row.id))
.orderBy(desc(schema.problemsetProgress.joinTime)) .orderBy(desc(schema.problemsetProgress.joinTime))
return success(c, rows.map(({ progress, username, realName }) => return success(
c,
rows.map(
({ progress, username, realName }) =>
({ ({
id: progress.id, id: progress.id,
userId: progress.userId, userId: progress.userId,
@@ -388,32 +644,56 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c
completedProblemsCount: progress.completedProblemsCount, completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount, totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore, totalScore: progress.totalScore,
} satisfies AdminProblemSetProgress))) }) satisfies AdminProblemSetProgress,
}) ),
)
},
)
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => { adminProblemSetRoutes.delete(
"/problem-sets/:id/progress/:userId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!) const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 }) const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => { const deleted = await db.transaction(async (tx) => {
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空 // 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
const badges = await tx.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge) const badges = await tx
.select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id)) .where(eq(schema.problemsetBadge.problemsetId, row.id))
if (badges.length) { if (badges.length) {
await tx.delete(schema.userBadge).where(and( await tx.delete(schema.userBadge).where(
and(
eq(schema.userBadge.userId, userId), eq(schema.userBadge.userId, userId),
inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)), inArray(
)) schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
),
)
} }
await tx.delete(schema.problemsetSubmission).where(and( await tx
.delete(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, row.id), eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.userId, userId), eq(schema.problemsetSubmission.userId, userId),
)) ),
return tx.delete(schema.problemsetProgress).where(and( )
return tx
.delete(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, row.id), eq(schema.problemsetProgress.problemsetId, row.id),
eq(schema.problemsetProgress.userId, userId), eq(schema.problemsetProgress.userId, userId),
)).returning({ id: schema.problemsetProgress.id }) ),
)
.returning({ id: schema.problemsetProgress.id })
}) })
if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单") if (deleted.length === 0)
return failure(c, 404, "progress-not-found", "用户未加入该题单")
return success(c, null) return success(c, null)
}) },
)
+256 -74
View File
@@ -9,10 +9,28 @@ import {
type RenameTagResponse, type RenameTagResponse,
type StuckProblem, type StuckProblem,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm" import {
and,
asc,
countDistinct,
count,
desc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
sql,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware" import {
requireProblemPermission,
requireTeacher,
type AppEnv,
} from "../../auth/middleware"
import type { AuthUser } from "../../auth/session" import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db" import { db, schema } from "../../db"
import { failure, success } from "../../http" import { failure, success } from "../../http"
@@ -25,7 +43,11 @@ import { findTagsByName, normalizeTagNames } from "./problem"
export const adminTagRoutes = new Hono<AppEnv>() export const adminTagRoutes = new Hono<AppEnv>()
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR] const FAILED = [
JudgeStatus.WRONG_ANSWER,
JudgeStatus.COMPILE_ERROR,
JudgeStatus.RUNTIME_ERROR,
]
/** 能管所有题目:超管,或 problemPermission 为 All */ /** 能管所有题目:超管,或 problemPermission 为 All */
function canManageAllProblems(user: AuthUser) { function canManageAllProblems(user: AuthUser) {
@@ -36,51 +58,91 @@ function canManageAllProblems(user: AuthUser) {
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => { adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
const rows = await db.select({ const rows = await db
.select({
id: schema.problemTag.id, id: schema.problemTag.id,
name: schema.problemTag.name, name: schema.problemTag.name,
problemCount: countDistinct(schema.problemTags.problemId), problemCount: countDistinct(schema.problemTags.problemId),
}).from(schema.problemTag) })
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id)) .from(schema.problemTag)
.leftJoin(
schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name) .groupBy(schema.problemTag.id, schema.problemTag.name)
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些), // 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0 // 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name)) .orderBy(
desc(countDistinct(schema.problemTags.problemId)),
asc(schema.problemTag.name),
)
return success(c, rows satisfies AdminTag[]) return success(c, rows satisfies AdminTag[])
}) })
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => { adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = renameTagRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = renameTagRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "标签名不能为空") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "标签名不能为空")
const name = parsed.data.name const name = parsed.data.name
const [tag] = await db.select().from(schema.problemTag).where(eq(schema.problemTag.id, id)).limit(1) const [tag] = await db
.select()
.from(schema.problemTag)
.where(eq(schema.problemTag.id, id))
.limit(1)
if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试") if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
const [target] = await db.select().from(schema.problemTag) const [target] = await db
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1) .select()
.from(schema.problemTag)
.where(
and(
sql`lower(${schema.problemTag.name}) = lower(${name})`,
ne(schema.problemTag.id, id),
),
)
.limit(1)
if (!target) { if (!target) {
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id)) await db
return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse) .update(schema.problemTag)
.set({ name })
.where(eq(schema.problemTag.id, id))
return success(c, {
merged: false,
id,
name,
affectedCount: 0,
} satisfies RenameTagResponse)
} }
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除 // 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
const affected = await db.transaction(async (tx) => { const affected = await db.transaction(async (tx) => {
const links = await tx.select({ problemId: schema.problemTags.problemId }) const links = await tx
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id)) .select({ problemId: schema.problemTags.problemId })
const already = new Set((await tx.select({ problemId: schema.problemTags.problemId }) .from(schema.problemTags)
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, target.id))) .where(eq(schema.problemTags.problemtagId, id))
.map((row) => row.problemId)) const already = new Set(
(
await tx
.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemtagId, target.id))
).map((row) => row.problemId),
)
// 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束 // 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束
const missing = links.filter((link) => !already.has(link.problemId)) const missing = links.filter((link) => !already.has(link.problemId))
if (missing.length) { if (missing.length) {
await tx.insert(schema.problemTags).values(missing.map((link) => ({ await tx.insert(schema.problemTags).values(
missing.map((link) => ({
problemId: link.problemId, problemId: link.problemId,
problemtagId: target.id, problemtagId: target.id,
}))) })),
)
} }
// 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE0010)。 // 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE0010)。
// 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。 // 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。
@@ -88,29 +150,57 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
return links.length return links.length
}) })
return success(c, { return success(c, {
merged: true, id: target.id, name: target.name, affectedCount: affected, merged: true,
id: target.id,
name: target.name,
affectedCount: affected,
} satisfies RenameTagResponse) } satisfies RenameTagResponse)
}) })
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => { adminTagRoutes.delete(
"/problem-tags/:id",
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE0010 // 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE0010
const deleted = await db.delete(schema.problemTag).where(eq(schema.problemTag.id, id)) const deleted = await db
.delete(schema.problemTag)
.where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id }) .returning({ id: schema.problemTag.id })
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试") if (deleted.length === 0)
return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null) return success(c, null)
}) },
)
adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) => { adminTagRoutes.post(
const parsed = batchProblemTagRequestSchema.safeParse(await c.req.json().catch(() => null)) "/problems/batch-tag",
requireProblemPermission,
async (c) => {
const parsed = batchProblemTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
} }
const user = c.get("user")! const user = c.get("user")!
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)] const filters = [
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id)) inArray(schema.problem.id, parsed.data.problemIds),
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(...filters)) isNull(schema.problem.contestId),
if (problems.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目") ]
if (!canManageAllProblems(user))
filters.push(eq(schema.problem.createdById, user.id))
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(and(...filters))
if (problems.length === 0)
return failure(c, 404, "no-problems", "没有可操作的题目")
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致 // 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
const wanted = normalizeTagNames(parsed.data.tagNames) const wanted = normalizeTagNames(parsed.data.tagNames)
@@ -119,35 +209,57 @@ adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) =
const existing = await findTagsByName(tx as unknown as typeof db, wanted) const existing = await findTagsByName(tx as unknown as typeof db, wanted)
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签 // 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
if (parsed.data.action === "add") { if (parsed.data.action === "add") {
const missing = wanted.filter((name) => !existing.has(name.toLowerCase())) const missing = wanted.filter(
(name) => !existing.has(name.toLowerCase()),
)
if (missing.length) { if (missing.length) {
const created = await tx.insert(schema.problemTag).values(missing.map((name) => ({ name }))) const created = await tx
.returning({ id: schema.problemTag.id, name: schema.problemTag.name }) .insert(schema.problemTag)
for (const row of created) existing.set(row.name.toLowerCase(), row.id) .values(missing.map((name) => ({ name })))
.returning({
id: schema.problemTag.id,
name: schema.problemTag.name,
})
for (const row of created)
existing.set(row.name.toLowerCase(), row.id)
} }
} }
return wanted.map((name) => existing.get(name.toLowerCase())).filter((id) => id !== undefined) return wanted
.map((name) => existing.get(name.toLowerCase()))
.filter((id) => id !== undefined)
}) })
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签") if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
const problemIds = problems.map((problem) => problem.id) const problemIds = problems.map((problem) => problem.id)
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
if (parsed.data.action === "remove") { if (parsed.data.action === "remove") {
await tx.delete(schema.problemTags).where(and( await tx
.delete(schema.problemTags)
.where(
and(
inArray(schema.problemTags.problemId, problemIds), inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds), inArray(schema.problemTags.problemtagId, tagIds),
)) ),
)
return return
} }
const existing = await tx.select().from(schema.problemTags).where(and( const existing = await tx
.select()
.from(schema.problemTags)
.where(
and(
inArray(schema.problemTags.problemId, problemIds), inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds), inArray(schema.problemTags.problemtagId, tagIds),
)) ),
const have = new Set(existing.map((row) => `${row.problemId}:${row.problemtagId}`)) )
const have = new Set(
existing.map((row) => `${row.problemId}:${row.problemtagId}`),
)
const rows = [] const rows = []
for (const problemId of problemIds) { for (const problemId of problemIds) {
for (const tagId of tagIds) { for (const tagId of tagIds) {
if (!have.has(`${problemId}:${tagId}`)) rows.push({ problemId, problemtagId: tagId }) if (!have.has(`${problemId}:${tagId}`))
rows.push({ problemId, problemtagId: tagId })
} }
} }
if (rows.length) await tx.insert(schema.problemTags).values(rows) if (rows.length) await tx.insert(schema.problemTags).values(rows)
@@ -157,14 +269,25 @@ adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) =
problemCount: problems.length, problemCount: problems.length,
tagCount: tagIds.length, tagCount: tagIds.length,
} satisfies BatchProblemTagResponse) } satisfies BatchProblemTagResponse)
}) },
)
// ---------------------------------------------------------------- 题目可见性 // ---------------------------------------------------------------- 题目可见性
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => { adminTagRoutes.put(
"/problems/:id/visibility",
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problem] = await db.select({ id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById }) const [problem] = await db
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1) .select({
id: schema.problem.id,
visible: schema.problem.visible,
createdById: schema.problem.createdById,
})
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛 // 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
// AttributeError500)。这里正常返回 404。 // AttributeError500)。这里正常返回 404。
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在") if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
@@ -172,9 +295,13 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
if (!canManageAllProblems(user) && problem.createdById !== user.id) { if (!canManageAllProblems(user) && problem.createdById !== user.id) {
return failure(c, 404, "problem-not-found", "题目不存在") return failure(c, 404, "problem-not-found", "题目不存在")
} }
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id)) await db
.update(schema.problem)
.set({ visible: !problem.visible })
.where(eq(schema.problem.id, id))
return success(c, { visible: !problem.visible }) return success(c, { visible: !problem.visible })
}) },
)
// ---------------------------------------------------------------- 卡点题目 / AC 趋势 // ---------------------------------------------------------------- 卡点题目 / AC 趋势
@@ -184,15 +311,26 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。 // requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => { adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})` const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
const rows = await db.select({ const rows = await db
.select({
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
title: schema.problem.title, title: schema.problem.title,
total: count(), total: count(),
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number), accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number), failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number), failedUsers:
}).from(schema.submission) sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) Number,
),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
/** /**
* ac-trend where * ac-trend where
* 1 61 * 1 61
@@ -207,17 +345,27 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
*/ */
.where(isNull(schema.submission.contestId)) .where(isNull(schema.submission.contestId))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title) .groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`) .having(
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`)) sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`,
)
.orderBy(
desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`),
)
.limit(40) .limit(40)
return success(c, rows.map((row) => ({ return success(
c,
rows.map(
(row) =>
({
problemId: row.displayId, problemId: row.displayId,
problemTitle: row.title, problemTitle: row.title,
total: row.total, total: row.total,
failed: row.failed, failed: row.failed,
failedUsers: row.failedUsers, failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
} satisfies StuckProblem))) }) satisfies StuckProblem,
),
)
}) })
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
@@ -226,37 +374,64 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023) let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023 if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
let untilYear = queryInteger(c.req.query("untilYear"), currentYear) let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1 if (untilYear < sinceYear || untilYear > currentYear)
untilYear = currentYear - 1
let minPerYear = queryInteger(c.req.query("minPerYear"), 100) let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100 if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径 // 年份按东八区切,和上面 `currentYear` 的夹逼同口径
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(Number) const year =
const rows = await db.select({ sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
Number,
)
const rows = await db
.select({
problemId: schema.problem.id, problemId: schema.problem.id,
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
title: schema.problem.title, title: schema.problem.title,
year, year,
total: count(), total: count(),
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number), accepted:
}).from(schema.submission) sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) Number,
.where(and( ),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(
and(
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
gte(year, sinceYear), gte(year, sinceYear),
lte(year, untilYear), lte(year, untilYear),
)) ),
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year) )
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
year,
)
.orderBy(asc(schema.problem.id), asc(year)) .orderBy(asc(schema.problem.id), asc(year))
const required = new Set<number>() const required = new Set<number>()
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y) for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
const grouped = new Map<number, { displayId: string; title: string; yearly: typeof rows }>() const grouped = new Map<
number,
{ displayId: string; title: string; yearly: typeof rows }
>()
for (const row of rows) { for (const row of rows) {
const bucket = grouped.get(row.problemId) const bucket = grouped.get(row.problemId)
if (bucket) bucket.yearly.push(row) if (bucket) bucket.yearly.push(row)
else grouped.set(row.problemId, { displayId: row.displayId, title: row.title, yearly: [row] }) else
grouped.set(row.problemId, {
displayId: row.displayId,
title: row.title,
yearly: [row],
})
} }
const result = [] const result = []
@@ -283,9 +458,15 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
// ---------------------------------------------------------------- Python → 流程图 // ---------------------------------------------------------------- Python → 流程图
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => { adminTagRoutes.post(
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null)) "/problems/flowchart",
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空") requireProblemPermission,
async (c) => {
const parsed = generateFlowchartRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "python 代码不能为空")
try { try {
const flowchart = await completeChat( const flowchart = await completeChat(
`你是一个可以将Python代码转换为mermaid的助手。 `你是一个可以将Python代码转换为mermaid的助手。
@@ -299,4 +480,5 @@ adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) =
console.error("Flowchart generation failed", error) console.error("Flowchart generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
} }
}) },
)
+130 -42
View File
@@ -40,7 +40,11 @@ function serializeTutorial(row: {
function selectTutorial(id: number) { function selectTutorial(id: number) {
return db return db
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName }) .select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.tutorial) .from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -50,7 +54,11 @@ function selectTutorial(id: number) {
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => { adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
const rows = await db const rows = await db
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName }) .select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.tutorial) .from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -64,62 +72,98 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
}) })
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => { adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
const parsed = createTutorialRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const now = new Date().toISOString() const now = new Date().toISOString()
const [created] = await db.insert(schema.tutorial).values({ const [created] = await db
.insert(schema.tutorial)
.values({
...parsed.data, ...parsed.data,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
}).returning({ id: schema.tutorial.id }) })
.returning({ id: schema.tutorial.id })
const [row] = await selectTutorial(created!.id) const [row] = await selectTutorial(created!.id)
return success(c, serializeTutorial(row!), 201) return success(c, serializeTutorial(row!), 201)
}) })
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => { adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 })) const [row] = await selectTutorial(
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, serializeTutorial(row)) return success(c, serializeTutorial(row))
}) })
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => { adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateTutorialRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const updated = await db.update(schema.tutorial) const updated = await db
.update(schema.tutorial)
.set({ ...parsed.data, updatedAt: new Date().toISOString() }) .set({ ...parsed.data, updatedAt: new Date().toISOString() })
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id }) .where(eq(schema.tutorial.id, id))
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") .returning({ id: schema.tutorial.id })
if (updated.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id) const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!)) return success(c, serializeTutorial(row!))
}) })
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => { adminTutorialRoutes.put(
"/tutorials/:id/visibility",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = setTutorialVisibilityRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isPublic is required")
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉 // 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
const updated = await db.update(schema.tutorial) const updated = await db
.update(schema.tutorial)
.set({ isPublic: parsed.data.isPublic }) .set({ isPublic: parsed.data.isPublic })
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id }) .where(eq(schema.tutorial.id, id))
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") .returning({ id: schema.tutorial.id })
if (updated.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id) const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!)) return success(c, serializeTutorial(row!))
}) },
)
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => { adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id // 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**, // 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。 // 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
const deleted = await db.delete(schema.tutorial).where(eq(schema.tutorial.id, id)) const deleted = await db
.delete(schema.tutorial)
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id }) .returning({ id: schema.tutorial.id })
if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") if (deleted.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, null) return success(c, null)
}) })
@@ -136,52 +180,96 @@ function serializeExercise(row: typeof schema.exercise.$inferSelect) {
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数, // 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误 // 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => { adminTutorialRoutes.get(
const rows = await db.select().from(schema.exercise) "/tutorials/:id/exercises",
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 }))) requireSuperAdmin,
async (c) => {
const rows = await db
.select()
.from(schema.exercise)
.where(
eq(
schema.exercise.tutorialId,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id)) .orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
return success(c, rows.map(serializeExercise)) return success(c, rows.map(serializeExercise))
}) },
)
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => { adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
const parsed = createExerciseRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial) const [tutorial] = await db
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1) .select({ id: schema.tutorial.id })
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") .from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId))
.limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const dataError = exerciseDataError(parsed.data.type, parsed.data.data) const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError) if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [created] = await db.insert(schema.exercise).values({ const [created] = await db
.insert(schema.exercise)
.values({
tutorialId: parsed.data.tutorialId, tutorialId: parsed.data.tutorialId,
type: parsed.data.type, type: parsed.data.type,
data: parsed.data.data, data: parsed.data.data,
order: parsed.data.order, order: parsed.data.order,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}).returning() })
.returning()
return success(c, serializeExercise(created!), 201) return success(c, serializeExercise(created!), 201)
}) })
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => { adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
const parsed = updateExerciseRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = updateExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
} }
const dataError = exerciseDataError(parsed.data.type, parsed.data.data) const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError) if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [updated] = await db.update(schema.exercise) const [updated] = await db
.set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order }) .update(schema.exercise)
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .set({
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
})
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.returning() .returning()
if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist") if (!updated)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, serializeExercise(updated)) return success(c, serializeExercise(updated))
}) })
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => { adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
const deleted = await db.delete(schema.exercise) const deleted = await db
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .delete(schema.exercise)
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.returning({ id: schema.exercise.id }) .returning({ id: schema.exercise.id })
if (deleted.length === 0) return failure(c, 404, "exercise-not-found", "Exercise does not exist") if (deleted.length === 0)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, null) return success(c, null)
}) })
+674 -158
View File
File diff suppressed because it is too large Load Diff
+124 -39
View File
@@ -38,25 +38,36 @@ async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
] ]
if (classNames) filters.push(inArray(schema.user.className, classNames)) if (classNames) filters.push(inArray(schema.user.className, classNames))
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`)) if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
const rows = await db.select({ const rows = await db
.select({
userId: schema.user.id, userId: schema.user.id,
username: schema.user.username, username: schema.user.username,
className: schema.user.className, className: schema.user.className,
acceptedNumber: schema.userProfile.acceptedNumber, acceptedNumber: schema.userProfile.acceptedNumber,
submissionNumber: schema.userProfile.submissionNumber, submissionNumber: schema.userProfile.submissionNumber,
}).from(schema.user).innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(and(...filters)) })
.from(schema.user)
.innerJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(and(...filters))
return rows.filter((row): row is ClassUser => row.className !== null) return rows.filter((row): row is ClassUser => row.className !== null)
} }
function mean(values: number[]) { function mean(values: number[]) {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 return values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0
} }
function median(values: number[]) { function median(values: number[]) {
if (!values.length) return 0 if (!values.length) return 0
const sorted = [...values].sort((a, b) => a - b) const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2) const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2 return sorted.length % 2
? sorted[middle]!
: (sorted[middle - 1]! + sorted[middle]!) / 2
} }
function quantile(values: number[], p: number) { function quantile(values: number[], p: number) {
@@ -73,35 +84,59 @@ function quantile(values: number[], p: number) {
function sampleStdDev(values: number[]) { function sampleStdDev(values: number[]) {
if (values.length <= 1) return 0 if (values.length <= 1) return 0
const average = mean(values) const average = mean(values)
return Math.sqrt(values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1)) return Math.sqrt(
values.reduce((sum, value) => sum + (value - average) ** 2, 0) /
(values.length - 1),
)
} }
classroomRoutes.get("/rankings/classes", async (c) => { classroomRoutes.get("/rankings/classes", async (c) => {
const grade = c.req.query("grade")?.trim() const grade = c.req.query("grade")?.trim()
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required") if (!grade || !/^\d+$/.test(grade))
return failure(c, 400, "invalid-grade", "grade is required")
const users = await loadClassUsers(undefined, grade) const users = await loadClassUsers(undefined, grade)
const groups = new Map<string, ClassUser[]>() const groups = new Map<string, ClassUser[]>()
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user]) for (const user of users)
const result = [...groups].map(([className, members]) => { groups.set(user.className, [...(groups.get(user.className) ?? []), user])
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0) const result = [...groups]
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0) .map(([className, members]) => {
const totalAc = members.reduce(
(sum, member) => sum + member.acceptedNumber,
0,
)
const totalSubmission = members.reduce(
(sum, member) => sum + member.submissionNumber,
0,
)
return { return {
className, className,
userCount: members.length, userCount: members.length,
totalAc, totalAc,
totalSubmission, totalSubmission,
avgAc: rounded(totalAc / members.length), avgAc: rounded(totalAc / members.length),
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0, acRate:
totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
} }
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission) })
return success(c, result.map((item, index) => ({ ...item, rank: index + 1 } satisfies ClassRankItem))) .sort(
(a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission,
)
return success(
c,
result.map(
(item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem,
),
)
}) })
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => { classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息") if (!user.className)
return failure(c, 400, "class-missing", "用户没有班级信息")
const members = (await loadClassUsers([user.className])).sort( const members = (await loadClassUsers([user.className])).sort(
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber, (a, b) =>
b.acceptedNumber - a.acceptedNumber ||
a.submissionNumber - b.submissionNumber,
) )
const ranks = members.map((member, index) => ({ const ranks = members.map((member, index) => ({
userId: member.userId, userId: member.userId,
@@ -121,35 +156,64 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10) const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
selected = ranks.slice(start, start + 10) selected = ranks.slice(start, start + 10)
} }
return success(c, { className: user.className, myRank, total: ranks.length, ranks: selected } satisfies ClassUserRank) return success(c, {
className: user.className,
myRank,
total: ranks.length,
ranks: selected,
} satisfies ClassUserRank)
}) })
classroomRoutes.post("/classes/comparison", async (c) => { classroomRoutes.post("/classes/comparison", async (c) => {
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = classComparisonRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "At least one class is required")
const users = await loadClassUsers(parsed.data.classNames) const users = await loadClassUsers(parsed.data.classNames)
const allAc = users.map((user) => user.acceptedNumber) const allAc = users.map((user) => user.acceptedNumber)
const globalQ1 = quantile(allAc, 0.25) const globalQ1 = quantile(allAc, 0.25)
const globalQ3 = quantile(allAc, 0.75) const globalQ3 = quantile(allAc, 0.75)
const byClass = new Map<string, ClassUser[]>() const byClass = new Map<string, ClassUser[]>()
for (const user of users) byClass.set(user.className, [...(byClass.get(user.className) ?? []), user]) for (const user of users)
byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
let recentByUser = new Map<number, Set<number>>() let recentByUser = new Map<number, Set<number>>()
let recentSubmissionCount = new Map<string, number>() let recentSubmissionCount = new Map<string, number>()
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime) const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
if (hasTimeRange) { if (hasTimeRange) {
const rows = await db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, result: schema.submission.result }) const rows = await db
.from(schema.submission).where(and( .select({
inArray(schema.submission.userId, users.map((user) => user.userId)), userId: schema.submission.userId,
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
inArray(
schema.submission.userId,
users.map((user) => user.userId),
),
gte(schema.submission.createTime, parsed.data.startTime!), gte(schema.submission.createTime, parsed.data.startTime!),
lte(schema.submission.createTime, parsed.data.endTime!), lte(schema.submission.createTime, parsed.data.endTime!),
)) ),
const userClass = new Map(users.map((user) => [user.userId, user.className])) )
const userClass = new Map(
users.map((user) => [user.userId, user.className]),
)
for (const row of rows) { for (const row of rows) {
const className = userClass.get(row.userId) const className = userClass.get(row.userId)
if (!className) continue if (!className) continue
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1) recentSubmissionCount.set(
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) { className,
(recentSubmissionCount.get(className) ?? 0) + 1,
)
if (
[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(
row.result as 0 | 10,
)
) {
const set = recentByUser.get(row.userId) ?? new Set<number>() const set = recentByUser.get(row.userId) ?? new Set<number>()
set.add(row.problemId) set.add(row.problemId)
recentByUser.set(row.userId, set) recentByUser.set(row.userId, set)
@@ -158,12 +222,17 @@ classroomRoutes.post("/classes/comparison", async (c) => {
} }
const comparisons = [...byClass].map(([className, members]) => { const comparisons = [...byClass].map(([className, members]) => {
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a) const ac = members
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a) .map((member) => member.acceptedNumber)
.sort((a, b) => b - a)
const submissions = members
.map((member) => member.submissionNumber)
.sort((a, b) => b - a)
const userCount = members.length const userCount = members.length
const topCount = Math.max(1, Math.ceil(userCount * 0.1)) const topCount = Math.max(1, Math.ceil(userCount * 0.1))
const bottomCount = topCount const bottomCount = topCount
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac const middle =
topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
const totalAc = ac.reduce((sum, value) => sum + value, 0) const totalAc = ac.reduce((sum, value) => sum + value, 0)
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0) const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
const base: ClassComparison = { const base: ClassComparison = {
@@ -180,19 +249,30 @@ classroomRoutes.post("/classes/comparison", async (c) => {
top10Avg: rounded(mean(ac.slice(0, topCount))), top10Avg: rounded(mean(ac.slice(0, topCount))),
middle80Avg: rounded(mean(middle)), middle80Avg: rounded(mean(middle)),
bottom10Avg: rounded(mean(ac.slice(-bottomCount))), bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
excellentRate: rounded(ac.filter((value) => value >= globalQ3).length / userCount * 100), excellentRate: rounded(
passRate: rounded(ac.filter((value) => value >= globalQ1).length / userCount * 100), (ac.filter((value) => value >= globalQ3).length / userCount) * 100,
activeRate: rounded(submissions.filter((value) => value > 0).length / userCount * 100), ),
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0, passRate: rounded(
(ac.filter((value) => value >= globalQ1).length / userCount) * 100,
),
activeRate: rounded(
(submissions.filter((value) => value > 0).length / userCount) * 100,
),
acRate:
totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
compositeScore: 0, compositeScore: 0,
} }
if (hasTimeRange) { if (hasTimeRange) {
const recent = members.map((member) => recentByUser.get(member.userId)?.size ?? 0).sort((a, b) => b - a) const recent = members
.map((member) => recentByUser.get(member.userId)?.size ?? 0)
.sort((a, b) => b - a)
base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0) base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0 base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
base.recentAvgAc = rounded(mean(recent)) base.recentAvgAc = rounded(mean(recent))
base.recentMedianAc = rounded(median(recent)) base.recentMedianAc = rounded(median(recent))
base.recentTop10Avg = rounded(mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1))))) base.recentTop10Avg = rounded(
mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))),
)
base.recentActiveCount = recent.filter((value) => value > 0).length base.recentActiveCount = recent.filter((value) => value > 0).length
} }
return base return base
@@ -201,14 +281,19 @@ classroomRoutes.post("/classes/comparison", async (c) => {
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg)) const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
for (const item of comparisons) { for (const item of comparisons) {
item.compositeScore = rounded( item.compositeScore = rounded(
0.4 * (item.medianAc / maxMedian * 100) + 0.4 * ((item.medianAc / maxMedian) * 100) +
0.15 * (item.middle80Avg / maxMiddle * 100) + 0.15 * ((item.middle80Avg / maxMiddle) * 100) +
0.2 * item.activeRate + 0.2 * item.activeRate +
0.15 * item.passRate + 0.15 * item.passRate +
0.1 * item.excellentRate, 0.1 * item.excellentRate,
1, 1,
) )
} }
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc) comparisons.sort(
return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse) (a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc,
)
return success(c, {
comparisons,
hasTimeRange,
} satisfies ClassComparisonResponse)
}) })
+302 -83
View File
@@ -33,15 +33,37 @@ contentRoutes.get("/announcements", async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.announcement).where(eq(schema.announcement.visible, true)), db
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName }) .select({ value: count() })
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id)) .from(schema.announcement)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(eq(schema.announcement.visible, true)),
db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement)
.innerJoin(
schema.user,
eq(schema.announcement.createdById, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(eq(schema.announcement.visible, true)) .where(eq(schema.announcement.visible, true))
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset), .orderBy(
desc(schema.announcement.top),
desc(schema.announcement.createTime),
)
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: rows.map(({ announcement, user, realName }) => ({ results: rows.map(
({ announcement, user, realName }) =>
({
id: announcement.id, id: announcement.id,
title: announcement.title, title: announcement.title,
tag: announcement.tag, tag: announcement.tag,
@@ -49,18 +71,37 @@ contentRoutes.get("/announcements", async (c) => {
createdBy: sampleUser(user, realName), createdBy: sampleUser(user, realName),
createTime: announcement.createTime, createTime: announcement.createTime,
lastUpdateTime: announcement.lastUpdateTime, lastUpdateTime: announcement.lastUpdateTime,
} satisfies AnnouncementListItem)), }) satisfies AnnouncementListItem,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies AnnouncementList) } satisfies AnnouncementList)
}) })
contentRoutes.get("/announcements/:id", async (c) => { contentRoutes.get("/announcements/:id", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName }) const [row] = await db
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id)) .select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1) .where(
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist") and(
eq(schema.announcement.id, id),
eq(schema.announcement.visible, true),
),
)
.limit(1)
if (!row)
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
return success(c, { return success(c, {
id: row.announcement.id, id: row.announcement.id,
title: row.announcement.title, title: row.announcement.title,
@@ -78,16 +119,41 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)), db
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId }) .select({ value: count() })
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id)) .from(schema.message)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(eq(schema.message.recipientId, user.id)),
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id)) db
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .select({
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset), message: schema.message,
sender: schema.user,
realName: schema.userProfile.realName,
submission: schema.submission,
displayId: schema.problem.displayId,
})
.from(schema.message)
.innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.innerJoin(
schema.submission,
eq(schema.message.submissionId, schema.submission.id),
)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(eq(schema.message.recipientId, user.id))
.orderBy(desc(schema.message.createTime))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: rows.map(({ message, sender, realName, submission, displayId }) => ({ results: rows.map(
({ message, sender, realName, submission, displayId }) =>
({
id: message.id, id: message.id,
sender: sampleUser(sender, realName), sender: sampleUser(sender, realName),
createTime: message.createTime, createTime: message.createTime,
@@ -107,7 +173,8 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
problem: displayId, problem: displayId,
showLink: true, showLink: true,
} satisfies EmbeddedSubmission, } satisfies EmbeddedSubmission,
} satisfies Message)), }) satisfies Message,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies MessageList) } satisfies MessageList)
}) })
@@ -120,15 +187,39 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
*/ */
contentRoutes.post("/messages", requireSuperAdmin, async (c) => { contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createMessageRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload") await c.req.json().catch(() => null),
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself") )
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid message payload")
if (parsed.data.recipientId === user.id)
return failure(
c,
400,
"invalid-recipient",
"Can not send a message to yourself",
)
const [[recipient], [submission]] = await Promise.all([ const [[recipient], [submission]] = await Promise.all([
db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false))).limit(1), db
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1), .select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.id, parsed.data.recipientId),
eq(schema.user.isDisabled, false),
),
)
.limit(1),
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.id, parsed.data.submissionId))
.limit(1),
]) ])
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist") if (!recipient)
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist") return failure(c, 404, "user-not-found", "User does not exist")
if (!submission)
return failure(c, 404, "submission-not-found", "Submission does not exist")
await db.insert(schema.message).values({ await db.insert(schema.message).values({
message: parsed.data.message, message: parsed.data.message,
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
@@ -140,11 +231,22 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
}) })
async function reactionState(problemId: number, userId: number) { async function reactionState(problemId: number, userId: number) {
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction) const [mine] = await db
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1) .select({ type: schema.reaction.type })
.from(schema.reaction)
.where(
and(
eq(schema.reaction.problemId, problemId),
eq(schema.reaction.userId, userId),
),
)
.limit(1)
if (!mine) return { mine: null, counts: null } satisfies ReactionState if (!mine) return { mine: null, counts: null } satisfies ReactionState
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction) const rows = await db
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type) .select({ type: schema.reaction.type, value: count() })
.from(schema.reaction)
.where(eq(schema.reaction.problemId, problemId))
.groupBy(schema.reaction.type)
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。 // fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
// row.type 不必再 safeParsereaction.type 列上挂着 $type<ReactionKey>() // row.type 不必再 safeParsereaction.type 列上挂着 $type<ReactionKey>()
const counts = Object.fromEntries( const counts = Object.fromEntries(
@@ -161,41 +263,85 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
contentRoutes.post("/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 problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = setReactionRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid reaction")
const user = c.get("user")! const user = c.get("user")!
const [[problem], [solved]] = await Promise.all([ const [[problem], [solved]] = await Promise.all([
db.select({ id: schema.problem.id }).from(schema.problem).where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true))).limit(1), db
db.select({ id: schema.submission.id }).from(schema.submission).where(and( .select({ id: schema.problem.id })
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId), .from(schema.problem)
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]), .where(
)).limit(1), and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true)),
)
.limit(1),
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, problemId),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
),
)
.limit(1),
]) ])
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!problem)
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required") return failure(c, 404, "problem-not-found", "Problem does not exist")
await db.insert(schema.reaction).values({ if (!solved)
return failure(
c,
403,
"accepted-submission-required",
"An accepted submission is required",
)
await db
.insert(schema.reaction)
.values({
problemId, problemId,
userId: user.id, userId: user.id,
type: parsed.data.type, type: parsed.data.type,
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] }) })
.onConflictDoNothing({
target: [schema.reaction.problemId, schema.reaction.userId],
})
return success(c, await reactionState(problemId, user.id)) return success(c, await reactionState(problemId, user.id))
}) })
contentRoutes.get("/tutorials", async (c) => { contentRoutes.get("/tutorials", async (c) => {
const type = c.req.query("type") === "c" ? "c" : "python" const type = c.req.query("type") === "c" ? "c" : "python"
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial) const rows = await db
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order)) .select({ id: schema.tutorial.id, title: schema.tutorial.title })
.from(schema.tutorial)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.orderBy(asc(schema.tutorial.order))
return success(c, rows satisfies TutorialSummary[]) return success(c, rows satisfies TutorialSummary[])
}) })
contentRoutes.get("/tutorials/:id", async (c) => { contentRoutes.get("/tutorials/:id", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName }) const [row] = await db
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id)) .select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1) .where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") .limit(1)
if (!row)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, { return success(c, {
id: row.tutorial.id, id: row.tutorial.id,
title: row.tutorial.title, title: row.tutorial.title,
@@ -222,40 +368,63 @@ contentRoutes.get("/tutorials/:id", async (c) => {
contentRoutes.get("/learn/progress", requireAuth, async (c) => { contentRoutes.get("/learn/progress", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const type = c.req.query("type") === "c" ? "c" : "python" const type = c.req.query("type") === "c" ? "c" : "python"
const visible = and(eq(schema.tutorial.type, type), eq(schema.tutorial.isPublic, true)) const visible = and(
eq(schema.tutorial.type, type),
eq(schema.tutorial.isPublic, true),
)
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零, // 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来 // 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
const [rows, exerciseRows] = await Promise.all([ const [rows, exerciseRows] = await Promise.all([
db.select({ db
.select({
tutorialId: schema.tutorial.id, tutorialId: schema.tutorial.id,
viewCount: schema.tutorialProgress.viewCount, viewCount: schema.tutorialProgress.viewCount,
totalSeconds: schema.tutorialProgress.totalSeconds, totalSeconds: schema.tutorialProgress.totalSeconds,
firstViewedAt: schema.tutorialProgress.firstViewedAt, firstViewedAt: schema.tutorialProgress.firstViewedAt,
lastViewedAt: schema.tutorialProgress.lastViewedAt, lastViewedAt: schema.tutorialProgress.lastViewedAt,
}).from(schema.tutorial) })
.leftJoin(schema.tutorialProgress, and( .from(schema.tutorial)
.leftJoin(
schema.tutorialProgress,
and(
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id), eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
eq(schema.tutorialProgress.userId, user.id), eq(schema.tutorialProgress.userId, user.id),
)) ),
)
.where(visible) .where(visible)
.orderBy(asc(schema.tutorial.order)), .orderBy(asc(schema.tutorial.order)),
db.select({ db
.select({
tutorialId: schema.exercise.tutorialId, tutorialId: schema.exercise.tutorialId,
total: count(), total: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number), solved:
}).from(schema.exercise) sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId)) Number,
.leftJoin(schema.exerciseAttempt, and( ),
})
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.leftJoin(
schema.exerciseAttempt,
and(
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id), eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
eq(schema.exerciseAttempt.userId, user.id), eq(schema.exerciseAttempt.userId, user.id),
)) ),
)
.where(visible) .where(visible)
.groupBy(schema.exercise.tutorialId), .groupBy(schema.exercise.tutorialId),
]) ])
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row])) const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
return success(c, rows.map((row) => ({ return success(
c,
rows.map(
(row) =>
({
tutorialId: row.tutorialId, tutorialId: row.tutorialId,
viewCount: row.viewCount ?? 0, viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0, totalSeconds: row.totalSeconds ?? 0,
@@ -263,7 +432,9 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
lastViewedAt: row.lastViewedAt, lastViewedAt: row.lastViewedAt,
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0, exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0, exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
} satisfies TutorialProgress))) }) satisfies TutorialProgress,
),
)
}) })
/** /**
@@ -276,23 +447,36 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => { contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null)) const parsed = tutorialProgressPingSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload") await c.req.json().catch(() => null),
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial) )
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1) if (!parsed.success)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") return failure(c, 400, "invalid-request", "Invalid progress payload")
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const now = new Date().toISOString() const now = new Date().toISOString()
const { seconds, opened } = parsed.data const { seconds, opened } = parsed.data
await db.insert(schema.tutorialProgress).values({ await db
.insert(schema.tutorialProgress)
.values({
userId: user.id, userId: user.id,
tutorialId: id, tutorialId: id,
viewCount: opened ? 1 : 0, viewCount: opened ? 1 : 0,
totalSeconds: seconds, totalSeconds: seconds,
firstViewedAt: now, firstViewedAt: now,
lastViewedAt: now, lastViewedAt: now,
}).onConflictDoUpdate({ })
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId], .onConflictDoUpdate({
target: [
schema.tutorialProgress.userId,
schema.tutorialProgress.tutorialId,
],
set: { set: {
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页 // 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半 // 同时上报时,读改写会互相覆盖,时长凭空少掉一半
@@ -317,18 +501,30 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => { contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = exerciseAttemptRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid attempt payload")
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报 // 练习跟着教程走:教程没公开,它底下的练习也不该能上报
const [exercise] = await db.select({ id: schema.exercise.id }).from(schema.exercise) const [exercise] = await db
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId)) .select({ id: schema.exercise.id })
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true))).limit(1) .from(schema.exercise)
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist") .innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!exercise)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
const now = new Date().toISOString() const now = new Date().toISOString()
const { correct } = parsed.data const { correct } = parsed.data
const answer = correct ? null : (parsed.data.answer ?? null) const answer = correct ? null : (parsed.data.answer ?? null)
await db.insert(schema.exerciseAttempt).values({ await db
.insert(schema.exerciseAttempt)
.values({
userId: user.id, userId: user.id,
exerciseId: id, exerciseId: id,
attempts: 1, attempts: 1,
@@ -339,8 +535,12 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
firstAttemptAt: now, firstAttemptAt: now,
lastAttemptAt: now, lastAttemptAt: now,
solvedAt: correct ? now : null, solvedAt: correct ? now : null,
}).onConflictDoUpdate({ })
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId], .onConflictDoUpdate({
target: [
schema.exerciseAttempt.userId,
schema.exerciseAttempt.exerciseId,
],
set: { set: {
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。 // 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
// //
@@ -368,9 +568,28 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
contentRoutes.get("/tutorials/:id/exercises", async (c) => { contentRoutes.get("/tutorials/:id/exercises", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial) const [tutorial] = await db
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1) .select({ id: schema.tutorial.id })
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") .from(schema.tutorial)
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order)) .where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise))) .limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const rows = await db
.select()
.from(schema.exercise)
.where(eq(schema.exercise.tutorialId, id))
.orderBy(asc(schema.exercise.order))
return success(
c,
rows.map(
(row) =>
({
id: row.id,
type: row.type,
data: objectValue(row.data),
order: row.order,
}) satisfies Exercise,
),
)
}) })
+213 -52
View File
@@ -9,7 +9,18 @@ import {
type ProblemDetail, type ProblemDetail,
type ProblemListItem, type ProblemListItem,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm" import {
and,
asc,
count,
desc,
eq,
gte,
ilike,
inArray,
lte,
sql,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, requireAuth } from "../auth/middleware" import { optionalAuth, requireAuth } from "../auth/middleware"
@@ -27,7 +38,12 @@ import {
requireContestAccess, requireContestAccess,
type ContestEnv, type ContestEnv,
} from "../services/contest" } from "../services/contest"
import { objectValue, publicTemplates, queryInteger, sampleUser } from "./helpers" import {
objectValue,
publicTemplates,
queryInteger,
sampleUser,
} from "./helpers"
export const contestRoutes = new Hono<ContestEnv>() export const contestRoutes = new Hono<ContestEnv>()
@@ -35,8 +51,14 @@ export const contestRoutes = new Hono<ContestEnv>()
async function creators(ids: number[]) { async function creators(ids: number[]) {
const map = new Map<number, ReturnType<typeof sampleUser>>() const map = new Map<number, ReturnType<typeof sampleUser>>()
if (ids.length === 0) return map if (ids.length === 0) return map
const rows = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName }) const rows = await db
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .select({
id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(inArray(schema.user.id, ids)) .where(inArray(schema.user.id, ids))
for (const row of rows) map.set(row.id, sampleUser(row, row.realName)) for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
return map return map
@@ -75,18 +97,33 @@ contestRoutes.get("/contests", async (c) => {
if (tag) filters.push(eq(schema.contest.tag, tag)) if (tag) filters.push(eq(schema.contest.tag, tag))
if (status === "1") filters.push(gte(schema.contest.startTime, now)) if (status === "1") filters.push(gte(schema.contest.startTime, now))
else if (status === "-1") filters.push(lte(schema.contest.endTime, now)) else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
else if (status === "0") filters.push(and(lte(schema.contest.startTime, now), gte(schema.contest.endTime, now))!) else if (status === "0")
filters.push(
and(
lte(schema.contest.startTime, now),
gte(schema.contest.endTime, now),
)!,
)
const where = and(...filters) const where = and(...filters)
const [totalRow, rows] = await Promise.all([ const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.contest).where(where), db.select({ value: count() }).from(schema.contest).where(where),
db.select().from(schema.contest).where(where).orderBy(desc(schema.contest.startTime)).limit(limit).offset(offset), db
.select()
.from(schema.contest)
.where(where)
.orderBy(desc(schema.contest.startTime))
.limit(limit)
.offset(offset),
]) ])
const byId = await creators([...new Set(rows.map((row) => row.createdById))]) const byId = await creators([...new Set(rows.map((row) => row.createdById))])
return success(c, { return success(c, {
results: rows.map((row) => serializeContest( results: rows.map((row) =>
serializeContest(
row, row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), byId.get(row.createdById) ??
)), sampleUser({ id: row.createdById, username: "" }, null),
),
),
total: totalRow[0]?.value ?? 0, total: totalRow[0]?.value ?? 0,
} satisfies ContestList) } satisfies ContestList)
}) })
@@ -94,31 +131,55 @@ contestRoutes.get("/contests", async (c) => {
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」—— // optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在 // 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
contestRoutes.get("/contests/:id", optionalAuth, async (c) => { contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 })) const contest = await findAccessibleContest(
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const byId = await creators([contest.createdById]) const byId = await creators([contest.createdById])
return success(c, serializeContest( return success(
c,
serializeContest(
contest, contest,
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null), byId.get(contest.createdById) ??
sampleUser({ id: contest.createdById, username: "" }, null),
true, true,
)) ),
)
}) })
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 })) const contest = await findAccessibleContest(
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist") c.get("user"),
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null)) queryInteger(c.req.param("id"), 0, { min: 1 }),
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required") )
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),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Password is required")
if (!checkContestPassword(parsed.data.password, contest.password)) { if (!checkContestPassword(parsed.data.password, contest.password)) {
return failure(c, 403, "wrong-password", "Wrong password or password expired") return failure(
c,
403,
"wrong-password",
"Wrong password or password expired",
)
} }
await setContestPassword(c, contest.id, parsed.data.password) await setContestPassword(c, contest.id, parsed.data.password)
return success(c, true) return success(c, true)
}) })
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => { contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 })) const contest = await findAccessibleContest(
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist") c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "details") const access = await canAccessContest(c, contest, "details")
return success(c, { access: access.ok } satisfies ContestAccess) return success(c, { access: access.ok } satisfies ContestAccess)
}) })
@@ -136,8 +197,11 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
*/ */
async function contestProblemStatuses(userId: number | undefined) { async function contestProblemStatuses(userId: number | undefined) {
if (!userId) return {} if (!userId) return {}
const [profile] = await db.select({ status: schema.userProfile.acmProblemsStatus }) const [profile] = await db
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1) .select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.limit(1)
return objectValue(objectValue(profile?.status).contest_problems) return objectValue(objectValue(profile?.status).contest_problems)
} }
@@ -148,24 +212,56 @@ function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
async function contestProblemTags(problemIds: number[]) { async function contestProblemTags(problemIds: number[]) {
if (problemIds.length === 0) return new Map<number, string[]>() if (problemIds.length === 0) return new Map<number, string[]>()
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }) const rows = await db
.from(schema.problemTags).innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) .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)) .where(inArray(schema.problemTags.problemId, problemIds))
const map = new Map<number, string[]>() const map = new Map<number, string[]>()
for (const row of rows) map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name]) for (const row of rows)
map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name])
return map return map
} }
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => { contestRoutes.get(
"/contests/:id/problems",
optionalAuth,
requireContestAccess("problems"),
async (c) => {
const contest = c.get("contest")! const contest = c.get("contest")!
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) const rows = await db
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .select({
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) problem: schema.problem,
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId)) user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.problem.contestId, contest.id),
eq(schema.problem.visible, true),
),
)
.orderBy(asc(schema.problem.displayId))
const tags = await contestProblemTags(rows.map((row) => row.problem.id)) const tags = await contestProblemTags(rows.map((row) => row.problem.id))
const allowed = contestDetailsAllowed(c.get("user"), contest) const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id) const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(c, rows.map(({ problem, user, realName }) => ({ return success(
c,
rows.map(
({ problem, user, realName }) =>
({
id: problem.id, id: problem.id,
_id: problem.displayId, _id: problem.displayId,
title: problem.title, title: problem.title,
@@ -179,16 +275,40 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null, hasAstRules: problem.astRules !== null,
myStatus: myStatusOf(statuses, problem.id), myStatus: myStatusOf(statuses, problem.id),
} satisfies ProblemListItem))) }) satisfies ProblemListItem,
}) ),
)
},
)
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => { contestRoutes.get(
"/contests/:id/problems/:displayId",
optionalAuth,
requireContestAccess("problems"),
async (c) => {
const contest = c.get("contest")! const contest = c.get("contest")!
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) const [row] = await db
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .select({
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) problem: schema.problem,
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true), sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`)).limit(1) user: schema.user,
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist") realName: schema.userProfile.realName,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.problem.contestId, contest.id),
eq(schema.problem.visible, true),
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
),
)
.limit(1)
if (!row)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const tags = await contestProblemTags([row.problem.id]) const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest) const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id) const statuses = await contestProblemStatuses(c.get("user")?.id)
@@ -222,33 +342,72 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
allowFlowchart: row.problem.allowFlowchart, allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart, showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData), flowchartData: row.problem.allowFlowchart
? null
: objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint, flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig, sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay, sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生 // 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules), astRequirements: astRequirements(row.problem.astRules),
} satisfies ProblemDetail) } satisfies ProblemDetail)
}) },
)
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => { contestRoutes.get(
"/contests/:id/rank",
optionalAuth,
requireContestAccess("ranks"),
async (c) => {
const contest = c.get("contest")! const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false)) const where = and(
eq(schema.acmContestRank.contestId, contest.id),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
)
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where), db
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName }) .select({ value: count() })
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)) .from(schema.acmContestRank)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where) .innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.where(where),
db
.select({
rank: schema.acmContestRank,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.acmContestRank)
.innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是 // 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人 // limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排 // 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset), .orderBy(
desc(schema.acmContestRank.acceptedNumber),
asc(schema.acmContestRank.totalTime),
asc(schema.acmContestRank.id),
)
.limit(limit)
.offset(offset),
]) ])
const admin = isContestAdmin(c.get("user"), contest) const admin = isContestAdmin(c.get("user"), contest)
return success(c, { return success(c, {
results: rows.map(({ rank, user, realName }) => ({ results: rows.map(
({ rank, user, realName }) =>
({
id: rank.id, id: rank.id,
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84 // 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)` // `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
@@ -258,7 +417,9 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
totalTime: rank.totalTime, totalTime: rank.totalTime,
submissionInfo: rank.submissionInfo, submissionInfo: rank.submissionInfo,
contestId: rank.contestId, contestId: rank.contestId,
} satisfies ContestRankItem)), }) satisfies ContestRankItem,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies ContestRank) } satisfies ContestRank)
}) },
)
+281 -74
View File
@@ -10,7 +10,17 @@ import {
type FlowchartStatistics, type FlowchartStatistics,
type FlowchartSubmission, type FlowchartSubmission,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm" import {
and,
asc,
count,
desc,
eq,
inArray,
isNull,
sql,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
@@ -38,8 +48,16 @@ function flowchartThrottleKey(userId: number) {
return `flowchart:${userId}` return `flowchart:${userId}`
} }
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) { function canView(
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id user: import("../auth/session").AuthUser,
row: { userId: number },
problem: { createdById: number },
) {
return (
row.userId === user.id ||
isAdminRole(user) ||
problem.createdById === user.id
)
} }
function flowchartData( function flowchartData(
@@ -67,20 +85,48 @@ function flowchartData(
} }
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => { flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
const parsed = createFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = createFlowchartRequestSchema.safeParse(
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) { await c.req.json().catch(() => null),
return failure(c, 400, "invalid-request", parsed.error?.issues[0]?.message ?? "Flowchart data is too large") )
if (
!parsed.success ||
JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024
) {
return failure(
c,
400,
"invalid-request",
parsed.error?.issues[0]?.message ?? "Flowchart data is too large",
)
} }
const [problem] = await db.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart }).from(schema.problem) const [problem] = await db
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1) .select({ id: schema.problem.id, allow: schema.problem.allowFlowchart })
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") .from(schema.problem)
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission") .where(eq(schema.problem.id, parsed.data.problemId))
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.allow)
return failure(
c,
400,
"flowchart-not-allowed",
"This problem does not allow flowchart submission",
)
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。 // 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶, // 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
// 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。 // 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。
const throttle = await consumeToken("user", flowchartThrottleKey(c.get("user")!.id)) const throttle = await consumeToken(
"user",
flowchartThrottleKey(c.get("user")!.id),
)
if (!throttle.allowed) { if (!throttle.allowed) {
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`) return failure(
c,
429,
"too-many-submissions",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
} }
const id = randomBytes(16).toString("hex") const id = randomBytes(16).toString("hex")
await db.insert(schema.flowchartSubmission).values({ await db.insert(schema.flowchartSubmission).values({
@@ -104,10 +150,22 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
try { try {
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id }) await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
} catch (error) { } catch (error) {
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id)) await db
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") .update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, id))
return failure(
c,
502,
"queue-unavailable",
"Evaluation queue is unavailable",
)
} }
return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201) return success(
c,
{ submissionId: id, status: "pending" } satisfies CreateFlowchartResponse,
201,
)
}) })
/** /**
@@ -128,20 +186,27 @@ async function flowchartProblemFilter(displayId: string) {
const problems = await db const problems = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
.from(schema.problem) .from(schema.problem)
.where(and( .where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`, sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空), // 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
// 比赛题的 _id 撞号是常态,不该被筛进来 // 比赛题的 _id 撞号是常态,不该被筛进来
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
)) ),
)
return problems.length return problems.length
? inArray(schema.flowchartSubmission.problemId, problems.map((row) => row.id)) ? inArray(
schema.flowchartSubmission.problemId,
problems.map((row) => row.id),
)
: sql`false` : sql`false`
} }
async function flowchartUserFilter(username: string) { async function flowchartUserFilter(username: string) {
const ids = (await matchedUsers(username)).map((row) => row.id) const ids = (await matchedUsers(username)).map((row) => row.id)
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false` return ids.length
? inArray(schema.flowchartSubmission.userId, ids)
: sql`false`
} }
/** /**
@@ -187,32 +252,54 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉 // 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门, // submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。 // 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { if (
!(await getBooleanOption("submission_list_show_all", true)) &&
!isAdminRole(user)
) {
return success(c, { results: [], total: 0 } satisfies FlowchartList) return success(c, { results: [], total: 0 } satisfies FlowchartList)
} }
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己 // 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
const onlyMyself = c.req.query("myself") === "1" || (!username && user.adminType === "Regular User") const onlyMyself =
c.req.query("myself") === "1" ||
(!username && user.adminType === "Regular User")
const filters: Array<SQL | undefined> = [] const filters: Array<SQL | undefined> = []
filters.push(...await Promise.all([ filters.push(
...(await Promise.all([
displayId ? flowchartProblemFilter(displayId) : undefined, displayId ? flowchartProblemFilter(displayId) : undefined,
!onlyMyself && username ? flowchartUserFilter(username) : undefined, !onlyMyself && username ? flowchartUserFilter(username) : undefined,
])) ])),
)
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id)) if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
if (c.req.query("today") === "1") filters.push(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`) if (c.req.query("today") === "1")
if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!)) filters.push(
sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`,
)
if (["S", "A", "B", "C"].includes(grade ?? ""))
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
const where = and(...filters) const where = and(...filters)
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join // 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
db.select({ value: count() }).from(schema.flowchartSubmission).where(where), db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
db.select(flowchartListColumns) db
.select(flowchartListColumns)
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)) .innerJoin(
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) schema.user,
eq(schema.flowchartSubmission.userId, schema.user.id),
)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(where) .where(where)
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset), .orderBy(desc(schema.flowchartSubmission.createTime))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: rows.map(({ flowchart, username, problem }) => ({ results: rows.map(
({ flowchart, username, problem }) =>
({
id: flowchart.id, id: flowchart.id,
username, username,
problem: problem.displayId, problem: problem.displayId,
@@ -226,7 +313,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime, evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem), showLink: canView(user, flowchart, problem),
} satisfies FlowchartListItem)), }) satisfies FlowchartListItem,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies FlowchartList) } satisfies FlowchartList)
}) })
@@ -262,20 +350,24 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED), eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
sql`${schema.flowchartSubmission.createTime} <= ${end}`, sql`${schema.flowchartSubmission.createTime} <= ${end}`,
] ]
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`) if (start)
filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
const displayId = c.req.query("problemId")?.trim() const displayId = c.req.query("problemId")?.trim()
if (displayId) { if (displayId) {
const [problem] = await db const [problem] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
.from(schema.problem) .from(schema.problem)
.where(and( .where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`, sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
)) ),
)
.limit(1) .limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
filters.push(eq(schema.flowchartSubmission.problemId, problem.id)) filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
} }
@@ -286,13 +378,17 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
if (username) { if (username) {
const ids = matched.map((row) => row.id) const ids = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」 // 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」
filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`) filters.push(
ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`,
)
} }
const where = and(...filters) const where = and(...filters)
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。 // 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
// 未禁用的普通用户才进分母,教师和管理员不算 // 未禁用的普通用户才进分母,教师和管理员不算
const roster = username const roster = username
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User") ? matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
: [] : []
/** /**
@@ -304,7 +400,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
* criteria 255 + suggestions 64 + feedback 47 2134 5 * criteria 255 + suggestions 64 + feedback 47 2134 5
* 18MB * 18MB
*/ */
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([ const [[totals], gradeRows, criteriaRows, textRows, submittedRows] =
await Promise.all([
db db
.select({ .select({
total: count(), total: count(),
@@ -313,11 +410,20 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
* Django Avg() NULL * Django Avg() NULL
* avg() NULL * avg() NULL
*/ */
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(Number), scoreSum:
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(Number), sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
Number,
),
scoreCount:
sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
Number,
),
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样, // 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
// 按 user_id 就不必 join user // 按 user_id 就不必 join user
completedCount: sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number), completedCount:
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(
Number,
),
}) })
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where(where), .where(where),
@@ -423,7 +529,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
if (!criteriaMax.has(key)) { if (!criteriaMax.has(key)) {
criteriaMax.set(key, typeof detail.max === "number" ? detail.max : 100) criteriaMax.set(key, typeof detail.max === "number" ? detail.max : 100)
} }
if (typeof detail.comment === "string" && detail.comment) pushText(detail.comment) if (typeof detail.comment === "string" && detail.comment)
pushText(detail.comment)
} }
if (row.feedback) pushText(row.feedback) if (row.feedback) pushText(row.feedback)
if (row.suggestions) pushText(row.suggestions) if (row.suggestions) pushText(row.suggestions)
@@ -431,13 +538,18 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const criteriaAverages: Record<string, { avg: number; max: number }> = {} const criteriaAverages: Record<string, { avg: number; max: number }> = {}
for (const row of criteriaRows) { for (const row of criteriaRows) {
criteriaAverages[row.key] = { avg: rounded(row.avg, 1), max: criteriaMax.get(row.key) ?? 100 } criteriaAverages[row.key] = {
avg: rounded(row.avg, 1),
max: criteriaMax.get(row.key) ?? 100,
}
} }
const submitted = new Set(submittedRows.map((row) => row.userId)) const submitted = new Set(submittedRows.map((row) => row.userId))
return success(c, { return success(c, {
totalCount: totals.total, totalCount: totals.total,
avgScore: totals.scoreCount ? rounded(totals.scoreSum / totals.scoreCount, 1) : 0, avgScore: totals.scoreCount
? rounded(totals.scoreSum / totals.scoreCount, 1)
: 0,
gradeDistribution, gradeDistribution,
criteriaAverages, criteriaAverages,
personCount: roster.length, personCount: roster.length,
@@ -453,33 +565,74 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
}) })
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => { flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem }) const [row] = await db
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)) .select({
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) flowchart: schema.flowchartSubmission,
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1) username: schema.user.username,
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist") problem: schema.problem,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.user,
eq(schema.flowchartSubmission.userId, schema.user.id),
)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1)
if (!row || !canView(c.get("user")!, row.flowchart, row.problem))
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
return success(c, flowchartData(row.flowchart, row.username)) return success(c, flowchartData(row.flowchart, row.username))
}) })
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission) const [row] = await db
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1) .from(schema.flowchartSubmission)
if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist") .innerJoin(
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry") schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1)
if (!row || !canView(user, row.flowchart, row.problem))
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
if (![2, 3].includes(row.flowchart.status))
return failure(
c,
409,
"retry-not-allowed",
"Submission is not in a state that allows retry",
)
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。 // canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法 // 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
if (!isAdminRole(user)) { if (!isAdminRole(user)) {
const throttle = await consumeToken("user", flowchartThrottleKey(user.id)) const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
if (!throttle.allowed) { if (!throttle.allowed) {
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`) return failure(
c,
429,
"too-many-submissions",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
} }
} }
await db.update(schema.flowchartSubmission).set({ await db
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null, .update(schema.flowchartSubmission)
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null, .set({
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) status: 0,
aiScore: null,
aiGrade: null,
aiFeedback: null,
aiSuggestions: null,
aiCriteriaDetails: {},
processingTime: null,
evaluationTime: null,
})
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
try { try {
// jobId 必须**正好三段**bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复 // jobId 必须**正好三段**bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
// 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛 // 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛
@@ -493,28 +646,82 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
) )
} catch (error) { } catch (error) {
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理 // 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await db
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") .update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
return failure(
c,
502,
"queue-unavailable",
"Evaluation queue is unavailable",
)
} }
return success(c, { submissionId: row.flowchart.id, status: "pending" } satisfies CreateFlowchartResponse) return success(c, {
submissionId: row.flowchart.id,
status: "pending",
} satisfies CreateFlowchartResponse)
}) })
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => { flowchartRoutes.get(
"/problems/:id/flowchart/current",
requireAuth,
async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade }) const rows = await db
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2))) .select({
score: schema.flowchartSubmission.aiScore,
grade: schema.flowchartSubmission.aiGrade,
})
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
.orderBy(desc(schema.flowchartSubmission.createTime)) .orderBy(desc(schema.flowchartSubmission.createTime))
return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent) return success(c, {
}) count: rows.length,
score: rows[0]?.score ?? 0,
grade: rows[0]?.grade ?? "",
} satisfies FlowchartCurrent)
},
)
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { flowchartRoutes.get(
"/problems/:id/flowchart/history",
requireAuth,
async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const page = queryInteger(c.req.query("page"), 0, { min: 0 }) const page = queryInteger(c.req.query("page"), 0, { min: 0 })
const rows = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username }) const rows = await db
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)) .select({
.where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2))) flowchart: schema.flowchartSubmission,
username: schema.user.username,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.user,
eq(schema.flowchartSubmission.userId, schema.user.id),
)
.where(
and(
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
.orderBy(asc(schema.flowchartSubmission.createTime)) .orderBy(asc(schema.flowchartSubmission.createTime))
const selected = page === 0 ? rows.at(-1) : rows[page - 1] const selected = page === 0 ? rows.at(-1) : rows[page - 1]
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range") if (page > rows.length)
return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail) return failure(c, 400, "page-out-of-range", "Page out of range")
}) return success(c, {
submission: selected
? flowchartData(selected.flowchart, selected.username)
: null,
count: rows.length,
} satisfies FlowchartDetail)
},
)
+7 -2
View File
@@ -51,7 +51,9 @@ export function stripClassPrefix(
* **** * ****
* *
*/ */
export function asFilterValue<T extends string | number>(value: string | number): T { export function asFilterValue<T extends string | number>(
value: string | number,
): T {
return value as T return value as T
} }
@@ -115,7 +117,10 @@ export function rounded(value: number, digits = 2) {
* / hint * / hint
* `hint-locked` * `hint-locked`
*/ */
export async function countFailedSubmissions(userId: number, problemId: number) { export async function countFailedSubmissions(
userId: number,
problemId: number,
) {
const [failed] = await db const [failed] = await db
.select({ value: count() }) .select({ value: count() })
.from(schema.submission) .from(schema.submission)
+267 -62
View File
@@ -1,4 +1,11 @@
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract" import type {
ProblemAuthor,
ProblemDetail,
ProblemList,
ProblemListItem,
Tag,
YearlyAc,
} from "@oj2/contract"
import { import {
and, and,
asc, asc,
@@ -22,7 +29,13 @@ import { astRequirements } from "../judge/ast"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { localTime, shiftMonthsByCalendar, todayStart } from "../time" import { localTime, shiftMonthsByCalendar, todayStart } from "../time"
import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers" import {
asFilterValue,
countFailedSubmissions,
objectValue as toObject,
queryInteger,
sampleUser,
} from "./helpers"
export const problemRoutes = new Hono<AppEnv>() export const problemRoutes = new Hono<AppEnv>()
@@ -44,24 +57,39 @@ function publicTemplates(value: unknown) {
async function getProblemStatuses(userId: number | undefined) { async function getProblemStatuses(userId: number | undefined) {
if (!userId) return {} if (!userId) return {}
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }) const [profile] = await db
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1) .select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.limit(1)
return toObject(toObject(profile?.value).problems) return toObject(toObject(profile?.value).problems)
} }
async function getProblemTags(problemIds: number[]) { async function getProblemTags(problemIds: number[]) {
if (problemIds.length === 0) return new Map<number, string[]>() if (problemIds.length === 0) return new Map<number, string[]>()
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }) const rows = await db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
.from(schema.problemTags) .from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) .innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(inArray(schema.problemTags.problemId, problemIds)) .where(inArray(schema.problemTags.problemId, problemIds))
const result = new Map<number, string[]>() const result = new Map<number, string[]>()
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name]) for (const row of rows)
result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
return result return result
} }
function listItem( function listItem(
row: { problem: typeof schema.problem.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null }, row: {
problem: typeof schema.problem.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
},
tags: Map<number, string[]>, tags: Map<number, string[]>,
statuses: Record<string, unknown>, statuses: Record<string, unknown>,
) { ) {
@@ -86,26 +114,54 @@ function listItem(
problemRoutes.get("/problems", optionalAuth, async (c) => { problemRoutes.get("/problems", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)] const filters = [
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
]
const author = c.req.query("author")?.trim() const author = c.req.query("author")?.trim()
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim() const difficulty = c.req.query("difficulty")?.trim()
const tag = c.req.query("tag")?.trim() const tag = c.req.query("tag")?.trim()
if (author) filters.push(eq(schema.user.username, author)) if (author) filters.push(eq(schema.user.username, author))
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!) if (keyword)
if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty))) filters.push(
or(
ilike(schema.problem.title, `%${keyword}%`),
ilike(schema.problem.displayId, `%${keyword}%`),
)!,
)
if (difficulty)
filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (tag) { if (tag) {
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags) filters.push(
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) inArray(
.where(eq(schema.problemTag.name, tag)))) schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(eq(schema.problemTag.name, tag)),
),
)
} }
const where = and(...filters) const where = and(...filters)
const sort = c.req.query("sort") const sort = c.req.query("sort")
const order = sort === "flowchart" const order =
? [desc(schema.problem.allowFlowchart), desc(schema.problem.showFlowchart), desc(schema.problem.createTime)] sort === "flowchart"
? [
desc(schema.problem.allowFlowchart),
desc(schema.problem.showFlowchart),
desc(schema.problem.createTime),
]
: sort === "ast" : sort === "ast"
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)] ? [
desc(sql`(${schema.problem.astRules} is not null)`),
desc(schema.problem.createTime),
]
: sort === "-accepted_number" : sort === "-accepted_number"
? [desc(schema.problem.acceptedNumber)] ? [desc(schema.problem.acceptedNumber)]
: sort === "accepted_number" : sort === "accepted_number"
@@ -119,13 +175,24 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
: sort === "create_time" : sort === "create_time"
? [asc(schema.problem.createTime)] ? [asc(schema.problem.createTime)]
: [desc(schema.problem.createTime)] : [desc(schema.problem.createTime)]
const [totalRow] = await db.select({ value: countDistinct(schema.problem.id) }).from(schema.problem) const [totalRow] = await db
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)).where(where) .select({ value: countDistinct(schema.problem.id) })
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) .from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(where)
const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem) .from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(...order).limit(limit).offset(offset) .where(where)
.orderBy(...order)
.limit(limit)
.offset(offset)
const [tags, statuses] = await Promise.all([ const [tags, statuses] = await Promise.all([
getProblemTags(rows.map((row) => row.problem.id)), getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id), getProblemStatuses(c.get("user")?.id),
@@ -140,33 +207,63 @@ problemRoutes.get("/problem-tags", async (c) => {
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在 // 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件) // 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
const rows = await db.select({ id: schema.problemTag.id, name: schema.problemTag.name, problemCount: countDistinct(schema.problemTags.problemId) }) const rows = await db
.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag) .from(schema.problemTag)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id)) .innerJoin(
.innerJoin(schema.problem, and( schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(
schema.problem,
and(
eq(schema.problem.id, schema.problemTags.problemId), eq(schema.problem.id, schema.problemTags.problemId),
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
)) ),
)
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`) .groupBy(schema.problemTag.id, schema.problemTag.name)
.having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name)) .orderBy(asc(schema.problemTag.name))
return success(c, rows satisfies Tag[]) return success(c, rows satisfies Tag[])
}) })
problemRoutes.get("/problems/random", async (c) => { problemRoutes.get("/problems/random", async (c) => {
const [row] = await db.select({ displayId: schema.problem.displayId }).from(schema.problem) const [row] = await db
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))).orderBy(sql`random()`).limit(1) .select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)),
)
.orderBy(sql`random()`)
.limit(1)
if (!row) return failure(c, 404, "no-problems", "No problem to pick") if (!row) return failure(c, 404, "no-problems", "No problem to pick")
return success(c, row.displayId) return success(c, row.displayId)
}) })
problemRoutes.get("/problem-authors", async (c) => { problemRoutes.get("/problem-authors", async (c) => {
const showAll = c.req.query("all") === "1" const showAll = c.req.query("all") === "1"
const rows = await db.select({ username: schema.user.username, problemCount: count(schema.problem.id) }) const rows = await db
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .select({
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true))) username: schema.user.username,
.groupBy(schema.user.username).orderBy(desc(count(schema.problem.id))) problemCount: count(schema.problem.id),
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(
and(
isNull(schema.problem.contestId),
eq(schema.user.isDisabled, false),
showAll ? undefined : eq(schema.problem.visible, true),
),
)
.groupBy(schema.user.username)
.orderBy(desc(count(schema.problem.id)))
return success(c, rows satisfies ProblemAuthor[]) return success(c, rows satisfies ProblemAuthor[])
}) })
@@ -174,56 +271,160 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
const user = c.get("user") const user = c.get("user")
if (!user) return success(c, "0") if (!user) return success(c, "0")
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [mine] = await db.select({ value: count() }).from(schema.submission).where(and( const [mine] = await db
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, id), .select({ value: count() })
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]), .from(schema.submission)
)) .where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
),
)
if (!mine?.value) return success(c, "0") if (!mine?.value) return success(c, "0")
// 「近两年」按东八区日历算到当天零点 // 「近两年」按东八区日历算到当天零点
const since = todayStart(shiftMonthsByCalendar(new Date(), -24)) const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
const [active, accepted] = await Promise.all([ const [active, accepted] = await Promise.all([
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))), db
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and( .select({ value: count() })
eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since), .from(schema.user)
)), .where(
and(
eq(schema.user.isDisabled, false),
gte(schema.user.lastLogin, since),
),
),
db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [0, 10]),
gte(schema.submission.createTime, since),
),
),
]) ])
const total = active[0]?.value ?? 0 const total = active[0]?.value ?? 0
const solved = accepted[0]?.value ?? 0 const solved = accepted[0]?.value ?? 0
return success(c, total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0") return success(
c,
total > 0 && solved < total
? (((total - solved) / total) * 100).toFixed(2)
: "0",
)
}) })
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
const [target] = await db.select({ id: schema.problem.id }).from(schema.problem) const [target] = await db
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId))).limit(1) .select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!target) return failure(c, 404, "problem-not-found", "Problem not found") if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id)) const targetTags = await db
.select({ id: schema.problemTags.problemtagId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemId, target.id))
if (targetTags.length === 0) return success(c, []) if (targetTags.length === 0) return success(c, [])
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem) .from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and( .where(
eq(schema.problem.visible, true), isNull(schema.problem.contestId), sql`${schema.problem.id} <> ${target.id}`, and(
inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags) eq(schema.problem.visible, true),
.where(inArray(schema.problemTags.problemtagId, targetTags.map((tag) => tag.id)))), isNull(schema.problem.contestId),
)).groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName).orderBy(asc(schema.problem.difficulty)).limit(5) sql`${schema.problem.id} <> ${target.id}`,
const [tags, statuses] = await Promise.all([getProblemTags(rows.map((row) => row.problem.id)), getProblemStatuses(c.get("user")?.id)]) inArray(
const filtered = rows.filter((row) => toObject(statuses[String(row.problem.id)]).status !== JudgeStatus.ACCEPTED) schema.problem.id,
return success(c, filtered.map((row) => listItem(row, tags, statuses))) db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemtagId,
targetTags.map((tag) => tag.id),
),
),
),
),
)
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
.orderBy(asc(schema.problem.difficulty))
.limit(5)
const [tags, statuses] = await Promise.all([
getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id),
])
const filtered = rows.filter(
(row) =>
toObject(statuses[String(row.problem.id)]).status !==
JudgeStatus.ACCEPTED,
)
return success(
c,
filtered.map((row) => listItem(row, tags, statuses)),
)
}) })
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => { problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem) const [problem] = await db
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1) .select({ id: schema.problem.id })
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") .from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int` const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
const rows = await db.select({ const rows = await db
.select({
year, year,
total: count(), total: count(),
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`, accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
}).from(schema.submission).where(and(eq(schema.submission.problemId, problem.id), isNull(schema.submission.contestId), notInArray(schema.submission.result, [6, 7]))) })
.groupBy(year).orderBy(year) .from(schema.submission)
return success(c, rows.map((row) => ({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 } satisfies YearlyAc))) .where(
and(
eq(schema.submission.problemId, problem.id),
isNull(schema.submission.contestId),
notInArray(schema.submission.result, [6, 7]),
),
)
.groupBy(year)
.orderBy(year)
return success(
c,
rows.map(
(row) =>
({
...row,
acRate:
row.total > 0
? Math.round((row.accepted / row.total) * 10_000) / 100
: 0,
}) satisfies YearlyAc,
),
)
}) })
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
@@ -244,7 +445,8 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
) )
.limit(1) .limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!row)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const tagRows = await db const tagRows = await db
.select({ name: schema.problemTag.name }) .select({ name: schema.problemTag.name })
@@ -297,7 +499,10 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
statisticInfo: objectValue(row.problem.statisticInfo), statisticInfo: objectValue(row.problem.statisticInfo),
contestId: row.problem.contestId, contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name), tags: tagRows.map((tag) => tag.name),
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null), createdBy: sampleUser(
{ id: row.creatorId, username: row.creatorUsername },
null,
),
myStatus, myStatus,
myFailedCount, myFailedCount,
allowFlowchart: row.problem.allowFlowchart, allowFlowchart: row.problem.allowFlowchart,
+357 -88
View File
@@ -24,7 +24,12 @@ import {
} from "drizzle-orm" } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import {
optionalAuth,
requireAuth,
requireTeacher,
type AppEnv,
} from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { computeProgress } from "../services/problemset" import { computeProgress } from "../services/problemset"
@@ -34,14 +39,18 @@ export const problemsetRoutes = new Hono<AppEnv>()
type ProblemSetRow = typeof schema.problemset.$inferSelect type ProblemSetRow = typeof schema.problemset.$inferSelect
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) { function progressSummary(
return progress ? { progress: typeof schema.problemsetProgress.$inferSelect | undefined,
) {
return progress
? {
isJoined: true, isJoined: true,
progressPercentage: progress.progressPercentage, progressPercentage: progress.progressPercentage,
completedCount: progress.completedProblemsCount, completedCount: progress.completedProblemsCount,
totalCount: progress.totalProblemsCount, totalCount: progress.totalProblemsCount,
isCompleted: progress.isCompleted, isCompleted: progress.isCompleted,
} : { }
: {
isJoined: false, isJoined: false,
progressPercentage: 0, progressPercentage: 0,
completedCount: 0, completedCount: 0,
@@ -53,14 +62,23 @@ function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect
async function problemSetCreators(ids: number[]) { async function problemSetCreators(ids: number[]) {
const map = new Map<number, ReturnType<typeof sampleUser>>() const map = new Map<number, ReturnType<typeof sampleUser>>()
if (ids.length === 0) return map if (ids.length === 0) return map
const rows = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName }) const rows = await db
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .select({
id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(inArray(schema.user.id, ids)) .where(inArray(schema.user.id, ids))
for (const row of rows) map.set(row.id, sampleUser(row, row.realName)) for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
return map return map
} }
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) { function badgeData(
badge: typeof schema.problemsetBadge.$inferSelect,
earned?: boolean,
) {
return { return {
id: badge.id, id: badge.id,
problemsetId: badge.problemsetId, problemsetId: badge.problemsetId,
@@ -86,26 +104,68 @@ async function serializeProblemSets(
) { ) {
if (rows.length === 0) return [] if (rows.length === 0) return []
const ids = rows.map((row) => row.id) const ids = rows.map((row) => row.id)
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([ const [problemCounts, progresses, badges, earnedRows, creators] =
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() }) await Promise.all([
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids)) db
.select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId), .groupBy(schema.problemsetProblem.problemsetId),
userId ? db.select().from(schema.problemsetProgress) userId
.where(and(inArray(schema.problemsetProgress.problemsetId, ids), eq(schema.problemsetProgress.userId, userId))) ? db
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]), .select()
includeBadges ? db.select().from(schema.problemsetBadge) .from(schema.problemsetProgress)
.where(inArray(schema.problemsetBadge.problemsetId, ids)).orderBy(asc(schema.problemsetBadge.id)) .where(
and(
inArray(schema.problemsetProgress.problemsetId, ids),
eq(schema.problemsetProgress.userId, userId),
),
)
: Promise.resolve(
[] as (typeof schema.problemsetProgress.$inferSelect)[],
),
includeBadges
? db
.select()
.from(schema.problemsetBadge)
.where(inArray(schema.problemsetBadge.problemsetId, ids))
.orderBy(asc(schema.problemsetBadge.id))
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]), : Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
includeBadges && userId ? db.select({ id: schema.userBadge.badgeId }).from(schema.userBadge) includeBadges && userId
.innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id)) ? db
.where(and(eq(schema.userBadge.userId, userId), inArray(schema.problemsetBadge.problemsetId, ids))) .select({ id: schema.userBadge.badgeId })
.from(schema.userBadge)
.innerJoin(
schema.problemsetBadge,
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
)
.where(
and(
eq(schema.userBadge.userId, userId),
inArray(schema.problemsetBadge.problemsetId, ids),
),
)
: Promise.resolve([] as { id: number }[]), : Promise.resolve([] as { id: number }[]),
problemSetCreators([...new Set(rows.map((row) => row.createdById))]), problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
]) ])
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value])) const countBySet = new Map(
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item])) problemCounts.map((item) => [item.problemsetId, item.value]),
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>() )
for (const badge of badges) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge]) const progressBySet = new Map(
progresses.map((item) => [item.problemsetId, item]),
)
const badgesBySet = new Map<
number,
(typeof schema.problemsetBadge.$inferSelect)[]
>()
for (const badge of badges)
badgesBySet.set(badge.problemsetId, [
...(badgesBySet.get(badge.problemsetId) ?? []),
badge,
])
const earned = new Set(earnedRows.map((item) => item.id)) const earned = new Set(earnedRows.map((item) => item.id))
return rows.map((row) => { return rows.map((row) => {
const progress = progressBySet.get(row.id) const progress = progressBySet.get(row.id)
@@ -113,7 +173,9 @@ async function serializeProblemSets(
id: row.id, id: row.id,
title: row.title, title: row.title,
description: row.description, description: row.description,
createdBy: creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), createdBy:
creators.get(row.createdById) ??
sampleUser({ id: row.createdById, username: "" }, null),
createTime: row.createTime, createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime, lastUpdateTime: row.lastUpdateTime,
difficulty: row.difficulty, difficulty: row.difficulty,
@@ -123,7 +185,11 @@ async function serializeProblemSets(
problemsCount: countBySet.get(row.id) ?? 0, problemsCount: countBySet.get(row.id) ?? 0,
completedCount: progress?.completedProblemsCount ?? 0, completedCount: progress?.completedProblemsCount ?? 0,
userProgress: progressSummary(progress), userProgress: progressSummary(progress),
badges: includeBadges ? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id))) : undefined, badges: includeBadges
? (badgesBySet.get(row.id) ?? []).map((badge) =>
badgeData(badge, earned.has(badge.id)),
)
: undefined,
} satisfies ProblemSet } satisfies ProblemSet
}) })
} }
@@ -131,17 +197,33 @@ async function serializeProblemSets(
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => { problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")] const filters = [
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
]
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim() const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim() const status = c.req.query("status")?.trim()
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!) if (keyword)
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty))) filters.push(
or(
ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`),
)!,
)
if (difficulty)
filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status))) if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = and(...filters) const where = and(...filters)
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where), db.select({ value: count() }).from(schema.problemset).where(where),
db.select().from(schema.problemset).where(where).orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset), db
.select()
.from(schema.problemset)
.where(where)
.orderBy(desc(schema.problemset.createTime))
.limit(limit)
.offset(offset),
]) ])
return success(c, { return success(c, {
results: await serializeProblemSets(rows, c.get("user")?.id, true), results: await serializeProblemSets(rows, c.get("user")?.id, true),
@@ -151,8 +233,17 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => { problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db.select().from(schema.problemset) const [row] = await db
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1) .select()
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const [data] = await serializeProblemSets([row], c.get("user")?.id) const [data] = await serializeProblemSets([row], c.get("user")?.id)
return success(c, data) return success(c, data)
@@ -160,8 +251,17 @@ problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset) const [problemSet] = await db
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1) .select({ id: schema.problemset.id })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、 // 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。 // flowchart_data、sql_display 一起拉回来,题单页一个都不用。
@@ -169,7 +269,8 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组 // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话 // 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。 // 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
const rows = await db.select({ const rows = await db
.select({
link: schema.problemsetProblem, link: schema.problemsetProblem,
problemId: schema.problem.id, problemId: schema.problem.id,
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
@@ -177,15 +278,33 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
difficulty: schema.problem.difficulty, difficulty: schema.problem.difficulty,
}) })
.from(schema.problemsetProblem) .from(schema.problemsetProblem)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
.where(eq(schema.problemsetProblem.problemsetId, id)) .where(eq(schema.problemsetProblem.problemsetId, id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) .orderBy(
asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
)
const progressRows = c.get("user") const progressRows = c.get("user")
? await db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress) ? await db
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) .select({ detail: schema.problemsetProgress.progressDetail })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, id),
eq(schema.problemsetProgress.userId, c.get("user")!.id),
),
)
.limit(1)
: [] : []
const completed = objectValue(progressRows[0]?.detail) const completed = objectValue(progressRows[0]?.detail)
return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => ({ return success(
c,
rows.map(
({ link, problemId, displayId, title, difficulty }) =>
({
id: link.id, id: link.id,
problemsetId: link.problemsetId, problemsetId: link.problemsetId,
problem: { id: problemId, _id: displayId, title, difficulty }, problem: { id: problemId, _id: displayId, title, difficulty },
@@ -194,7 +313,9 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
score: link.score, score: link.score,
hint: link.hint, hint: link.hint,
isCompleted: String(problemId) in completed, isCompleted: String(problemId) in completed,
} satisfies ProblemSetProblem))) }) satisfies ProblemSetProblem,
),
)
}) })
async function recomputeProgress( async function recomputeProgress(
@@ -202,30 +323,58 @@ async function recomputeProgress(
progress: typeof schema.problemsetProgress.$inferSelect, progress: typeof schema.problemsetProgress.$inferSelect,
detail: Record<string, unknown>, detail: Record<string, unknown>,
) { ) {
const links = await tx.select({ const links = await tx
.select({
problemId: schema.problemsetProblem.problemId, problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score, score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired, isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId)) })
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份, // 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed // 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
const update = computeProgress(detail, links, progress.completeTime) const update = computeProgress(detail, links, progress.completeTime)
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id)) await tx
.update(schema.problemsetProgress)
.set(update)
.where(eq(schema.problemsetProgress.id, progress.id))
return { ...progress, ...update } return { ...progress, ...update }
} }
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => { problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
const parsed = joinProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = joinProblemSetRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid problem set") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid problem set")
const user = c.get("user")! const user = c.get("user")!
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset) const [problemSet] = await db
.where(and(eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1) .select({ id: schema.problemset.id })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, parsed.data.problemSetId),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
const [existing] = await db.select({ id: schema.problemsetProgress.id }).from(schema.problemsetProgress) const [existing] = await db
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))).limit(1) .select({ id: schema.problemsetProgress.id })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, problemSet.id),
eq(schema.problemsetProgress.userId, user.id),
),
)
.limit(1)
if (existing) return failure(c, 409, "already-joined", "已经加入该题单") if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [created] = await tx.insert(schema.problemsetProgress).values({ const [created] = await tx
.insert(schema.problemsetProgress)
.values({
problemsetId: problemSet.id, problemsetId: problemSet.id,
userId: user.id, userId: user.id,
joinTime: new Date().toISOString(), joinTime: new Date().toISOString(),
@@ -236,7 +385,8 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
totalProblemsCount: 0, totalProblemsCount: 0,
totalScore: 0, totalScore: 0,
progressDetail: {}, progressDetail: {},
}).returning() })
.returning()
if (created) await recomputeProgress(tx, created, {}) if (created) await recomputeProgress(tx, created, {})
}) })
return success(c, null, 201) return success(c, null, 201)
@@ -245,44 +395,102 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => { problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
const requested = c.req.param("username") const requested = c.req.param("username")
const username = requested === "me" ? c.get("user")?.username : requested const username = requested === "me" ? c.get("user")?.username : requested
if (!username) return failure(c, 401, "login-required", "Authentication required") if (!username)
const [target] = await db.select({ id: schema.user.id }).from(schema.user) return failure(c, 401, "login-required", "Authentication required")
.where(and(eq(schema.user.username, username), eq(schema.user.isDisabled, false))).limit(1) const [target] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.username, username),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!target) return failure(c, 404, "user-not-found", "用户不存在") if (!target) return failure(c, 404, "user-not-found", "用户不存在")
const rows = await db.select({ userBadge: schema.userBadge, badge: schema.problemsetBadge, problemSet: schema.problemset }) const rows = await db
.from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id)) .select({
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id)) userBadge: schema.userBadge,
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime)) badge: schema.problemsetBadge,
return success(c, rows.map(({ userBadge, badge, problemSet }) => ({ problemSet: schema.problemset,
})
.from(schema.userBadge)
.innerJoin(
schema.problemsetBadge,
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
)
.innerJoin(
schema.problemset,
eq(schema.problemsetBadge.problemsetId, schema.problemset.id),
)
.where(eq(schema.userBadge.userId, target.id))
.orderBy(desc(schema.userBadge.earnedTime))
return success(
c,
rows.map(
({ userBadge, badge, problemSet }) =>
({
id: userBadge.id, id: userBadge.id,
userId: userBadge.userId, userId: userBadge.userId,
badge: badgeData(badge), badge: badgeData(badge),
earnedTime: userBadge.earnedTime, earnedTime: userBadge.earnedTime,
problemset: { id: problemSet.id, title: problemSet.title }, problemset: { id: problemSet.id, title: problemSet.title },
} satisfies UserBadge))) }) satisfies UserBadge,
),
)
}) })
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => { problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and( const [problemSet] = await db
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"), .select({ id: schema.problemset.id })
)).limit(1) .from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, id)) const badges = await db
return success(c, badges.map((badge) => badgeData(badge))) .select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, id))
return success(
c,
badges.map((badge) => badgeData(badge)),
)
}) })
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => { problemsetRoutes.get(
"/problem-sets/:id/user-progress",
requireTeacher,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db.select({ id: schema.problemset.id, createdById: schema.problemset.createdById }) const [problemSet] = await db
.from(schema.problemset).where(and( .select({
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"), id: schema.problemset.id,
)).limit(1) createdById: schema.problemset.createdById,
})
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行, // 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。 // 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
// 越权报「不存在」,不泄露题单存在与否。 // 越权报「不存在」,不泄露题单存在与否。
const user = c.get("user")! const user = c.get("user")!
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) { if (
!problemSet ||
(user.adminType !== "Super Admin" && problemSet.createdById !== user.id)
) {
return failure(c, 404, "problem-set-not-found", "题单不存在") return failure(c, 404, "problem-set-not-found", "题单不存在")
} }
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
@@ -291,24 +499,77 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
const completion = c.req.query("completionStatus")?.trim() const completion = c.req.query("completionStatus")?.trim()
const filters = [eq(schema.problemsetProgress.problemsetId, id)] const filters = [eq(schema.problemsetProgress.problemsetId, id)]
if (className) filters.push(ilike(schema.user.username, `%${className}%`)) if (className) filters.push(ilike(schema.user.username, `%${className}%`))
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true)) if (completion === "completed")
else if (completion === "in_progress") filters.push(and(eq(schema.problemsetProgress.isCompleted, false), gt(schema.problemsetProgress.completedProblemsCount, 0))!) filters.push(eq(schema.problemsetProgress.isCompleted, true))
else if (completion === "not_started") filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0)) else if (completion === "in_progress")
filters.push(
and(
eq(schema.problemsetProgress.isCompleted, false),
gt(schema.problemsetProgress.completedProblemsCount, 0),
)!,
)
else if (completion === "not_started")
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
const where = and(...filters) const where = and(...filters)
const [statsRows, rows, problemRows] = await Promise.all([ const [statsRows, rows, problemRows] = await Promise.all([
db.select({ total: count(), completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`, avgProgress: avg(schema.problemsetProgress.progressPercentage) }) db
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)).where(where), .select({
db.select({ progress: schema.problemsetProgress, user: schema.user, realName: schema.userProfile.realName }) total: count(),
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)) completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where) avgProgress: avg(schema.problemsetProgress.progressPercentage),
.orderBy(desc(schema.problemsetProgress.isCompleted), desc(schema.problemsetProgress.progressPercentage), asc(schema.problemsetProgress.joinTime)).limit(limit).offset(offset), })
db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem) .from(schema.problemsetProgress)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.where(where),
db
.select({
progress: schema.problemsetProgress,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problemsetProgress)
.innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(
desc(schema.problemsetProgress.isCompleted),
desc(schema.problemsetProgress.progressPercentage),
asc(schema.problemsetProgress.joinTime),
)
.limit(limit)
.offset(offset),
db
.select({
id: schema.problem.id,
_id: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problemsetProblem)
.innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
.where(eq(schema.problemsetProblem.problemsetId, id)) .where(eq(schema.problemsetProblem.problemsetId, id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)), .orderBy(
asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
),
]) ])
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem])) const problemMap = new Map(
const results = rows.map(({ progress, user: progressUser, realName }) => ({ problemRows.map((problem) => [String(problem.id), problem]),
)
const results = rows.map(
({ progress, user: progressUser, realName }) =>
({
id: progress.id, id: progress.id,
problemsetId: progress.problemsetId, problemsetId: progress.problemsetId,
user: sampleUser(progressUser, realName), user: sampleUser(progressUser, realName),
@@ -319,13 +580,21 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
completedProblemsCount: progress.completedProblemsCount, completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount, totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore, totalScore: progress.totalScore,
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []), completedProblems: Object.keys(
} satisfies ProblemSetProgress)) objectValue(progress.progressDetail),
).flatMap((key) => problemMap.get(key) ?? []),
}) satisfies ProblemSetProgress,
)
const stats = statsRows[0] const stats = statsRows[0]
return success(c, { return success(c, {
results, results,
total: stats?.total ?? 0, total: stats?.total ?? 0,
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) }, statistics: {
total: stats?.total ?? 0,
completed: stats?.completed ?? 0,
avgProgress: Number(stats?.avgProgress ?? 0),
},
problems: problemRows, problems: problemRows,
} satisfies ProblemSetProgressList) } satisfies ProblemSetProgressList)
}) },
)
+29 -8
View File
@@ -36,7 +36,10 @@ siteRoutes.get("/site/online", async (c) => {
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里) // 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
const fallbackQuotes = [ const fallbackQuotes = [
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" }, {
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
from: "Structure and Interpretation of Computer Programs",
},
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" }, { hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" }, { hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
] ]
@@ -50,10 +53,15 @@ const sentenceCache = new Map<string, Quote[]>()
async function loadSentences(path: string) { async function loadSentences(path: string) {
const cached = sentenceCache.get(path) const cached = sentenceCache.get(path)
if (cached) return cached if (cached) return cached
const raw = await Bun.file(resolve(config.hitokotoDirectory, path)).json() as { hitokoto?: unknown, from?: unknown }[] const raw = (await Bun.file(
resolve(config.hitokotoDirectory, path),
).json()) as { hitokoto?: unknown; from?: unknown }[]
const rows = (Array.isArray(raw) ? raw : []) const rows = (Array.isArray(raw) ? raw : [])
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0) .filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
.map((it) => ({ hitokoto: it.hitokoto as string, from: typeof it.from === "string" ? it.from : "佚名" })) .map((it) => ({
hitokoto: it.hitokoto as string,
from: typeof it.from === "string" ? it.from : "佚名",
}))
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`) if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
sentenceCache.set(path, rows) sentenceCache.set(path, rows)
return rows return rows
@@ -61,8 +69,12 @@ async function loadSentences(path: string) {
async function randomQuote() { async function randomQuote() {
if (!categoryPaths) { if (!categoryPaths) {
const categories = await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json() as { path?: string }[] const categories = (await Bun.file(
const paths = categories.map((it) => it.path).filter((it): it is string => typeof it === "string") resolve(config.hitokotoDirectory, "categories.json"),
).json()) as { path?: string }[]
const paths = categories
.map((it) => it.path)
.filter((it): it is string => typeof it === "string")
if (paths.length === 0) throw new Error("no hitokoto categories") if (paths.length === 0) throw new Error("no hitokoto categories")
categoryPaths = paths categoryPaths = paths
} }
@@ -75,7 +87,8 @@ siteRoutes.get("/quotes/random", async (c) => {
try { try {
return success(c, (await randomQuote()) satisfies Quote) return success(c, (await randomQuote()) satisfies Quote)
} catch { } catch {
const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]! const item =
fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
return success(c, item satisfies Quote) return success(c, item satisfies Quote)
} }
}) })
@@ -83,7 +96,12 @@ siteRoutes.get("/quotes/random", async (c) => {
siteRoutes.get("/classes/:className/usernames", async (c) => { siteRoutes.get("/classes/:className/usernames", async (c) => {
const className = c.req.param("className").trim() const className = c.req.param("className").trim()
if (!/^\d{3,4}$/.test(className)) { if (!/^\d{3,4}$/.test(className)) {
return failure(c, 400, "invalid-class", "Class name must contain 3 or 4 digits") return failure(
c,
400,
"invalid-class",
"Class name must contain 3 or 4 digits",
)
} }
const rows = await db const rows = await db
.select({ username: schema.user.username }) .select({ username: schema.user.username })
@@ -91,5 +109,8 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
.where(eq(schema.user.className, className)) .where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id)) .orderBy(desc(schema.user.createTime), asc(schema.user.id))
// 用 stripClassPrefix 而不是 replacereplace 会把中间的匹配也删掉,前缀对不上时截出乱码 // 用 stripClassPrefix 而不是 replacereplace 会把中间的匹配也删掉,前缀对不上时截出乱码
return success(c, rows.map(({ username }) => stripClassPrefix(username, className))) return success(
c,
rows.map(({ username }) => stripClassPrefix(username, className)),
)
}) })
+365 -121
View File
@@ -12,7 +12,19 @@ import {
type SubmissionStatisticsItems, type SubmissionStatisticsItems,
type TodaySubmissionStatistics, type TodaySubmissionStatistics,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import {
and,
count,
desc,
eq,
gt,
ilike,
inArray,
isNull,
or,
sql,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { import {
@@ -24,7 +36,11 @@ import {
import type { AuthUser } from "../auth/session" import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" import {
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { judgeQueue } from "../queue" import { judgeQueue } from "../queue"
import { import {
canAccessContest, canAccessContest,
@@ -66,11 +82,22 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
if (parsed.data.contestId) { if (parsed.data.contestId) {
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体, // 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。 // 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
const contest = await findAccessibleContest(c.get("user"), parsed.data.contestId) const contest = await findAccessibleContest(
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") c.get("user"),
parsed.data.contestId,
)
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "problems") const access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) if (!access.ok)
if (contestStatus(contest) === "-1") return failure(c, 403, "contest-ended", "The contest has ended") return failure(
c,
access.code === "login-required" ? 401 : 403,
access.code,
access.message,
)
if (contestStatus(contest) === "-1")
return failure(c, 403, "contest-ended", "The contest has ended")
contestId = contest.id contestId = contest.id
} }
@@ -78,7 +105,12 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
// 比赛权限校验之后、取题目之前,按用户 id 消耗一个令牌。判题沙箱是有限资源。 // 比赛权限校验之后、取题目之前,按用户 id 消耗一个令牌。判题沙箱是有限资源。
const throttle = await consumeToken("user", String(c.get("user")!.id)) const throttle = await consumeToken("user", String(c.get("user")!.id))
if (!throttle.allowed) { if (!throttle.allowed) {
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`) return failure(
c,
429,
"too-many-submissions",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
} }
const [problem] = await db const [problem] = await db
@@ -91,12 +123,15 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
and( and(
eq(schema.problem.id, parsed.data.problemId), eq(schema.problem.id, parsed.data.problemId),
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
contestId === null ? isNull(schema.problem.contestId) : eq(schema.problem.contestId, contestId), contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
), ),
) )
.limit(1) .limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.languages.includes(parsed.data.language)) { if (!problem.languages.includes(parsed.data.language)) {
return failure( return failure(
c, c,
@@ -113,12 +148,15 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。 // 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
let problemsetId: number | null = null let problemsetId: number | null = null
if (contestId === null && parsed.data.problemSetId) { if (contestId === null && parsed.data.problemSetId) {
const [link] = await db.select({ id: schema.problemsetProblem.id }) const [link] = await db
.select({ id: schema.problemsetProblem.id })
.from(schema.problemsetProblem) .from(schema.problemsetProblem)
.where(and( .where(
and(
eq(schema.problemsetProblem.problemsetId, parsed.data.problemSetId), eq(schema.problemsetProblem.problemsetId, parsed.data.problemSetId),
eq(schema.problemsetProblem.problemId, problem.id), eq(schema.problemsetProblem.problemId, problem.id),
)) ),
)
.limit(1) .limit(1)
if (link) problemsetId = parsed.data.problemSetId if (link) problemsetId = parsed.data.problemSetId
} }
@@ -157,22 +195,27 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
return failure(c, 502, "queue-unavailable", "Judge queue is unavailable") return failure(c, 502, "queue-unavailable", "Judge queue is unavailable")
} }
return success( return success(c, { submissionId } satisfies CreateSubmissionResponse, 201)
c,
{ submissionId } satisfies CreateSubmissionResponse,
201,
)
}) })
submissionRoutes.get("/submissions/today-count", async (c) => { submissionRoutes.get("/submissions/today-count", async (c) => {
const language = c.req.query("language") const language = c.req.query("language")
if (language === "Flowchart") { if (language === "Flowchart") {
const [row] = await db.select({ value: count() }).from(schema.flowchartSubmission) const [row] = await db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`) .where(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
return success(c, row?.value ?? 0) return success(c, row?.value ?? 0)
} }
const [row] = await db.select({ value: count() }).from(schema.submission) const [row] = await db
.where(and(isNull(schema.submission.contestId), sql`${schema.submission.createTime} >= ${todayStart()}`)) .select({ value: count() })
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`,
),
)
return success(c, row?.value ?? 0) return success(c, row?.value ?? 0)
}) })
@@ -190,14 +233,18 @@ function judgedRate(accepted: number, judged: number) {
* `localTime()` `extract(hour from create_time)` * `localTime()` `extract(hour from create_time)`
* UTC 8 * UTC 8
*/ */
submissionRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => { submissionRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/** /**
* *
* today-count * today-count
* 21 0 * 21 0
*/ */
const showProblems = const showProblems =
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user")) (await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and( const where = and(
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`, sql`${schema.submission.createTime} >= ${todayStart()}`,
@@ -206,13 +253,17 @@ submissionRoutes.get("/submissions/today-statistics", optionalAuth, async (c) =>
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int` const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([ const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db db
.select({ .select({
total: count(), total: count(),
accepted: acceptedFilter.mapWith(Number), accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number), judging: judgingFilter.mapWith(Number),
userCount: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number), userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
}) })
.from(schema.submission) .from(schema.submission)
.where(where), .where(where),
@@ -242,10 +293,17 @@ submissionRoutes.get("/submissions/today-statistics", optionalAuth, async (c) =>
accepted: acceptedFilter.mapWith(Number), accepted: acceptedFilter.mapWith(Number),
}) })
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) .innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西 // 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true))) .where(and(where, eq(schema.problem.visible, true)))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title) .groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count())) .orderBy(desc(count()))
.limit(10) .limit(10)
: [], : [],
@@ -256,31 +314,37 @@ submissionRoutes.get("/submissions/today-statistics", optionalAuth, async (c) =>
const hours = Array.from({ length: 24 }, () => 0) const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value for (const row of hourRows) hours[row.hour] = row.value
return success( return success(c, {
c,
{
total, total,
accepted: totals?.accepted ?? 0, accepted: totals?.accepted ?? 0,
judging, judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging), correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0, userCount: totals?.userCount ?? 0,
hours, hours,
languages: languageRows.map((row) => ({ language: row.language, count: row.value })), languages: languageRows.map((row) => ({
results: resultRows.map((row) => ({ result: row.result, count: row.value })), language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({ problems: problemRows.map((row) => ({
problem: row.displayId, problem: row.displayId,
problemTitle: row.title, problemTitle: row.title,
count: row.value, count: row.value,
acceptedCount: row.accepted, acceptedCount: row.accepted,
})), })),
} satisfies TodaySubmissionStatistics, } satisfies TodaySubmissionStatistics)
) },
}) )
/** /**
* `end` `start` * `end` `start`
*/ */
function statisticsRange(c: { req: { query(name: string): string | undefined } }) { function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
const end = c.req.query("end")?.trim() const end = c.req.query("end")?.trim()
if (!end) return null if (!end) return null
const start = c.req.query("start")?.trim() const start = c.req.query("start")?.trim()
@@ -353,7 +417,12 @@ async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// 口径要和列上的 $type 一致 // 口径要和列上的 $type 一致
const byUser = new Map< const byUser = new Map<
number, number,
{ id: string; problem: string; result: JudgeStatusValue; error: string | null } {
id: string
problem: string
result: JudgeStatusValue
error: string | null
}
>() >()
if (!userIds.length) return byUser if (!userIds.length) return byUser
@@ -445,10 +514,20 @@ async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
*/ */
async function usernameFilter(username: string) { async function usernameFilter(username: string) {
const like = `%${username}%` const like = `%${username}%`
const users = await db.select({ id: schema.user.id }).from(schema.user) const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like)) .where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like) const frozen = ilike(schema.submission.username, like)
return users.length ? or(inArray(schema.submission.userId, users.map((row) => row.id)), frozen)! : frozen return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
} }
/** /**
@@ -461,11 +540,23 @@ async function usernameFilter(username: string) {
* filter * filter
*/ */
async function problemFilter(displayId: string, contestId: number | null) { async function problemFilter(displayId: string, contestId: number | null) {
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and( const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`, sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null ? isNull(schema.problem.contestId) : eq(schema.problem.contestId, contestId), contestId === null
)) ? isNull(schema.problem.contestId)
return problems.length ? inArray(schema.submission.problemId, problems.map((row) => row.id)) : sql`false` : eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
} }
/** /**
@@ -482,14 +573,20 @@ async function statisticsScope(c: {
}): Promise<StatisticsScope> { }): Promise<StatisticsScope> {
const range = statisticsRange(c) const range = statisticsRange(c)
if (!range) { if (!range) {
return { ok: false, status: 400, code: "invalid-request", message: "end is required" } return {
ok: false,
status: 400,
code: "invalid-request",
message: "end is required",
}
} }
const filters = [ const filters = [
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`, sql`${schema.submission.createTime} <= ${range.end}`,
] ]
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`) if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "") const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) { if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
@@ -532,7 +629,9 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失, // 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」 // 「查无此班」会变成「全站统计」
filters.push( filters.push(
matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`, matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
) )
} }
const where = and(...filters) const where = and(...filters)
@@ -612,7 +711,9 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
*/ */
const requiredSolved = scope.problemCount const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) => const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0 requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
/** /**
* **西**`done` * **西**`done`
@@ -639,7 +740,10 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
solvedCount: row.solvedCount, solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0, astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount, judgingCount: row.judgingCount,
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount), correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row), done: isDone(row),
})) }))
@@ -681,7 +785,9 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
* *
* perUser join * perUser join
*/ */
realName: username ? stripClassPrefix(row.username, row.className) : row.username, realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount, submissionCount: row.submissionCount,
solvedCount: row.solvedCount, solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null, lastFailure: failureByUser.get(row.userId) ?? null,
@@ -693,9 +799,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
let personCount = rosterRows.length let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount if (personCount && personCount < doneCount) personCount = doneCount
return success( return success(c, {
c,
{
submissionCount, submissionCount,
acceptedCount, acceptedCount,
judgingCount, judgingCount,
@@ -704,8 +808,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
data, data,
dataUnaccepted, dataUnaccepted,
dataAttempted, dataAttempted,
} satisfies SubmissionStatistics, } satisfies SubmissionStatistics)
)
}) })
/** /**
@@ -715,9 +818,13 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
* 沿 scope * 沿 scope
* *
*/ */
submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c) => { submissionRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim() const username = c.req.query("username")?.trim()
if (!username) return failure(c, 400, "invalid-request", "username is required") if (!username)
return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c) const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
@@ -751,28 +858,46 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
problemTitle: schema.problem.title, problemTitle: schema.problem.title,
}) })
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) .innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity)) .where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id)) .orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1) .limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success( return success(c, {
c,
{
items: rows.slice(0, STATISTICS_ITEMS_LIMIT), items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated, truncated,
} satisfies SubmissionStatisticsItems, } satisfies SubmissionStatisticsItems)
) },
}) )
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => { submissionRoutes.post(
"/submissions/:id/rejudge",
requireSuperAdmin,
async (c) => {
const [row] = await db const [row] = await db
.select({ id: schema.submission.id, problemId: schema.submission.problemId }) .select({
id: schema.submission.id,
problemId: schema.submission.problemId,
})
.from(schema.submission) .from(schema.submission)
.where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId))) .where(
and(
eq(schema.submission.id, c.req.param("id")),
isNull(schema.submission.contestId),
),
)
.limit(1) .limit(1)
if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist") if (!row)
return failure(
c,
404,
"submission-not-found",
"Submission does not exist",
)
await db await db
.update(schema.submission) .update(schema.submission)
@@ -787,17 +912,26 @@ submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) =
{ jobId: `${row.id}:rejudge:${Date.now()}` }, { jobId: `${row.id}:rejudge:${Date.now()}` },
) )
return success(c, null) return success(c, null)
}) },
)
submissionRoutes.post("/code/format", requireAuth, async (c) => { submissionRoutes.post("/code/format", requireAuth, async (c) => {
const parsed = formatCodeRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = formatCodeRequestSchema.safeParse(
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload") await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid format payload")
try { try {
const code = await formatCode(parsed.data.code, parsed.data.language) const code = await formatCode(parsed.data.code, parsed.data.language)
return success(c, { code } satisfies FormatCodeResponse) return success(c, { code } satisfies FormatCodeResponse)
} catch (error) { } catch (error) {
if (error instanceof CodeFormatError) { if (error instanceof CodeFormatError) {
return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message) return failure(
c,
error.kind === "syntax" ? 400 : 500,
error.kind === "syntax" ? "format-error" : "format-tool-error",
error.message,
)
} }
throw error throw error
} }
@@ -831,15 +965,26 @@ async function problemSetJoinTimes(userId: number, problemIds: number[]) {
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`, joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
}) })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId)) .innerJoin(
.innerJoin(schema.problemsetProblem, eq(schema.problemsetProblem.problemsetId, schema.problemset.id)) schema.problemset,
.where(and( eq(schema.problemset.id, schema.problemsetProgress.problemsetId),
)
.innerJoin(
schema.problemsetProblem,
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
)
.where(
and(
eq(schema.problemsetProgress.userId, userId), eq(schema.problemsetProgress.userId, userId),
inArray(schema.problemsetProblem.problemId, problemIds), inArray(schema.problemsetProblem.problemId, problemIds),
eq(schema.problemset.status, "active"), eq(schema.problemset.status, "active"),
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)), or(
isNull(schema.problemset.endTime),
gt(schema.problemset.endTime, sql`now()`),
),
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`, sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
)) ),
)
.groupBy(schema.problemsetProblem.problemId) .groupBy(schema.problemsetProblem.problemId)
for (const row of rows) joinTimes.set(row.problemId, row.joinTime) for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
return joinTimes return joinTimes
@@ -859,7 +1004,11 @@ function canViewSubmission(
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。 // `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。
if (row.userId === user.id && !isAdminRole(user)) { if (row.userId === user.id && !isAdminRole(user)) {
const joinTime = problemSetJoinTime?.get(row.problemId) const joinTime = problemSetJoinTime?.get(row.problemId)
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false if (
joinTime !== undefined &&
Date.parse(row.createTime) < Date.parse(joinTime)
)
return false
} }
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里 // 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里
// contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。 // contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。
@@ -868,8 +1017,13 @@ function canViewSubmission(
// //
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人, // 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
// 他早就知道答案了,挡他没有意义。 // 他早就知道答案了,挡他没有意义。
const elevated = isAdminRole(user) const elevated =
&& !(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin") isAdminRole(user) &&
!(
contest &&
contestStatus(contest) !== "-1" &&
user.adminType === "Student Admin"
)
// 这三条就是全部:别人的代码谁都看不到,比赛内外一样。 // 这三条就是全部:别人的代码谁都看不到,比赛内外一样。
// 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉, // 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉,
// 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条 // 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条
@@ -918,26 +1072,51 @@ const submissionListColumns = {
* data submissionCaseResults * data submissionCaseResults
*/ */
function caseSummary(submission: typeof schema.submission.$inferSelect) { function caseSummary(submission: typeof schema.submission.$inferSelect) {
if (submission.contestId !== null || submission.language === "SQL") return null if (submission.contestId !== null || submission.language === "SQL")
return null
const data = objectValue(submission.info).data const data = objectValue(submission.info).data
if (!Array.isArray(data) || data.length === 0) return null if (!Array.isArray(data) || data.length === 0) return null
const passed = data.filter((item) => objectValue(item).result === JudgeStatus.ACCEPTED).length const passed = data.filter(
(item) => objectValue(item).result === JudgeStatus.ACCEPTED,
).length
return { passed, total: data.length } return { passed, total: data.length }
} }
async function submissionDetail(id: string, user: AuthUser) { async function submissionDetail(id: string, user: AuthUser) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest }) const [row] = await db
.select({
submission: schema.submission,
problem: schema.problem,
contest: schema.contest,
})
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .innerJoin(
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id)) schema.problem,
.where(eq(schema.submission.id, id)).limit(1) eq(schema.submission.problemId, schema.problem.id),
)
.leftJoin(
schema.contest,
eq(schema.submission.contestId, schema.contest.id),
)
.where(eq(schema.submission.id, id))
.limit(1)
if (!row) return null if (!row) return null
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`views/oj.py:103 // 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`views/oj.py:103
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const joinTimes = isAdminRole(user) || row.submission.userId !== user.id const joinTimes =
isAdminRole(user) || row.submission.userId !== user.id
? undefined ? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId]) : await problemSetJoinTimes(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, row.contest, joinTimes)) return null if (
!canViewSubmission(
user,
row.submission,
row.problem,
row.contest,
joinTimes,
)
)
return null
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
@@ -995,12 +1174,18 @@ async function paginateSubmissionRows(
offset: number, offset: number,
byUsername: boolean, byUsername: boolean,
) { ) {
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const const order = [
desc(schema.submission.createTime),
desc(schema.submission.id),
] as const
const page = (condition: SQL | undefined) => const page = (condition: SQL | undefined) =>
db db
.select(submissionListColumns) .select(submissionListColumns)
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
// 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行, // 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行,
// inner join 会把他们的提交整条从列表里抹掉 // inner join 会把他们的提交整条从列表里抹掉
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
@@ -1010,7 +1195,10 @@ async function paginateSubmissionRows(
if (byUsername) { if (byUsername) {
const matched = db const matched = db
.select({ id: schema.submission.id, createTime: schema.submission.createTime }) .select({
id: schema.submission.id,
createTime: schema.submission.createTime,
})
.from(schema.submission) .from(schema.submission)
.where(where) .where(where)
return page(sql`${schema.submission.id} in ( return page(sql`${schema.submission.id} in (
@@ -1021,7 +1209,10 @@ async function paginateSubmissionRows(
if (offset === 0) return page(where) if (offset === 0) return page(where)
const [boundary] = await db const [boundary] = await db
.select({ createTime: schema.submission.createTime, id: schema.submission.id }) .select({
createTime: schema.submission.createTime,
id: schema.submission.id,
})
.from(schema.submission) .from(schema.submission)
.where(where) .where(where)
.orderBy(...order) .orderBy(...order)
@@ -1030,10 +1221,12 @@ async function paginateSubmissionRows(
// offset 越过了结果集尾巴,这一页本来就该是空的 // offset 越过了结果集尾巴,这一页本来就该是空的
if (!boundary) return [] if (!boundary) return []
return page(and( return page(
and(
where, where,
sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`, sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`,
)) ),
)
} }
/** /**
@@ -1043,7 +1236,8 @@ async function paginateSubmissionRows(
async function problemsetTitleMap(ids: Array<number | null>) { async function problemsetTitleMap(ids: Array<number | null>) {
const unique = [...new Set(ids.filter((id): id is number => id !== null))] const unique = [...new Set(ids.filter((id): id is number => id !== null))]
if (unique.length === 0) return new Map<number, string>() if (unique.length === 0) return new Map<number, string>()
const rows = await db.select({ id: schema.problemset.id, title: schema.problemset.title }) const rows = await db
.select({ id: schema.problemset.id, title: schema.problemset.title })
.from(schema.problemset) .from(schema.problemset)
.where(inArray(schema.problemset.id, unique)) .where(inArray(schema.problemset.id, unique))
return new Map(rows.map((row) => [row.id, row.title])) return new Map(rows.map((row) => [row.id, row.title]))
@@ -1055,7 +1249,10 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const user = c.get("user") const user = c.get("user")
// 「非管理员即受限」,不能写成「是普通用户才受限」—— // 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。 // 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { if (
!(await getBooleanOption("submission_list_show_all", true)) &&
!isAdminRole(user)
) {
return success(c, { results: [], total: 0 } satisfies SubmissionList) return success(c, { results: [], total: 0 } satisfies SubmissionList)
} }
const displayId = c.req.query("problemId")?.trim() const displayId = c.req.query("problemId")?.trim()
@@ -1065,14 +1262,19 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const result = c.req.query("result") const result = c.req.query("result")
const language = c.req.query("language")?.trim() const language = c.req.query("language")?.trim()
const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)] const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)]
filters.push(...await Promise.all([ filters.push(
...(await Promise.all([
displayId ? problemFilter(displayId, null) : undefined, displayId ? problemFilter(displayId, null) : undefined,
username ? usernameFilter(username) : undefined, username ? usernameFilter(username) : undefined,
])) ])),
)
if (myself) filters.push(eq(schema.submission.userId, myself.id)) if (myself) filters.push(eq(schema.submission.userId, myself.id))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result)))) if (result !== undefined && result !== "" && Number.isInteger(Number(result)))
if (language) filters.push(eq(schema.submission.language, asFilterValue(language))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`) if (language)
filters.push(eq(schema.submission.language, asFilterValue(language)))
if (c.req.query("today") === "1")
filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters) const where = and(...filters)
// count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan // count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。 // (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
@@ -1083,19 +1285,27 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([ const [joinTimes, problemsetTitles] = await Promise.all([
user && !isAdminRole(user) user && !isAdminRole(user)
? problemSetJoinTimes(user.id, [...new Set( ? problemSetJoinTimes(user.id, [
rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId), ...new Set(
)]) rows
.filter((row) => row.submission.userId === user.id)
.map((row) => row.submission.problemId),
),
])
: undefined, : undefined,
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 // 来源题单的标题。一页里不同题单最多几个,按主键查一次就够
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
]) ])
return success(c, { return success(c, {
results: rows.map(({ submission, problem }) => ({ results: rows.map(
({ submission, problem }) =>
({
id: submission.id, id: submission.id,
problem: problem.displayId, problem: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, null, joinTimes) : false, showLink: user
? canViewSubmission(user, submission, problem, null, joinTimes)
: false,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,
@@ -1103,15 +1313,25 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
language: submission.language, language: submission.language,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: objectValue(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 // 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) problemSet:
? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! } submission.problemsetId !== null &&
problemsetTitles.has(submission.problemsetId)
? {
id: submission.problemsetId,
title: problemsetTitles.get(submission.problemsetId)!,
}
: null, : null,
} satisfies SubmissionListItem)), }) satisfies SubmissionListItem,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies SubmissionList) } satisfies SubmissionList)
}) })
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => { submissionRoutes.get(
"/contests/:contestId/submissions",
optionalAuth,
requireContestAccess("submissions", "contestId"),
async (c) => {
const contest = c.get("contest")! const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
@@ -1120,34 +1340,56 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
const myself = c.req.query("myself") === "1" ? user : null const myself = c.req.query("myself") === "1" ? user : null
const username = myself ? undefined : c.req.query("username")?.trim() const username = myself ? undefined : c.req.query("username")?.trim()
const result = c.req.query("result") const result = c.req.query("result")
const filters: Array<SQL | undefined> = [eq(schema.submission.contestId, contest.id)] const filters: Array<SQL | undefined> = [
filters.push(...await Promise.all([ eq(schema.submission.contestId, contest.id),
]
filters.push(
...(await Promise.all([
displayId ? problemFilter(displayId, contest.id) : undefined, displayId ? problemFilter(displayId, contest.id) : undefined,
username ? usernameFilter(username) : undefined, username ? usernameFilter(username) : undefined,
])) ])),
)
if (myself) filters.push(eq(schema.submission.userId, myself.id)) if (myself) filters.push(eq(schema.submission.userId, myself.id))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result)))) if (
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`) result !== undefined &&
result !== "" &&
Number.isInteger(Number(result))
)
filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (contestStatus(contest) !== "1")
filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
const where = and(...filters) const where = and(...filters)
// 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵, // 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵,
// 所以不像公开列表那样分游标 / 圈选两条路 // 所以不像公开列表那样分游标 / 圈选两条路
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.submission).where(where), db.select({ value: count() }).from(schema.submission).where(where),
db.select(submissionListColumns).from(schema.submission) db
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .select(submissionListColumns)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)).where(where) .from(schema.submission)
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset), .innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
.orderBy(desc(schema.submission.createTime))
.limit(limit)
.offset(offset),
]) ])
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了 // 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
// `isNull(problem.contestId)`admin/problemset.ts:232)——而这条列表只出比赛提交, // `isNull(problem.contestId)`admin/problemset.ts:232)——而这条列表只出比赛提交,
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
return success(c, { return success(c, {
results: rows.map(({ submission, problem }) => ({ results: rows.map(
({ submission, problem }) =>
({
id: submission.id, id: submission.id,
problem: problem.displayId, problem: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, contest) : false, showLink: user
? canViewSubmission(user, submission, problem, contest)
: false,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,
@@ -1157,10 +1399,12 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null, problemSet: null,
} satisfies SubmissionListItem)), }) satisfies SubmissionListItem,
),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies SubmissionList) } satisfies SubmissionList)
}) },
)
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => { submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
+6 -2
View File
@@ -31,7 +31,9 @@ export function selfCommand(subcommand: string): string[] {
* docker/compose.dev.yml **** data/test_case cwd * docker/compose.dev.yml **** data/test_case cwd
* apps/api/data/ * apps/api/data/
*/ */
export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, "../../..") export const pathBase = isCompiled
? process.cwd()
: resolve(import.meta.dir, "../../..")
/** /**
* `0000_*.sql` + `meta/_journal.json` * `0000_*.sql` + `meta/_journal.json`
@@ -48,4 +50,6 @@ export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, ".
*/ */
export const migrationsDir = export const migrationsDir =
process.env.OJ2_MIGRATIONS_DIR ?? process.env.OJ2_MIGRATIONS_DIR ??
(isCompiled ? "/usr/local/share/oj2/migrations" : resolve(import.meta.dir, "db")) (isCompiled
? "/usr/local/share/oj2/migrations"
: resolve(import.meta.dir, "db"))
+14 -5
View File
@@ -42,8 +42,12 @@ const missing: Array<{ language: string; target: string; node: string }> = []
for (const [language, table] of Object.entries(AST_NODE_TARGETS_BY_LANGUAGE)) { for (const [language, table] of Object.entries(AST_NODE_TARGETS_BY_LANGUAGE)) {
const wasmPath = WASM_BY_LANGUAGE[language] const wasmPath = WASM_BY_LANGUAGE[language]
if (!wasmPath) { if (!wasmPath) {
console.log(`${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`) console.log(
console.log(` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`) `${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`,
)
console.log(
` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`,
)
process.exit(2) process.exit(2)
} }
const loaded = await Language.load(wasmPath) const loaded = await Language.load(wasmPath)
@@ -55,7 +59,8 @@ for (const [language, table] of Object.entries(AST_NODE_TARGETS_BY_LANGUAGE)) {
} }
for (const [target, entry] of Object.entries(table)) { for (const [target, entry] of Object.entries(table)) {
checked++ checked++
if (!declared.has(entry.node)) missing.push({ language, target, node: entry.node }) if (!declared.has(entry.node))
missing.push({ language, target, node: entry.node })
} }
} }
@@ -66,7 +71,11 @@ if (missing.length === 0) {
} }
for (const { language, target, node } of missing) { for (const { language, target, node } of missing) {
console.log(`\n⚠ ${language}${target} → "${node}"`) console.log(`\n⚠ ${language}${target} → "${node}"`)
console.log(` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`) console.log(
console.log(` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`) ` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`,
)
console.log(
` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`,
)
} }
process.exit(1) process.exit(1)
+18 -6
View File
@@ -63,7 +63,9 @@ export function shadows(pattern: string, target: string) {
function collect(): Route[] { function collect(): Route[] {
const routerFile = new Map<string, string>() const routerFile = new Map<string, string>()
for (const file of walk(SRC)) { for (const file of walk(SRC)) {
for (const m of readFileSync(file, "utf8").matchAll(/export const (\w+) = new Hono/g)) { for (const m of readFileSync(file, "utf8").matchAll(
/export const (\w+) = new Hono/g,
)) {
routerFile.set(m[1]!, file) routerFile.set(m[1]!, file)
} }
} }
@@ -72,7 +74,10 @@ function collect(): Route[] {
const file = routerFile.get(router) const file = routerFile.get(router)
if (!file) return [] if (!file) return []
const text = readFileSync(file, "utf8") const text = readFileSync(file, "utf8")
const pattern = new RegExp(`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, "g") const pattern = new RegExp(
`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`,
"g",
)
return [...text.matchAll(pattern)].map((m) => ({ return [...text.matchAll(pattern)].map((m) => ({
method: m[1]!.toUpperCase(), method: m[1]!.toUpperCase(),
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/", path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
@@ -83,10 +88,14 @@ function collect(): Route[] {
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平 // 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
const index = readFileSync(join(SRC, "index.ts"), "utf8") const index = readFileSync(join(SRC, "index.ts"), "utf8")
const adminIndex = readFileSync(join(SRC, "routes/admin/index.ts"), "utf8") const adminIndex = readFileSync(join(SRC, "routes/admin/index.ts"), "utf8")
const adminMounts = [...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g)] const adminMounts = [
...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g),
]
const all: Route[] = [] const all: Route[] = []
for (const m of index.matchAll(/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g)) { for (const m of index.matchAll(
/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g,
)) {
const [, prefix, router] = m const [, prefix, router] = m
if (router === "adminRoutes") { if (router === "adminRoutes") {
for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!)) for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!))
@@ -102,7 +111,8 @@ const hits: [Route, Route][] = []
for (let i = 0; i < routes.length; i++) { for (let i = 0; i < routes.length; i++) {
for (let j = i + 1; j < routes.length; j++) { for (let j = i + 1; j < routes.length; j++) {
if (routes[i]!.method !== routes[j]!.method) continue if (routes[i]!.method !== routes[j]!.method) continue
if (shadows(routes[i]!.path, routes[j]!.path)) hits.push([routes[i]!, routes[j]!]) if (shadows(routes[i]!.path, routes[j]!.path))
hits.push([routes[i]!, routes[j]!])
} }
} }
@@ -113,7 +123,9 @@ if (hits.length === 0) {
} }
for (const [first, second] of hits) { for (const [first, second] of hits) {
console.log(`\n⚠ ${second.method} ${second.path} ${second.file}`) console.log(`\n⚠ ${second.method} ${second.path} ${second.file}`)
console.log(` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file}`) console.log(
` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file}`,
)
console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`) console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
} }
process.exit(1) process.exit(1)
+139 -36
View File
@@ -3,7 +3,11 @@ import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
import { JudgeStatus, isAccepted } from "../judge/status" import { JudgeStatus, isAccepted } from "../judge/status"
import { objectValue } from "../routes/helpers" import { objectValue } from "../routes/helpers"
import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../services/achievements" import {
metaAchievements,
refreshUnlockedCount,
rescanAchievement,
} from "../services/achievements"
/** /**
* submission * submission
@@ -50,7 +54,11 @@ type ProblemExpected = {
* contestId user_profile * contestId user_profile
*/ */
async function expectedProblems() { async function expectedProblems() {
const rows = await db.execute<{ problem_id: number; result: number; n: number }>(sql` const rows = await db.execute<{
problem_id: number
result: number
n: number
}>(sql`
select problem_id, result, count(*)::int as n select problem_id, result, count(*)::int as n
from submission from submission
where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]}) where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]})
@@ -93,7 +101,11 @@ type ProfileExpected = {
* create_time * create_time
*/ */
async function expectedProfiles() { async function expectedProfiles() {
const totals = await db.execute<{ user_id: number; submissions: number; accepted: number }>(sql` const totals = await db.execute<{
user_id: number
submissions: number
accepted: number
}>(sql`
select user_id, select user_id,
count(*)::int as submissions, count(*)::int as submissions,
count(distinct problem_id) filter (where result in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED}))::int as accepted count(distinct problem_id) filter (where result in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED}))::int as accepted
@@ -122,7 +134,11 @@ async function expectedProfiles() {
`) `)
const expected = new Map<number, ProfileExpected>() const expected = new Map<number, ProfileExpected>()
const blank = (): ProfileExpected => ({ submissionNumber: 0, acceptedNumber: 0, status: {} }) const blank = (): ProfileExpected => ({
submissionNumber: 0,
acceptedNumber: 0,
status: {},
})
for (const row of totals) { for (const row of totals) {
const current = expected.get(row.user_id) ?? blank() const current = expected.get(row.user_id) ?? blank()
current.submissionNumber = row.submissions current.submissionNumber = row.submissions
@@ -146,7 +162,9 @@ async function expectedProfiles() {
function stable(value: unknown): string { function stable(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]` if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
if (value && typeof value === "object") { if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1)) const entries = Object.entries(value as Record<string, unknown>).sort(
([a], [b]) => (a < b ? -1 : 1),
)
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}` return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`
} }
return JSON.stringify(value) ?? "null" return JSON.stringify(value) ?? "null"
@@ -156,7 +174,10 @@ type Diff = { label: string; field: string; before: unknown; after: unknown }
type Plan = { type Plan = {
diffs: Diff[] diffs: Diff[]
problemFixes: { id: number; value: ProblemExpected }[] problemFixes: { id: number; value: ProblemExpected }[]
profileFixes: { id: number; value: ProfileExpected & { merged: Record<string, unknown> } }[] profileFixes: {
id: number
value: ProfileExpected & { merged: Record<string, unknown> }
}[]
/** achievement_unlocked_count 不对的用户 */ /** achievement_unlocked_count 不对的用户 */
unlockedCountFixes: number[] unlockedCountFixes: number[]
/** 按正确计数已达标、却没持有元成就的 (用户, 元成就) */ /** 按正确计数已达标、却没持有元成就的 (用户, 元成就) */
@@ -183,22 +204,43 @@ async function unlockedCountPlan(plan: Plan) {
metaAchievements(), metaAchievements(),
]) ])
const holders = metas.length const holders = metas.length
? await db.select({ userId: schema.userAchievement.userId, achievementId: schema.userAchievement.achievementId }) ? await db
.select({
userId: schema.userAchievement.userId,
achievementId: schema.userAchievement.achievementId,
})
.from(schema.userAchievement) .from(schema.userAchievement)
.where(sql`${schema.userAchievement.achievementId} in ${metas.map((meta) => meta.id)}`) .where(
sql`${schema.userAchievement.achievementId} in ${metas.map((meta) => meta.id)}`,
)
: [] : []
const held = new Set(holders.map((row) => `${row.userId}:${row.achievementId}`)) const held = new Set(
holders.map((row) => `${row.userId}:${row.achievementId}`),
)
for (const row of rows) { for (const row of rows) {
const label = `用户 ${row.user_id}` const label = `用户 ${row.user_id}`
if (row.counter !== row.actual) { if (row.counter !== row.actual) {
plan.diffs.push({ label, field: "achievement_unlocked_count", before: row.counter ?? null, after: row.actual }) plan.diffs.push({
label,
field: "achievement_unlocked_count",
before: row.counter ?? null,
after: row.actual,
})
plan.unlockedCountFixes.push(row.user_id) plan.unlockedCountFixes.push(row.user_id)
} }
for (const meta of metas) { for (const meta of metas) {
const met = meta.operator === "gte" ? row.actual >= meta.threshold : row.actual <= meta.threshold const met =
meta.operator === "gte"
? row.actual >= meta.threshold
: row.actual <= meta.threshold
if (!met || held.has(`${row.user_id}:${meta.id}`)) continue if (!met || held.has(`${row.user_id}:${meta.id}`)) continue
plan.diffs.push({ label, field: `成就「${meta.name}`, before: "未发", after: "补发" }) plan.diffs.push({
label,
field: `成就「${meta.name}`,
before: "未发",
after: "补发",
})
plan.metaGrants.push({ userId: row.user_id, achievementId: meta.id }) plan.metaGrants.push({ userId: row.user_id, achievementId: meta.id })
} }
} }
@@ -206,26 +248,37 @@ async function unlockedCountPlan(plan: Plan) {
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */ /** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
async function computePlan(): Promise<Plan> { async function computePlan(): Promise<Plan> {
const [problems, profiles, expectedProblem, expectedProfile] = await Promise.all([ const [problems, profiles, expectedProblem, expectedProfile] =
db.select({ await Promise.all([
db
.select({
id: schema.problem.id, id: schema.problem.id,
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
submissionNumber: schema.problem.submissionNumber, submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber, acceptedNumber: schema.problem.acceptedNumber,
statisticInfo: schema.problem.statisticInfo, statisticInfo: schema.problem.statisticInfo,
}).from(schema.problem), })
db.select({ .from(schema.problem),
db
.select({
id: schema.userProfile.id, id: schema.userProfile.id,
userId: schema.userProfile.userId, userId: schema.userProfile.userId,
submissionNumber: schema.userProfile.submissionNumber, submissionNumber: schema.userProfile.submissionNumber,
acceptedNumber: schema.userProfile.acceptedNumber, acceptedNumber: schema.userProfile.acceptedNumber,
acmProblemsStatus: schema.userProfile.acmProblemsStatus, acmProblemsStatus: schema.userProfile.acmProblemsStatus,
}).from(schema.userProfile), })
.from(schema.userProfile),
expectedProblems(), expectedProblems(),
expectedProfiles(), expectedProfiles(),
]) ])
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [], unlockedCountFixes: [], metaGrants: [] } const plan: Plan = {
diffs: [],
problemFixes: [],
profileFixes: [],
unlockedCountFixes: [],
metaGrants: [],
}
for (const problem of problems) { for (const problem of problems) {
const want = expectedProblem.get(problem.id) ?? { const want = expectedProblem.get(problem.id) ?? {
@@ -236,13 +289,30 @@ async function computePlan(): Promise<Plan> {
const label = `题目 ${problem.displayId}(id=${problem.id})` const label = `题目 ${problem.displayId}(id=${problem.id})`
const rows: Diff[] = [] const rows: Diff[] = []
if (problem.submissionNumber !== want.submissionNumber) { if (problem.submissionNumber !== want.submissionNumber) {
rows.push({ label, field: "submission_number", before: problem.submissionNumber, after: want.submissionNumber }) rows.push({
label,
field: "submission_number",
before: problem.submissionNumber,
after: want.submissionNumber,
})
} }
if (problem.acceptedNumber !== want.acceptedNumber) { if (problem.acceptedNumber !== want.acceptedNumber) {
rows.push({ label, field: "accepted_number", before: problem.acceptedNumber, after: want.acceptedNumber }) rows.push({
label,
field: "accepted_number",
before: problem.acceptedNumber,
after: want.acceptedNumber,
})
} }
if (stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo)) { if (
rows.push({ label, field: "statistic_info", before: problem.statisticInfo, after: want.statisticInfo }) stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo)
) {
rows.push({
label,
field: "statistic_info",
before: problem.statisticInfo,
after: want.statisticInfo,
})
} }
if (rows.length) { if (rows.length) {
plan.diffs.push(...rows) plan.diffs.push(...rows)
@@ -262,19 +332,38 @@ async function computePlan(): Promise<Plan> {
const merged: Record<string, unknown> = { ...existing } const merged: Record<string, unknown> = { ...existing }
delete merged.problems delete merged.problems
delete merged.contest_problems delete merged.contest_problems
for (const [bucket, value] of Object.entries(want.status)) merged[bucket] = value for (const [bucket, value] of Object.entries(want.status))
merged[bucket] = value
const label = `用户 ${profile.userId}` const label = `用户 ${profile.userId}`
const rows: Diff[] = [] const rows: Diff[] = []
if (profile.submissionNumber !== want.submissionNumber) { if (profile.submissionNumber !== want.submissionNumber) {
rows.push({ label, field: "submission_number", before: profile.submissionNumber, after: want.submissionNumber }) rows.push({
label,
field: "submission_number",
before: profile.submissionNumber,
after: want.submissionNumber,
})
} }
if (profile.acceptedNumber !== want.acceptedNumber) { if (profile.acceptedNumber !== want.acceptedNumber) {
rows.push({ label, field: "accepted_number", before: profile.acceptedNumber, after: want.acceptedNumber }) rows.push({
label,
field: "accepted_number",
before: profile.acceptedNumber,
after: want.acceptedNumber,
})
} }
if (stable(existing) !== stable(merged)) { if (stable(existing) !== stable(merged)) {
const keys = new Set([...Object.keys(objectValue(existing.problems)), ...Object.keys(want.status.problems ?? {})]) const keys = new Set([
rows.push({ label, field: "acm_problems_status", before: `${Object.keys(objectValue(existing.problems)).length}`, after: `${keys.size} 题(含比赛桶重建)` }) ...Object.keys(objectValue(existing.problems)),
...Object.keys(want.status.problems ?? {}),
])
rows.push({
label,
field: "acm_problems_status",
before: `${Object.keys(objectValue(existing.problems)).length}`,
after: `${keys.size} 题(含比赛桶重建)`,
})
} }
if (rows.length) { if (rows.length) {
plan.diffs.push(...rows) plan.diffs.push(...rows)
@@ -286,11 +375,16 @@ async function computePlan(): Promise<Plan> {
} }
function report(plan: Plan) { function report(plan: Plan) {
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人 / 已解锁数 ${plan.unlockedCountFixes.length} 人 / 元成就补发 ${plan.metaGrants.length} 条):`) console.log(
`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人 / 已解锁数 ${plan.unlockedCountFixes.length} 人 / 元成就补发 ${plan.metaGrants.length} 条):`,
)
for (const diff of plan.diffs.slice(0, 40)) { for (const diff of plan.diffs.slice(0, 40)) {
console.log(` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`) console.log(
` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`,
)
} }
if (plan.diffs.length > 40) console.log(` ……另有 ${plan.diffs.length - 40}`) if (plan.diffs.length > 40)
console.log(` ……另有 ${plan.diffs.length - 40}`)
} }
/** 退出码:0 = 一致或预演正常,1 = 落库后复核仍有差异 */ /** 退出码:0 = 一致或预演正常,1 = 落库后复核仍有差异 */
@@ -309,27 +403,36 @@ export async function recount(options: { apply: boolean }) {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
for (const fix of plan.problemFixes) { for (const fix of plan.problemFixes) {
await tx.update(schema.problem).set({ await tx
.update(schema.problem)
.set({
submissionNumber: fix.value.submissionNumber, submissionNumber: fix.value.submissionNumber,
acceptedNumber: fix.value.acceptedNumber, acceptedNumber: fix.value.acceptedNumber,
statisticInfo: fix.value.statisticInfo, statisticInfo: fix.value.statisticInfo,
}).where(eq(schema.problem.id, fix.id)) })
.where(eq(schema.problem.id, fix.id))
} }
for (const fix of plan.profileFixes) { for (const fix of plan.profileFixes) {
await tx.update(schema.userProfile).set({ await tx
.update(schema.userProfile)
.set({
submissionNumber: fix.value.submissionNumber, submissionNumber: fix.value.submissionNumber,
acceptedNumber: fix.value.acceptedNumber, acceptedNumber: fix.value.acceptedNumber,
acmProblemsStatus: fix.value.merged, acmProblemsStatus: fix.value.merged,
}).where(eq(schema.userProfile.id, fix.id)) })
.where(eq(schema.userProfile.id, fix.id))
} }
}) })
// 先改计数、再补发:rescanAchievement 读的是 metrics 里的计数。 // 先改计数、再补发:rescanAchievement 读的是 metrics 里的计数。
// 补发幂等(唯一键 + 冲突忽略),重跑不会重复发 // 补发幂等(唯一键 + 冲突忽略),重跑不会重复发
const recounted = await refreshUnlockedCount(plan.unlockedCountFixes) const recounted = await refreshUnlockedCount(plan.unlockedCountFixes)
if (plan.metaGrants.length) { if (plan.metaGrants.length) {
for (const meta of await metaAchievements()) await rescanAchievement(meta.id) for (const meta of await metaAchievements())
await rescanAchievement(meta.id)
} }
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人、已解锁数 ${recounted.length} 人,补发元成就 ${plan.metaGrants.length} 条,复核中……`) console.log(
`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人、已解锁数 ${recounted.length} 人,补发元成就 ${plan.metaGrants.length} 条,复核中……`,
)
// 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题(不是数据脏), // 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题(不是数据脏),
// 必须让部署脚本看见非零退出码,而不是打一行字了事。 // 必须让部署脚本看见非零退出码,而不是打一行字了事。
+6 -2
View File
@@ -8,7 +8,9 @@ import { db, schema } from "../db"
* raw_password * raw_password
* DATABASE_URL OJ2_SEED_FORCE=true * DATABASE_URL OJ2_SEED_FORCE=true
*/ */
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge" const url =
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const host = (() => { const host = (() => {
try { try {
return new URL(url).hostname return new URL(url).hostname
@@ -93,7 +95,9 @@ async function seed(account: SeedAccount) {
}) })
} }
console.log(` ${account.adminType.padEnd(13)} ${user.username} / ${account.password}`) console.log(
` ${account.adminType.padEnd(13)} ${user.username} / ${account.password}`,
)
} }
console.log("Seeded development logins:") console.log("Seeded development logins:")
+76 -16
View File
@@ -14,24 +14,85 @@ export interface AchievementMetric {
} }
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" }, {
{ key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" }, key: "accepted_count",
{ key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" }, name: "AC 题目数",
{ key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" }, helpText: "去重后通过的题目数量(不含比赛)",
},
{
key: "mid_ac_count",
name: "中等题 AC 数",
helpText: "去重后通过的中等难度题目数(不含比赛)",
},
{
key: "hard_ac_count",
name: "困难题 AC 数",
helpText: "去重后通过的困难题目数(不含比赛)",
},
{
key: "submission_count",
name: "提交总数",
helpText: "提交次数(不含比赛)",
},
{ key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" }, { key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" },
{ key: "max_ac_streak_days", name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数" }, {
key: "max_ac_streak_days",
name: "最长连续 AC 天数",
helpText: "连续每天至少 AC 一题的最长天数",
},
{ key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" }, { key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
{ key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" }, {
key: "contest_joined",
name: "参赛场次",
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
},
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" }, { key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
{ key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" }, {
{ key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" }, key: "problemset_completed",
{ key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:005:00 之间的提交次数" }, name: "完成题单数",
{ key: "early_bird_submissions", name: "早起提交次数", helpText: "5:007:00 之间的提交次数" }, helpText: "完成的题单数量",
{ key: "compile_error_count", name: "编译错误次数", helpText: "累计编译错误的次数" }, },
{ key: "max_wa_before_ac", name: "屡败屡战", helpText: "单题失败最多多少次后终于通过" }, {
{ key: "max_ac_in_one_day", name: "单日最多 AC", helpText: "一天之内最多通过多少题" }, key: "first_try_ac_count",
{ key: "max_code_lines", name: "最长代码行数", helpText: "提交过的最长代码有多少行" }, name: "一发入魂次数",
{ key: "achievement_unlocked_count", name: "已解锁成就数", helpText: "已解锁的成就数量(不含白金档)", meta: true }, helpText: "首次提交即通过的次数",
},
{
key: "midnight_submissions",
name: "凌晨提交次数",
helpText: "0:005:00 之间的提交次数",
},
{
key: "early_bird_submissions",
name: "早起提交次数",
helpText: "5:007:00 之间的提交次数",
},
{
key: "compile_error_count",
name: "编译错误次数",
helpText: "累计编译错误的次数",
},
{
key: "max_wa_before_ac",
name: "屡败屡战",
helpText: "单题失败最多多少次后终于通过",
},
{
key: "max_ac_in_one_day",
name: "单日最多 AC",
helpText: "一天之内最多通过多少题",
},
{
key: "max_code_lines",
name: "最长代码行数",
helpText: "提交过的最长代码有多少行",
},
{
key: "achievement_unlocked_count",
name: "已解锁成就数",
helpText: "已解锁的成就数量(不含白金档)",
meta: true,
},
] ]
const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item])) const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item]))
@@ -43,4 +104,3 @@ export function findMetric(key: string) {
export function metricName(key: string) { export function metricName(key: string) {
return BY_KEY.get(key)?.name ?? key return BY_KEY.get(key)?.name ?? key
} }
+268 -73
View File
@@ -1,4 +1,15 @@
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, ne, notInArray, sql } from "drizzle-orm" import {
and,
count,
countDistinct,
eq,
inArray,
isNotNull,
isNull,
ne,
notInArray,
sql,
} from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events" import { publishAchievementNotification } from "../events"
@@ -12,49 +23,90 @@ function numberMetric(metrics: Record<string, unknown>, key: string) {
return typeof value === "number" ? value : 0 return typeof value === "number" ? value : 0
} }
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) { async function unlockAchievements(
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement) userId: number,
metrics: Record<string, unknown>,
onlyMeta = false,
) {
const unlocked = await db
.select({ id: schema.userAchievement.achievementId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, userId)) .where(eq(schema.userAchievement.userId, userId))
const filters = [eq(schema.achievement.visible, true)] const filters = [eq(schema.achievement.visible, true)]
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id))) if (unlocked.length)
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count")) filters.push(
notInArray(
schema.achievement.id,
unlocked.map((row) => row.id),
),
)
if (onlyMeta)
filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count")) else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
const candidates = await db.select().from(schema.achievement).where(and(...filters)) const candidates = await db
.select()
.from(schema.achievement)
.where(and(...filters))
const hits = candidates.filter((achievement) => { const hits = candidates.filter((achievement) => {
const value = metrics[achievement.metric] const value = metrics[achievement.metric]
if (typeof value !== "number") return false if (typeof value !== "number") return false
return achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold return achievement.operator === "gte"
? value >= achievement.threshold
: value <= achievement.threshold
}) })
if (hits.length === 0) return [] if (hits.length === 0) return []
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。 // 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。 // 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
const inserted = await db.insert(schema.userAchievement).values(hits.map((achievement) => ({ const inserted = await db
.insert(schema.userAchievement)
.values(
hits.map((achievement) => ({
userId, userId,
achievementId: achievement.id, achievementId: achievement.id,
unlockTime: new Date().toISOString(), unlockTime: new Date().toISOString(),
backfilled: false, backfilled: false,
notified: false, notified: false,
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }) })),
)
.onConflictDoNothing({
target: [
schema.userAchievement.achievementId,
schema.userAchievement.userId,
],
})
.returning({ achievementId: schema.userAchievement.achievementId }) .returning({ achievementId: schema.userAchievement.achievementId })
if (inserted.length === 0) return [] if (inserted.length === 0) return []
const insertedIds = new Set(inserted.map((row) => row.achievementId)) const insertedIds = new Set(inserted.map((row) => row.achievementId))
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` }) await db
.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
.where(inArray(schema.achievement.id, [...insertedIds])) .where(inArray(schema.achievement.id, [...insertedIds]))
return hits.filter((achievement) => insertedIds.has(achievement.id)) return hits.filter((achievement) => insertedIds.has(achievement.id))
} }
export async function updateAchievementsForSubmission(submissionId: string) { export async function updateAchievementsForSubmission(submissionId: string) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission) const [row] = await db
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .select({ submission: schema.submission, problem: schema.problem })
.where(eq(schema.submission.id, submissionId)).limit(1) .from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(eq(schema.submission.id, submissionId))
.limit(1)
if (!row || row.submission.contestId !== null) return [] if (!row || row.submission.contestId !== null) return []
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and( const priorRows = await db
.select({ result: schema.submission.result })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, row.submission.userId), eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId), eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
ne(schema.submission.id, row.submission.id), ne(schema.submission.id, row.submission.id),
)) ),
)
const priorAccepted = priorRows.some((item) => isAccepted(item.result)) const priorAccepted = priorRows.some((item) => isAccepted(item.result))
const accepted = isAccepted(row.submission.result) const accepted = isAccepted(row.submission.result)
const firstAc = accepted && !priorAccepted const firstAc = accepted && !priorAccepted
@@ -63,97 +115,184 @@ export async function updateAchievementsForSubmission(submissionId: string) {
const hour = localHour(row.submission.createTime) const hour = localHour(row.submission.createTime)
const metrics = await db.transaction(async (tx) => { const metrics = await db.transaction(async (tx) => {
await tx.insert(schema.userStat).values({ await tx
.insert(schema.userStat)
.values({
userId: row.submission.userId, userId: row.submission.userId,
metrics: {}, metrics: {},
updateTime: new Date().toISOString(), updateTime: new Date().toISOString(),
}).onConflictDoNothing({ target: schema.userStat.userId }) })
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update") .onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, row.submission.userId))
.for("update")
if (!stat) throw new Error("User achievement stat could not be created") if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics) const value = objectValue(stat.metrics)
value.submission_count = numberMetric(value, "submission_count") + 1 value.submission_count = numberMetric(value, "submission_count") + 1
if (firstAc) { if (firstAc) {
value.accepted_count = numberMetric(value, "accepted_count") + 1 value.accepted_count = numberMetric(value, "accepted_count") + 1
if (row.problem.difficulty === "Mid") value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1 if (row.problem.difficulty === "Mid")
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1 value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1 if (row.problem.difficulty === "High")
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length) value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry)
value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(
numberMetric(value, "max_wa_before_ac"),
priorRows.length,
)
const perDay = objectValue(value._ac_per_day) const perDay = objectValue(value._ac_per_day)
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1 perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
value._ac_per_day = perDay value._ac_per_day = perDay
value.max_ac_in_one_day = Math.max(...Object.values(perDay).filter((item): item is number => typeof item === "number")) value.max_ac_in_one_day = Math.max(
...Object.values(perDay).filter(
(item): item is number => typeof item === "number",
),
)
} }
const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") : [] const activeDates = Array.isArray(value._active_dates)
? value._active_dates.filter(
(item): item is string => typeof item === "string",
)
: []
if (!activeDates.includes(date)) activeDates.push(date) if (!activeDates.includes(date)) activeDates.push(date)
value._active_dates = activeDates value._active_dates = activeDates
value.active_days = activeDates.length value.active_days = activeDates.length
if (accepted) { if (accepted) {
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null const last =
typeof value._last_ac_date === "string" ? value._last_ac_date : null
if (last !== date) { if (last !== date) {
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时, // 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。 // 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
const current = last && dayNumber(date) - dayNumber(last) === 1 const current =
last && dayNumber(date) - dayNumber(last) === 1
? numberMetric(value, "_current_ac_streak") + 1 ? numberMetric(value, "_current_ac_streak") + 1
: 1 : 1
value._last_ac_date = date value._last_ac_date = date
value._current_ac_streak = current value._current_ac_streak = current
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current) value.max_ac_streak_days = Math.max(
numberMetric(value, "max_ac_streak_days"),
current,
)
} }
} }
const languages = Array.isArray(value._languages) ? value._languages.filter((item): item is string => typeof item === "string") : [] const languages = Array.isArray(value._languages)
if (!languages.includes(row.submission.language)) languages.push(row.submission.language) ? value._languages.filter(
(item): item is string => typeof item === "string",
)
: []
if (!languages.includes(row.submission.language))
languages.push(row.submission.language)
value._languages = languages value._languages = languages
value.languages_used = languages.length value.languages_used = languages.length
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1 if (hour < 5)
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1 value.midnight_submissions =
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1 numberMetric(value, "midnight_submissions") + 1
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length) else if (hour < 7)
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id)) value.early_bird_submissions =
numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
value.max_code_lines = Math.max(
numberMetric(value, "max_code_lines"),
row.submission.code.split(/\r?\n/).length,
)
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value return value
}) })
const first = await unlockAchievements(row.submission.userId, metrics) const first = await unlockAchievements(row.submission.userId, metrics)
if (!first.length) return [] if (!first.length) return []
const [meta] = await db.select({ value: count() }).from(schema.userAchievement) const [meta] = await db
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id)) .select({ value: count() })
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum"))) .from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, row.submission.userId),
ne(schema.achievement.rarity, "platinum"),
),
)
metrics.achievement_unlocked_count = meta?.value ?? 0 metrics.achievement_unlocked_count = meta?.value ?? 0
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }).where(eq(schema.userStat.userId, row.submission.userId)) await db
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))] .update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, row.submission.userId))
return [
...first,
...(await unlockAchievements(row.submission.userId, metrics, true)),
]
} }
export async function updateAchievementsForProblemSet(userId: number) { export async function updateAchievementsForProblemSet(userId: number) {
const [[badgeRow], [completedRow]] = await Promise.all([ const [[badgeRow], [completedRow]] = await Promise.all([
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)), db
db.select({ value: count() }).from(schema.problemsetProgress).where(and( .select({ value: count() })
.from(schema.userBadge)
.where(eq(schema.userBadge.userId, userId)),
db
.select({ value: count() })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.userId, userId), eq(schema.problemsetProgress.userId, userId),
eq(schema.problemsetProgress.isCompleted, true), eq(schema.problemsetProgress.isCompleted, true),
)), ),
),
]) ])
const metrics = await db.transaction(async (tx) => { const metrics = await db.transaction(async (tx) => {
await tx.insert(schema.userStat).values({ await tx
.insert(schema.userStat)
.values({
userId, userId,
metrics: {}, metrics: {},
updateTime: new Date().toISOString(), updateTime: new Date().toISOString(),
}).onConflictDoNothing({ target: schema.userStat.userId }) })
const [stat] = await tx.select().from(schema.userStat) .onConflictDoNothing({ target: schema.userStat.userId })
.where(eq(schema.userStat.userId, userId)).for("update").limit(1) const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId))
.for("update")
.limit(1)
if (!stat) throw new Error("User achievement stat could not be created") if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics) const value = objectValue(stat.metrics)
value.badge_count = badgeRow?.value ?? 0 value.badge_count = badgeRow?.value ?? 0
value.problemset_completed = completedRow?.value ?? 0 value.problemset_completed = completedRow?.value ?? 0
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }) await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id)) .where(eq(schema.userStat.id, stat.id))
return value return value
}) })
const first = await unlockAchievements(userId, metrics) const first = await unlockAchievements(userId, metrics)
if (!first.length) return [] if (!first.length) return []
const [meta] = await db.select({ value: count() }).from(schema.userAchievement) const [meta] = await db
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id)) .select({ value: count() })
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum"))) .from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, userId),
ne(schema.achievement.rarity, "platinum"),
),
)
metrics.achievement_unlocked_count = meta?.value ?? 0 metrics.achievement_unlocked_count = meta?.value ?? 0
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }) await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, userId)) .where(eq(schema.userStat.userId, userId))
return [...first, ...(await unlockAchievements(userId, metrics, true))] return [...first, ...(await unlockAchievements(userId, metrics, true))]
} }
@@ -170,22 +309,39 @@ const USER_ACHIEVEMENT_INSERT_CHUNK = 1000
* *
*/ */
export async function rescanAchievement(achievementId: number) { export async function rescanAchievement(achievementId: number) {
const [achievement] = await db.select().from(schema.achievement) const [achievement] = await db
.where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true))).limit(1) .select()
.from(schema.achievement)
.where(
and(
eq(schema.achievement.id, achievementId),
eq(schema.achievement.visible, true),
),
)
.limit(1)
if (!achievement) return { scanned: 0, unlocked: 0 } if (!achievement) return { scanned: 0, unlocked: 0 }
const metric = findMetric(achievement.metric) const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 } if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) // contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
if (achievement.metric === "contest_joined") await refreshContestJoinedForAll() if (achievement.metric === "contest_joined")
await refreshContestJoinedForAll()
const already = new Set( const already = new Set(
(await db.select({ userId: schema.userAchievement.userId }).from(schema.userAchievement) (
.where(eq(schema.userAchievement.achievementId, achievement.id))).map((row) => row.userId), await db
.select({ userId: schema.userAchievement.userId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.achievementId, achievement.id))
).map((row) => row.userId),
) )
const stats = await db.select({ userId: schema.userStat.userId, metrics: schema.userStat.metrics }) const stats = await db
.select({
userId: schema.userStat.userId,
metrics: schema.userStat.metrics,
})
.from(schema.userStat) .from(schema.userStat)
const eligible = stats.filter((stat) => { const eligible = stats.filter((stat) => {
if (already.has(stat.userId)) return false if (already.has(stat.userId)) return false
@@ -201,39 +357,62 @@ export async function rescanAchievement(achievementId: number) {
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。 // 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
const unlockTime = new Date().toISOString() const unlockTime = new Date().toISOString()
const unlockedUserIds: number[] = [] const unlockedUserIds: number[] = []
for (let start = 0; start < eligible.length; start += USER_ACHIEVEMENT_INSERT_CHUNK) { for (
let start = 0;
start < eligible.length;
start += USER_ACHIEVEMENT_INSERT_CHUNK
) {
const chunk = eligible.slice(start, start + USER_ACHIEVEMENT_INSERT_CHUNK) const chunk = eligible.slice(start, start + USER_ACHIEVEMENT_INSERT_CHUNK)
const inserted = await db.insert(schema.userAchievement).values(chunk.map((stat) => ({ const inserted = await db
.insert(schema.userAchievement)
.values(
chunk.map((stat) => ({
userId: stat.userId, userId: stat.userId,
achievementId: achievement.id, achievementId: achievement.id,
unlockTime, unlockTime,
backfilled: true, backfilled: true,
notified: false, notified: false,
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }) })),
)
.onConflictDoNothing({
target: [
schema.userAchievement.achievementId,
schema.userAchievement.userId,
],
})
.returning({ userId: schema.userAchievement.userId }) .returning({ userId: schema.userAchievement.userId })
unlockedUserIds.push(...inserted.map((row) => row.userId)) unlockedUserIds.push(...inserted.map((row) => row.userId))
} }
if (unlockedUserIds.length) { if (unlockedUserIds.length) {
await db.update(schema.achievement) await db
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}` }) .update(schema.achievement)
.set({
unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}`,
})
.where(eq(schema.achievement.id, achievement.id)) .where(eq(schema.achievement.id, achievement.id))
for (const userId of unlockedUserIds) { for (const userId of unlockedUserIds) {
await publishAchievementNotification(userId, [{ await publishAchievementNotification(userId, [
{
id: achievement.id, id: achievement.id,
name: achievement.name, name: achievement.name,
description: achievement.description, description: achievement.description,
icon: achievement.icon, icon: achievement.icon,
rarity: achievement.rarity, rarity: achievement.rarity,
kind: "achievement", kind: "achievement",
}]) },
])
} }
// 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。 // 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。
// 旧 `rescan_achievement` 就漏了这步,OJ2 原样搬过来:2026-09-07 一次补发之后 // 旧 `rescan_achievement` 就漏了这步,OJ2 原样搬过来:2026-09-07 一次补发之后
// 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 —— // 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 ——
// 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。 // 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。
if (achievement.rarity !== "platinum" && achievement.metric !== "achievement_unlocked_count") { if (
achievement.rarity !== "platinum" &&
achievement.metric !== "achievement_unlocked_count"
) {
await refreshUnlockedCount(unlockedUserIds) await refreshUnlockedCount(unlockedUserIds)
for (const meta of await metaAchievements()) await rescanAchievement(meta.id) for (const meta of await metaAchievements())
await rescanAchievement(meta.id)
} }
} }
return { scanned: stats.length, unlocked: unlockedUserIds.length } return { scanned: stats.length, unlocked: unlockedUserIds.length }
@@ -241,9 +420,20 @@ export async function rescanAchievement(achievementId: number) {
/** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */ /** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */
export function metaAchievements() { export function metaAchievements() {
return db.select({ id: schema.achievement.id, name: schema.achievement.name, threshold: schema.achievement.threshold, operator: schema.achievement.operator }) return db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
threshold: schema.achievement.threshold,
operator: schema.achievement.operator,
})
.from(schema.achievement) .from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), eq(schema.achievement.metric, "achievement_unlocked_count"))) .where(
and(
eq(schema.achievement.visible, true),
eq(schema.achievement.metric, "achievement_unlocked_count"),
),
)
} }
/** /**
@@ -278,7 +468,6 @@ export async function refreshUnlockedCount(userIds?: number[]) {
return rows.map((row) => row.user_id) return rows.map((row) => row.user_id)
} }
/** 同上,3 个参数一行 */ /** 同上,3 个参数一行 */
const STAT_UPSERT_CHUNK = 1000 const STAT_UPSERT_CHUNK = 1000
@@ -299,19 +488,25 @@ const STAT_UPSERT_CHUNK = 1000
*/ */
async function refreshContestJoinedForAll() { async function refreshContestJoinedForAll() {
const rows = await db const rows = await db
.select({ userId: schema.submission.userId, value: countDistinct(schema.submission.contestId) }) .select({
userId: schema.submission.userId,
value: countDistinct(schema.submission.contestId),
})
.from(schema.submission) .from(schema.submission)
.where(isNotNull(schema.submission.contestId)) .where(isNotNull(schema.submission.contestId))
.groupBy(schema.submission.userId) .groupBy(schema.submission.userId)
const now = new Date().toISOString() const now = new Date().toISOString()
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) { for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK) const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
await db.insert(schema.userStat) await db
.values(chunk.map((row) => ({ .insert(schema.userStat)
.values(
chunk.map((row) => ({
userId: row.userId, userId: row.userId,
metrics: { contest_joined: row.value }, metrics: { contest_joined: row.value },
updateTime: now, updateTime: now,
}))) })),
)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: schema.userStat.userId, target: schema.userStat.userId,
set: { set: {
+50 -14
View File
@@ -27,14 +27,27 @@ export async function completeChat(system: string, user: string) {
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), { const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST", method: "POST",
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS), signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` }, headers: {
body: JSON.stringify(requestBody([ "content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
},
body: JSON.stringify(
requestBody(
[
{ role: "system", content: system }, { role: "system", content: system },
{ role: "user", content: user }, { role: "user", content: user },
], false)), ],
false,
),
),
}) })
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`) if (!response.ok)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> } throw new Error(
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
)
const payload = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>
}
return payload.choices?.[0]?.message?.content?.trim() ?? "" return payload.choices?.[0]?.message?.content?.trim() ?? ""
} }
@@ -48,21 +61,37 @@ export function streamChat(
async start(controller) { async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value)) const send = (value: string) => controller.enqueue(encoder.encode(value))
if (!config.aiKey) { if (!config.aiKey) {
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`) send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
)
send("event: end\n\n") send("event: end\n\n")
controller.close() controller.close()
return return
} }
try { try {
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), { const response = await fetch(
new URL("/chat/completions", config.aiBaseUrl),
{
method: "POST", method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` }, headers: {
body: JSON.stringify(requestBody([ "content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
},
body: JSON.stringify(
requestBody(
[
{ role: "system", content: system }, { role: "system", content: system },
{ role: "user", content: user }, { role: "user", content: user },
], true)), ],
}) true,
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`) ),
),
},
)
if (!response.ok || !response.body)
throw new Error(
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
)
send("event: start\n\n") send("event: start\n\n")
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
@@ -79,7 +108,12 @@ export function streamChat(
const data = line.slice(5).trim() const data = line.slice(5).trim()
if (data === "[DONE]") continue if (data === "[DONE]") continue
try { try {
const item = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }> } const item = JSON.parse(data) as {
choices?: Array<{
delta?: { content?: string }
finish_reason?: string | null
}>
}
const choice = item.choices?.[0] const choice = item.choices?.[0]
const content = choice?.delta?.content const content = choice?.delta?.content
if (content) { if (content) {
@@ -96,7 +130,9 @@ export function streamChat(
if (onComplete) await onComplete(full) if (onComplete) await onComplete(full)
send(`data: ${JSON.stringify({ type: "done" })}\n\n`) send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
} catch (error) { } catch (error) {
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`) send(
`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`,
)
} finally { } finally {
send("event: end\n\n") send("event: end\n\n")
controller.close() controller.close()
+56 -15
View File
@@ -29,23 +29,40 @@ export function contestStatus(contest: ContestRow) {
return "0" as const return "0" as const
} }
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) { export function isContestAdmin(
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin")) user: AuthUser | null | undefined,
contest: ContestRow,
) {
return Boolean(
user &&
(user.id === contest.createdById || user.adminType === "Super Admin"),
)
} }
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) { export function contestDetailsAllowed(
user: AuthUser | null | undefined,
contest: ContestRow,
) {
return contestStatus(contest) === "-1" || isContestAdmin(user, contest) return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
} }
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) { export function checkContestPassword(
candidate: string | null | undefined,
expected: string | null,
) {
if (!candidate || !expected) return false if (!candidate || !expected) return false
if (candidate === expected) return true if (candidate === expected) return true
const parts = candidate.split("#") const parts = candidate.split("#")
if (parts.length !== 2) return false if (parts.length !== 2) return false
const [signature, expiresAt] = parts const [signature, expiresAt] = parts
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8) const expectedSignature = createHash("sha256")
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000 .update(`${expected}${expiresAt}`)
.digest("hex")
.slice(0, 8)
return (
signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
)
} }
/** /**
@@ -58,9 +75,15 @@ export function checkContestPassword(candidate: string | null | undefined, expec
* *
* 404 * 404
*/ */
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) { export async function findAccessibleContest(
const [contest] = await db.select().from(schema.contest) user: AuthUser | null | undefined,
.where(eq(schema.contest.id, id)).limit(1) id: number,
) {
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, id))
.limit(1)
if (!contest) return null if (!contest) return null
return contest.visible || isContestAdmin(user, contest) ? contest : null return contest.visible || isContestAdmin(user, contest) ? contest : null
} }
@@ -73,16 +96,25 @@ export async function canAccessContest<E extends AppEnv>(
checkType: "details" | "problems" | "ranks" | "submissions", checkType: "details" | "problems" | "ranks" | "submissions",
) { ) {
const user = c.get("user") const user = c.get("user")
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" } if (!user)
return { ok: false as const, code: "login-required", message: "请先登录" }
if (isContestAdmin(user, contest)) return { ok: true as const } if (isContestAdmin(user, contest)) return { ok: true as const }
if (contest.password) { if (contest.password) {
const stored = await getContestPassword(c, contest.id) const stored = await getContestPassword(c, contest.id)
if (!checkContestPassword(stored, contest.password)) { if (!checkContestPassword(stored, contest.password)) {
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" } return {
ok: false as const,
code: "wrong-password",
message: "Wrong password or password expired",
}
} }
} }
if (contestStatus(contest) === "1" && checkType !== "details") { if (contestStatus(contest) === "1" && checkType !== "details") {
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." } return {
ok: false as const,
code: "contest-not-started",
message: "Contest has not started yet.",
}
} }
return { ok: true as const } return { ok: true as const }
} }
@@ -104,11 +136,20 @@ export function requireContestAccess(
): MiddlewareHandler<ContestEnv> { ): MiddlewareHandler<ContestEnv> {
return async (c, next) => { return async (c, next) => {
const id = Number(c.req.param(paramName)) const id = Number(c.req.param(paramName))
const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null const contest =
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") Number.isInteger(id) && id > 0
? await findAccessibleContest(c.get("user"), id)
: null
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, checkType) const access = await canAccessContest(c, contest, checkType)
if (!access.ok) { if (!access.ok) {
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) return failure(
c,
access.code === "login-required" ? 401 : 403,
access.code,
access.message,
)
} }
c.set("contest", contest) c.set("contest", contest)
await next() await next()
+23 -7
View File
@@ -34,7 +34,8 @@ export function exerciseDataError(
case "mcq": { case "mcq": {
const options = strings(data.options) const options = strings(data.options)
if (options.length < 2) return "选择题至少要有 2 个选项" if (options.length < 2) return "选择题至少要有 2 个选项"
if (options.some((option) => !option.trim())) return "选择题的选项不能为空" if (options.some((option) => !option.trim()))
return "选择题的选项不能为空"
return indexAnswerError(data.answer, options.length, "正确答案") return indexAnswerError(data.answer, options.length, "正确答案")
} }
case "sort": { case "sort": {
@@ -53,12 +54,19 @@ export function exerciseDataError(
case "match": { case "match": {
const left = strings(data.left) const left = strings(data.left)
const right = strings(data.right) const right = strings(data.right)
if (left.length < 2 || right.length < 2) return "连线题左右两列各至少 2 项" if (left.length < 2 || right.length < 2)
return "连线题左右两列各至少 2 项"
if (left.length !== right.length) return "连线题左右两列的行数必须相等" if (left.length !== right.length) return "连线题左右两列的行数必须相等"
return indexAnswerError(data.answer, right.length, "连线答案", left.length) return indexAnswerError(
data.answer,
right.length,
"连线答案",
left.length,
)
} }
case "predict": { case "predict": {
if (!(typeof data.code === "string" && data.code.trim())) return "输出预测题的代码不能为空" if (!(typeof data.code === "string" && data.code.trim()))
return "输出预测题的代码不能为空"
if (strings(data.answer).filter((item) => item.trim()).length === 0) { if (strings(data.answer).filter((item) => item.trim()).length === 0) {
return "输出预测题至少要有一个正确输出" return "输出预测题至少要有一个正确输出"
} }
@@ -75,7 +83,13 @@ export function exerciseDataError(
if (buckets.length < 2) return "归类题至少要有 2 个分组" if (buckets.length < 2) return "归类题至少要有 2 个分组"
if (items.length === 0) return "归类题至少要有一个项目" if (items.length === 0) return "归类题至少要有一个项目"
// 归类题的下标**允许重复**:好几个项目落在同一个分组是常态,别顺手加去重 // 归类题的下标**允许重复**:好几个项目落在同一个分组是常态,别顺手加去重
return indexAnswerError(data.answer, buckets.length, "归类答案", items.length, false) return indexAnswerError(
data.answer,
buckets.length,
"归类答案",
items.length,
false,
)
} }
} }
} }
@@ -122,7 +136,9 @@ function indexAnswerError(
? `请至少勾选一个${label}` ? `请至少勾选一个${label}`
: `${label}的条数(${answer.length})和项目数(${length})对不上` : `${label}的条数(${answer.length})和项目数(${length})对不上`
} }
if (answer.some((item) => item < 0 || item >= bound)) return `${label}的下标越界` if (answer.some((item) => item < 0 || item >= bound))
if (unique && new Set(answer).size !== answer.length) return `${label}里有重复的下标` return `${label}的下标越界`
if (unique && new Set(answer).size !== answer.length)
return `${label}里有重复的下标`
return null return null
} }
+11 -3
View File
@@ -32,7 +32,8 @@ async function runFormatter(command: string[], code: string) {
} }
function formatSql(code: string) { function formatSql(code: string) {
return code return (
code
.split(";") .split(";")
.map((statement) => statement.trim()) .map((statement) => statement.trim())
.filter(Boolean) .filter(Boolean)
@@ -43,9 +44,13 @@ function formatSql(code: string) {
), ),
) )
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "") .join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
)
} }
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") { export async function formatCode(
code: string,
language: "python" | "c" | "cpp" | "sql",
) {
if (language === "sql") return formatSql(code) if (language === "sql") return formatSql(code)
if (language === "python") { if (language === "python") {
@@ -54,7 +59,10 @@ export async function formatCode(code: string, language: "python" | "c" | "cpp"
code, code,
) )
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax") throw new CodeFormatError(
result.stderr || "Invalid Python syntax",
"syntax",
)
} }
return result.stdout return result.stdout
} }
+11 -3
View File
@@ -15,14 +15,22 @@ export const websiteOptionDefaults = {
export async function getOptions<const T extends readonly string[]>(keys: T) { export async function getOptions<const T extends readonly string[]>(keys: T) {
const rows = await db const rows = await db
.select({ key: schema.optionsSysoptions.key, value: schema.optionsSysoptions.value }) .select({
key: schema.optionsSysoptions.key,
value: schema.optionsSysoptions.value,
})
.from(schema.optionsSysoptions) .from(schema.optionsSysoptions)
.where(inArray(schema.optionsSysoptions.key, [...keys])) .where(inArray(schema.optionsSysoptions.key, [...keys]))
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<T[number], unknown> return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<
T[number],
unknown
>
} }
export async function getWebsiteOptions() { export async function getWebsiteOptions() {
const keys = Object.keys(websiteOptionDefaults) as Array<keyof typeof websiteOptionDefaults> const keys = Object.keys(websiteOptionDefaults) as Array<
keyof typeof websiteOptionDefaults
>
const values = await getOptions(keys) const values = await getOptions(keys)
return Object.fromEntries( return Object.fromEntries(
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]), keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
+124 -41
View File
@@ -6,8 +6,13 @@ import { objectValue } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number; isRequired: boolean } type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow, type BadgeCheck = Pick<
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail"> ProgressRow,
| "completedProblemsCount"
| "totalProblemsCount"
| "totalScore"
| "progressDetail"
>
/** /**
* *
@@ -24,7 +29,9 @@ export function computeProgress(
previousCompleteTime: string | null, previousCompleteTime: string | null,
now = new Date().toISOString(), now = new Date().toISOString(),
) { ) {
const scoreByProblem = new Map(links.map((link) => [String(link.problemId), link.score])) const scoreByProblem = new Map(
links.map((link) => [String(link.problemId), link.score]),
)
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多 // 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多
const kept: Record<string, unknown> = {} const kept: Record<string, unknown> = {}
let totalScore = 0 let totalScore = 0
@@ -44,7 +51,9 @@ export function computeProgress(
const required = links.filter((link) => link.isRequired) const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId))) const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length const completed = Object.keys(kept).filter((key) =>
gradedKeys.has(key),
).length
const total = graded.length const total = graded.length
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」, // total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。 // 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
@@ -55,7 +64,8 @@ export function computeProgress(
completedProblemsCount: completed, completedProblemsCount: completed,
totalScore, totalScore,
// 乘 10000 四舍五入再除 100,保留两位小数 // 乘 10000 四舍五入再除 100,保留两位小数
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0, progressPercentage:
total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
isCompleted, isCompleted,
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。 // 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
// //
@@ -78,7 +88,8 @@ async function writeProgress(rows: ProgressWrite[]) {
for (let start = 0; start < rows.length; start += 1000) { for (let start = 0; start < rows.length; start += 1000) {
const chunk = rows.slice(start, start + 1000) const chunk = rows.slice(start, start + 1000)
const values = sql.join( const values = sql.join(
chunk.map((row) => sql`( chunk.map(
(row) => sql`(
${row.id}::bigint, ${row.id}::bigint,
${JSON.stringify(row.progressDetail)}::jsonb, ${JSON.stringify(row.progressDetail)}::jsonb,
${row.totalProblemsCount}::int, ${row.totalProblemsCount}::int,
@@ -87,7 +98,8 @@ async function writeProgress(rows: ProgressWrite[]) {
${row.progressPercentage}::double precision, ${row.progressPercentage}::double precision,
${row.isCompleted}::boolean, ${row.isCompleted}::boolean,
${row.completeTime}::timestamptz ${row.completeTime}::timestamptz
)`), )`,
),
sql`, `, sql`, `,
) )
await db.execute(sql` await db.execute(sql`
@@ -118,13 +130,19 @@ async function writeProgress(rows: ProgressWrite[]) {
*/ */
export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) { export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
if (badge.conditionType === "all_problems") { if (badge.conditionType === "all_problems") {
return progress.totalProblemsCount > 0 && return (
progress.totalProblemsCount > 0 &&
progress.completedProblemsCount === progress.totalProblemsCount progress.completedProblemsCount === progress.totalProblemsCount
)
} }
if (badge.conditionType === "problem_count") { if (badge.conditionType === "problem_count") {
return Object.keys(objectValue(progress.progressDetail)).length >= badge.conditionValue return (
Object.keys(objectValue(progress.progressDetail)).length >=
badge.conditionValue
)
} }
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue if (badge.conditionType === "score")
return progress.totalScore >= badge.conditionValue
return false return false
} }
@@ -136,26 +154,45 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
* `known` * `known`
* *
*/ */
export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) { export async function recalculateBadge(
const progresses = known ?? await db.select().from(schema.problemsetProgress) badge: BadgeRow,
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)) known?: (BadgeCheck & { userId: number })[],
const eligibleIds = progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId) ) {
const progresses =
known ??
(await db
.select()
.from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)))
const eligibleIds = progresses
.filter((item) => eligibleForBadge(badge, item))
.map((item) => item.userId)
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(and( await tx
.delete(schema.userBadge)
.where(
and(
eq(schema.userBadge.badgeId, badge.id), eq(schema.userBadge.badgeId, badge.id),
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined, eligibleIds.length
)) ? notInArray(schema.userBadge.userId, eligibleIds)
: undefined,
),
)
if (!eligibleIds.length) return if (!eligibleIds.length) return
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge) const existing = await tx
.select({ userId: schema.userBadge.userId })
.from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id)) .where(eq(schema.userBadge.badgeId, badge.id))
const have = new Set(existing.map((item) => item.userId)) const have = new Set(existing.map((item) => item.userId))
const missing = eligibleIds.filter((id) => !have.has(id)) const missing = eligibleIds.filter((id) => !have.has(id))
if (missing.length) { if (missing.length) {
await tx.insert(schema.userBadge).values(missing.map((userId) => ({ await tx.insert(schema.userBadge).values(
missing.map((userId) => ({
userId, userId,
badgeId: badge.id, badgeId: badge.id,
earnedTime: new Date().toISOString(), earnedTime: new Date().toISOString(),
}))) })),
)
} }
}) })
} }
@@ -174,20 +211,32 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
*/ */
export async function resyncProgress(problemsetId: number) { export async function resyncProgress(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([ const [links, progresses, badges] = await Promise.all([
db.select({ db
.select({
problemId: schema.problemsetProblem.problemId, problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score, score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired, isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)), })
db.select().from(schema.problemsetProgress) .from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db
.select()
.from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)), .where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
db.select().from(schema.problemsetBadge) db
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)), .where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
]) ])
const now = new Date().toISOString() const now = new Date().toISOString()
const updated = progresses.map((progress) => ({ const updated = progresses.map((progress) => ({
...progress, ...progress,
...computeProgress(objectValue(progress.progressDetail), links, progress.completeTime, now), ...computeProgress(
objectValue(progress.progressDetail),
links,
progress.completeTime,
now,
),
})) }))
if (updated.length) await writeProgress(updated) if (updated.length) await writeProgress(updated)
for (const badge of badges) await recalculateBadge(badge, updated) for (const badge of badges) await recalculateBadge(badge, updated)
@@ -214,59 +263,93 @@ export async function recordSolvedProblem(
const joined = await db const joined = await db
.select({ problemsetId: schema.problemsetProgress.problemsetId }) .select({ problemsetId: schema.problemsetProgress.problemsetId })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
.innerJoin(schema.problemsetProblem, and( .innerJoin(
eq(schema.problemsetProblem.problemsetId, schema.problemsetProgress.problemsetId), schema.problemsetProblem,
and(
eq(
schema.problemsetProblem.problemsetId,
schema.problemsetProgress.problemsetId,
),
eq(schema.problemsetProblem.problemId, problemId), eq(schema.problemsetProblem.problemId, problemId),
)) ),
)
.where(eq(schema.problemsetProgress.userId, userId)) .where(eq(schema.problemsetProgress.userId, userId))
const earned: BadgeRow[] = [] const earned: BadgeRow[] = []
let updated = 0 let updated = 0
for (const { problemsetId } of joined) { for (const { problemsetId } of joined) {
const hits = await db.transaction(async (tx) => { const hits = await db.transaction(async (tx) => {
const [progress] = await tx.select().from(schema.problemsetProgress).where(and( const [progress] = await tx
.select()
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, problemsetId), eq(schema.problemsetProgress.problemsetId, problemsetId),
eq(schema.problemsetProgress.userId, userId), eq(schema.problemsetProgress.userId, userId),
)).for("update").limit(1) ),
)
.for("update")
.limit(1)
if (!progress) return [] if (!progress) return []
// 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行 // 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行
const [existing] = await tx.select({ id: schema.problemsetSubmission.id }) const [existing] = await tx
.from(schema.problemsetSubmission).where(and( .select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, problemsetId), eq(schema.problemsetSubmission.problemsetId, problemsetId),
eq(schema.problemsetSubmission.userId, userId), eq(schema.problemsetSubmission.userId, userId),
eq(schema.problemsetSubmission.problemId, problemId), eq(schema.problemsetSubmission.problemId, problemId),
)).limit(1) ),
)
.limit(1)
if (!existing) { if (!existing) {
await tx.insert(schema.problemsetSubmission) await tx
.insert(schema.problemsetSubmission)
.values({ problemsetId, userId, submissionId, problemId }) .values({ problemsetId, userId, submissionId, problemId })
} }
const detail = objectValue(progress.progressDetail) const detail = objectValue(progress.progressDetail)
if (String(problemId) in detail) return [] if (String(problemId) in detail) return []
const links = await tx.select({ const links = await tx
.select({
problemId: schema.problemsetProblem.problemId, problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score, score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired, isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem) })
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId)) .where(eq(schema.problemsetProblem.problemsetId, problemsetId))
const link = links.find((item) => item.problemId === problemId) const link = links.find((item) => item.problemId === problemId)
if (!link) return [] if (!link) return []
detail[String(problemId)] = { score: link.score, submit_time: solvedAt } detail[String(problemId)] = { score: link.score, submit_time: solvedAt }
const update = computeProgress(detail, links, progress.completeTime) const update = computeProgress(detail, links, progress.completeTime)
await tx.update(schema.problemsetProgress).set(update) await tx
.update(schema.problemsetProgress)
.set(update)
.where(eq(schema.problemsetProgress.id, progress.id)) .where(eq(schema.problemsetProgress.id, progress.id))
updated += 1 updated += 1
const badges = await tx.select().from(schema.problemsetBadge) const badges = await tx
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)) .where(eq(schema.problemsetBadge.problemsetId, problemsetId))
const eligible = badges.filter((badge) => eligibleForBadge(badge, { ...progress, ...update })) const eligible = badges.filter((badge) =>
eligibleForBadge(badge, { ...progress, ...update }),
)
if (eligible.length === 0) return [] if (eligible.length === 0) return []
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的 // 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
const inserted = await tx.insert(schema.userBadge).values(eligible.map((badge) => ({ const inserted = await tx
.insert(schema.userBadge)
.values(
eligible.map((badge) => ({
userId, userId,
badgeId: badge.id, badgeId: badge.id,
earnedTime: new Date().toISOString(), earnedTime: new Date().toISOString(),
}))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] }) })),
)
.onConflictDoNothing({
target: [schema.userBadge.badgeId, schema.userBadge.userId],
})
.returning({ badgeId: schema.userBadge.badgeId }) .returning({ badgeId: schema.userBadge.badgeId })
const ids = new Set(inserted.map((row) => row.badgeId)) const ids = new Set(inserted.map((row) => row.badgeId))
return eligible.filter((badge) => ids.has(badge.id)) return eligible.filter((badge) => ids.has(badge.id))
+4 -1
View File
@@ -3,7 +3,10 @@ import { and, eq } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
export async function getUserProfileById(userId: number, showRealName: boolean) { export async function getUserProfileById(
userId: number,
showRealName: boolean,
) {
const [row] = await db const [row] = await db
.select({ profile: schema.userProfile, user: schema.user }) .select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile) .from(schema.userProfile)
+30 -10
View File
@@ -95,13 +95,20 @@ export async function processTestCaseZip(
// 只按「精确文件名」取内容,不遍历压缩包里的条目 —— // 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
// 条目名一律不参与路径拼接,zip slip`../../etc/passwd` 这类条目名)从设计上就进不来。 // 条目名一律不参与路径拼接,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).filter((name) => /^\d+\.(in|out|sql)$/.test(name)),
)
const selected = options.sql ? collectSqlScripts(names) : collectPairs(names).flat() const selected = options.sql
if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点") ? collectSqlScripts(names)
: collectPairs(names).flat()
if (selected.length === 0)
throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
if (options.sql && selected.length < 2) { if (options.sql && selected.length < 2) {
// 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC // 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC
throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果") throw new TestCaseError(
"SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果",
)
} }
let total = 0 let total = 0
@@ -109,12 +116,16 @@ export async function processTestCaseZip(
for (const name of selected) { for (const name of selected) {
const raw = files[name]! const raw = files[name]!
if (raw.length > MAX_ENTRY_BYTES) { if (raw.length > MAX_ENTRY_BYTES) {
throw new TestCaseError(`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`) throw new TestCaseError(
`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`,
)
} }
const content = normalizeNewlines(raw) const content = normalizeNewlines(raw)
total += content.length total += content.length
if (total > MAX_TOTAL_BYTES) { if (total > MAX_TOTAL_BYTES) {
throw new TestCaseError(`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`) throw new TestCaseError(
`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`,
)
} }
contents.set(name, content) contents.set(name, content)
} }
@@ -149,7 +160,9 @@ export async function processTestCaseZip(
collectPairs(names).forEach(([input, output], index) => { collectPairs(names).forEach(([input, output], index) => {
const outputContent = contents.get(output)! const outputContent = contents.get(output)!
const entry: TestCaseEntry = { const entry: TestCaseEntry = {
stripped_output_md5: createHash("md5").update(rstrip(outputContent)).digest("hex"), stripped_output_md5: createHash("md5")
.update(rstrip(outputContent))
.digest("hex"),
input_size: contents.get(input)!.length, input_size: contents.get(input)!.length,
output_size: outputContent.length, output_size: outputContent.length,
input_name: input, input_name: input,
@@ -179,7 +192,9 @@ export async function packTestCaseZip(testCaseId: string) {
throw new TestCaseError("Test case does not exists") throw new TestCaseError("Test case does not exists")
} }
const names = new Set(entries) const names = new Set(entries)
const isSql = await readInfo(testCaseId).then((info) => Boolean(info?.sql)).catch(() => false) const isSql = await readInfo(testCaseId)
.then((info) => Boolean(info?.sql))
.catch(() => false)
const selected = isSql ? collectSqlScripts(names) : collectPairs(names).flat() const selected = isSql ? collectSqlScripts(names) : collectPairs(names).flat()
const bundle: Record<string, Uint8Array> = {} const bundle: Record<string, Uint8Array> = {}
for (const name of [...selected, "info"]) { for (const name of [...selected, "info"]) {
@@ -207,7 +222,10 @@ export async function readSqlScripts(testCaseId: string) {
const names = collectSqlScripts(new Set(await readdir(directory))) const names = collectSqlScripts(new Set(await readdir(directory)))
const scripts: { name: string; content: string }[] = [] const scripts: { name: string; content: string }[] = []
for (const name of names) { for (const name of names) {
scripts.push({ name, content: await readFile(resolve(directory, name), "utf8") }) scripts.push({
name,
content: await readFile(resolve(directory, name), "utf8"),
})
} }
return scripts return scripts
} }
@@ -216,5 +234,7 @@ function randomId() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
const bytes = new Uint8Array(32) const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes) crypto.getRandomValues(bytes)
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join("") return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
"",
)
} }
+19 -6
View File
@@ -81,11 +81,17 @@ type ThrottleRedis = typeof redis & {
): Promise<[number, string]> ): Promise<[number, string]>
} }
function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig { function parseBucketConfig(
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback value: unknown,
fallback: BucketConfig,
): BucketConfig {
if (!value || typeof value !== "object" || Array.isArray(value))
return fallback
const raw = value as Record<string, unknown> const raw = value as Record<string, unknown>
const pick = (key: keyof BucketConfig) => const pick = (key: keyof BucketConfig) =>
typeof raw[key] === "number" && Number.isFinite(raw[key]) && (raw[key] as number) > 0 typeof raw[key] === "number" &&
Number.isFinite(raw[key]) &&
(raw[key] as number) > 0
? (raw[key] as number) ? (raw[key] as number)
: fallback[key] : fallback[key]
return { return {
@@ -106,7 +112,10 @@ function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig
* `throttling` * `throttling`
*/ */
const BUCKET_CACHE_TTL = 60_000 const BUCKET_CACHE_TTL = 60_000
const bucketCache = new Map<"user", { value: BucketConfig; expiresAt: number }>() const bucketCache = new Map<
"user",
{ value: BucketConfig; expiresAt: number }
>()
export async function getBucketConfig(scope: "user"): Promise<BucketConfig> { export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
const cached = bucketCache.get(scope) const cached = bucketCache.get(scope)
@@ -117,9 +126,13 @@ export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
try { try {
const values = await getOptions(["throttling"]) const values = await getOptions(["throttling"])
const throttling = values.throttling const throttling = values.throttling
value = !throttling || typeof throttling !== "object" || Array.isArray(throttling) value =
!throttling || typeof throttling !== "object" || Array.isArray(throttling)
? fallback ? fallback
: parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback) : parseBucketConfig(
(throttling as Record<string, unknown>)[scope],
fallback,
)
} catch { } catch {
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟 // 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
// 全站都按默认参数限流 // 全站都按默认参数限流
+38 -8
View File
@@ -22,14 +22,44 @@ const STOPWORDS = new Set(
) )
const CUSTOM_WORDS = [ const CUSTOM_WORDS = [
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件", "循环结构",
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束", "条件判断",
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误", "判断条件",
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断", "结束条件",
"缺少循环", "死循环", "无限循环", "循环出口", "循环体", "循环条件",
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用", "异常处理",
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作", "边界条件",
"终止条件", "退出条件", "返回值", "输入输出",
"输入验证",
"开始结束",
"结束节点",
"开始节点",
"判断节点",
"流程走向",
"逻辑错误",
"逻辑缺陷",
"逻辑不清",
"缺少分支",
"缺少步骤",
"缺少判断",
"缺少循环",
"死循环",
"无限循环",
"循环出口",
"循环体",
"条件分支",
"分支结构",
"分支不全",
"分支缺失",
"符号使用",
"符号不规范",
"连线混乱",
"变量初始化",
"赋值操作",
"累加操作",
"终止条件",
"退出条件",
"返回值",
] ]
/** /**
+9 -3
View File
@@ -27,7 +27,9 @@ function fromWallClock(wall: Date): Date {
} }
/** 北京时间的日历日,形如 `2026-09-14` */ /** 北京时间的日历日,形如 `2026-09-14` */
export function calendarDay(value: Date | number | string = new Date()): string { export function calendarDay(
value: Date | number | string = new Date(),
): string {
return toWallClock(value).toISOString().slice(0, 10) return toWallClock(value).toISOString().slice(0, 10)
} }
@@ -64,7 +66,9 @@ export function localWeekday(day: number): number {
/** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */ /** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */
export function todayStart(now: Date | number | string = new Date()): string { export function todayStart(now: Date | number | string = new Date()): string {
return new Date(dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS).toISOString() return new Date(
dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS,
).toISOString()
} }
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */ /** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
@@ -73,7 +77,9 @@ export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const date = wall.getUTCDate() const date = wall.getUTCDate()
wall.setUTCDate(1) wall.setUTCDate(1)
wall.setUTCMonth(wall.getUTCMonth() + months) wall.setUTCMonth(wall.getUTCMonth() + months)
const lastDay = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate() const lastDay = new Date(
Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0),
).getUTCDate()
wall.setUTCDate(Math.min(date, lastDay)) wall.setUTCDate(Math.min(date, lastDay))
return fromWallClock(wall) return fromWallClock(wall)
} }
+3 -2
View File
@@ -54,8 +54,9 @@ export async function withBuiltinDict(): Promise<JiebaInstance> {
with: { type: "file" }, with: { type: "file" },
}) })
).default as unknown as string ).default as unknown as string
const dictPath = (await import("@node-rs/jieba/dict.txt", { with: { type: "file" } })) const dictPath = (
.default as unknown as string await import("@node-rs/jieba/dict.txt", { with: { type: "file" } })
).default as unknown as string
let addon: { Jieba: { withDict(dict: Buffer): JiebaInstance } } let addon: { Jieba: { withDict(dict: Buffer): JiebaInstance } }
try { try {
+49 -11
View File
@@ -114,7 +114,10 @@ function consume(bucket: RateBucket, burst: number, refillPerSecond: number) {
/** 文本帧:严格档。会查库,走这一档的都按最坏情况算 */ /** 文本帧:严格档。会查库,走这一档的都按最坏情况算 */
function allowMessage(ws: Bun.ServerWebSocket<SubmissionSocketData>) { function allowMessage(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
const bucket = (ws.data.rate ??= { tokens: RATE_BURST, updatedAt: Date.now() }) const bucket = (ws.data.rate ??= {
tokens: RATE_BURST,
updatedAt: Date.now(),
})
return consume(bucket, RATE_BURST, RATE_REFILL_PER_SECOND) return consume(bucket, RATE_BURST, RATE_REFILL_PER_SECOND)
} }
@@ -213,7 +216,10 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
liveSockets.add(ws) liveSockets.add(ws)
ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() } ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() }
if (ws.data.kind === "collab") { if (ws.data.kind === "collab") {
ws.data.binaryRate = { tokens: COLLAB_BINARY_BURST, updatedAt: Date.now() } ws.data.binaryRate = {
tokens: COLLAB_BINARY_BURST,
updatedAt: Date.now(),
}
handleCollabOpen(ws) handleCollabOpen(ws)
return return
} }
@@ -292,7 +298,10 @@ async function handleMessage(
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp })) ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
return return
} }
if (message.type !== "subscribe" || typeof message.submissionId !== "string") { if (
message.type !== "subscribe" ||
typeof message.submissionId !== "string"
) {
ws.send(JSON.stringify({ type: "error", message: "Invalid message" })) ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
return return
} }
@@ -336,19 +345,43 @@ async function handleMessage(
if (!submission) { if (!submission) {
const [flowchart] = await db const [flowchart] = await db
.select({ id: schema.flowchartSubmission.id, status: schema.flowchartSubmission.status, score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade }) .select({
id: schema.flowchartSubmission.id,
status: schema.flowchartSubmission.status,
score: schema.flowchartSubmission.aiScore,
grade: schema.flowchartSubmission.aiGrade,
})
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.where(and(eq(schema.flowchartSubmission.id, message.submissionId), eq(schema.flowchartSubmission.userId, ws.data.userId))) .where(
and(
eq(schema.flowchartSubmission.id, message.submissionId),
eq(schema.flowchartSubmission.userId, ws.data.userId),
),
)
.limit(1) .limit(1)
if (!flowchart) { if (!flowchart) {
ws.send(JSON.stringify({ type: "error", message: "Submission not found" })) ws.send(
JSON.stringify({ type: "error", message: "Submission not found" }),
)
return return
} }
const replay = flowchart.status === 2 const replay =
? { type: "flowchart_evaluation_completed" as const, submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined } flowchart.status === 2
? {
type: "flowchart_evaluation_completed" as const,
submissionId: flowchart.id,
score: flowchart.score ?? undefined,
grade: flowchart.grade ?? undefined,
}
: flowchart.status === 3 : flowchart.status === 3
? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id } ? {
: { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id } type: "flowchart_evaluation_failed" as const,
submissionId: flowchart.id,
}
: {
type: "flowchart_evaluation_update" as const,
submissionId: flowchart.id,
}
ws.send(JSON.stringify(replay satisfies FlowchartUpdate)) ws.send(JSON.stringify(replay satisfies FlowchartUpdate))
return return
} }
@@ -407,7 +440,12 @@ export async function bridgeSubmissionEvents(
const [activeUser] = await db const [activeUser] = await db
.select({ id: schema.user.id }) .select({ id: schema.user.id })
.from(schema.user) .from(schema.user)
.where(and(eq(schema.user.id, event.userId), eq(schema.user.isDisabled, false))) .where(
and(
eq(schema.user.id, event.userId),
eq(schema.user.isDisabled, false),
),
)
.limit(1) .limit(1)
if (!activeUser) return if (!activeUser) return
server.publish(topic, JSON.stringify(event.data)) server.publish(topic, JSON.stringify(event.data))
+15 -5
View File
@@ -20,7 +20,8 @@ const flowchartWorker = new Worker<FlowchartJobData>(
flowchartQueueName, flowchartQueueName,
// attemptsMade 是「此前已经失败过几次」,当前这次还没计进去, // attemptsMade 是「此前已经失败过几次」,当前这次还没计进去,
// 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts // 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts
async (job) => evaluateFlowchart(job.data, { async (job) =>
evaluateFlowchart(job.data, {
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1), isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
}), }),
{ connection: createBlockingRedis(), concurrency: 2 }, { connection: createBlockingRedis(), concurrency: 2 },
@@ -38,15 +39,24 @@ worker.on("failed", async (job, error) => {
try { try {
await failAbandonedSubmission(submissionId, error) await failAbandonedSubmission(submissionId, error)
} catch (markError) { } catch (markError) {
console.error(`Failed to mark submission ${submissionId} as system error`, markError) console.error(
`Failed to mark submission ${submissionId} as system error`,
markError,
)
} }
}) })
worker.on("error", (error) => { worker.on("error", (error) => {
console.error("Judge worker error", error) console.error("Judge worker error", error)
}) })
flowchartWorker.on("ready", () => console.log("Flowchart worker ready (concurrency=2)")) flowchartWorker.on("ready", () =>
flowchartWorker.on("failed", (job, error) => console.error(`Flowchart job ${job?.id ?? "unknown"} failed`, error)) console.log("Flowchart worker ready (concurrency=2)"),
flowchartWorker.on("error", (error) => console.error("Flowchart worker error", error)) )
flowchartWorker.on("failed", (job, error) =>
console.error(`Flowchart job ${job?.id ?? "unknown"} failed`, error),
)
flowchartWorker.on("error", (error) =>
console.error("Flowchart worker error", error),
)
async function shutdown() { async function shutdown() {
await worker.close() await worker.close()
+3 -2
View File
@@ -22,14 +22,15 @@ ViteRolldown 内核)、Naive UI、Pinia、Vue Router。
bun run dev # 只起前端 dev server5173),后端得另外起 bun run dev # 只起前端 dev server5173),后端得另外起
bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个 bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
bun run build # 生产构建 bun run build # 生产构建
bun run fmt # Prettier
``` ```
⚠️ **验证只认 `bun run type-check`。** `vue-tsc --noEmit -p tsconfig.json` 会**静默 ⚠️ **验证只认 `bun run type-check`。** `vue-tsc --noEmit -p tsconfig.json` 会**静默
通过**——那个 tsconfig 是 `files: []` + references 的壳,真正的配置在 通过**——那个 tsconfig 是 `files: []` + references 的壳,真正的配置在
`tsconfig.app.json`0.2 秒跑完就是没在检查的信号);`vite build` 也不做类型检查。 `tsconfig.app.json`0.2 秒跑完就是没在检查的信号);`vite build` 也不做类型检查。
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier 不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier**脚本在仓库根目录**
`cd ../.. && bun run fmt`,一把把后端、契约、前端全格式化)—— 前端这边原来那个
只管 `apps/web``fmt` 已经删掉,配置也收到了根目录的 `.prettierrc.toml`
## Architecture ## Architecture
-2
View File
@@ -8,7 +8,6 @@
"build": "vite build", "build": "vite build",
"build:staging": "vite build --mode staging", "build:staging": "vite build --mode staging",
"build:test": "vite build --mode test", "build:test": "vite build --mode test",
"fmt": "prettier --write src *.ts",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json" "type-check": "vue-tsc --noEmit -p tsconfig.app.json"
}, },
"dependencies": { "dependencies": {
@@ -64,7 +63,6 @@
"@vitejs/plugin-legacy": "^8.2.3", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.6",
"unplugin-auto-import": "^21.1.0", "unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.2.2", "vite": "^8.2.2",
+8 -11
View File
@@ -94,9 +94,7 @@ export function editProblem(problem: AdminProblem | BlankProblem) {
} }
export function toggleProblemVisible(problemID: number) { export function toggleProblemVisible(problemID: number) {
return api.put<{ visible: boolean }>( return api.put<{ visible: boolean }>(`admin/problems/${problemID}/visibility`)
`admin/problems/${problemID}/visibility`,
)
} }
export function generateFlowchartFromPythonCode(python: string) { export function generateFlowchartFromPythonCode(python: string) {
@@ -135,7 +133,11 @@ export function batchTagProblems(
} }
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank // 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank
export function getAdminUserRank(offset: number, limit: number, keyword: string) { export function getAdminUserRank(
offset: number,
limit: number,
keyword: string,
) {
return api.get<AdminUserRank>("admin/rankings/users", { return api.get<AdminUserRank>("admin/rankings/users", {
params: { offset, limit, keyword }, params: { offset, limit, keyword },
}) })
@@ -236,9 +238,7 @@ export function previewSQLTestcase(data: {
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序) // 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) { export function getSQLTestcaseScripts(problemId: number) {
return api.get<SqlTestCaseScript[]>( return api.get<SqlTestCaseScript[]>(`admin/problems/${problemId}/sql-scripts`)
`admin/problems/${problemId}/sql-scripts`,
)
} }
// AI 根据标准答案生成一个 SQL 测试点初始化脚本 // AI 根据标准答案生成一个 SQL 测试点初始化脚本
@@ -412,10 +412,7 @@ export function createTutorial(data: Partial<Tutorial>) {
} }
export function updateTutorial(data: Partial<Tutorial>) { export function updateTutorial(data: Partial<Tutorial>) {
return api.put<Tutorial>( return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data))
`admin/tutorials/${data.id}`,
toTutorialBody(data),
)
} }
export function deleteTutorial(id: number) { export function deleteTutorial(id: number) {
+8 -3
View File
@@ -168,10 +168,14 @@ const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>( const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
() => [ () => [
{ type: "expand", renderExpand: (row) => h(ExerciseAttempts, { {
type: "expand",
renderExpand: (row) =>
h(ExerciseAttempts, {
exerciseId: row.exerciseId, exerciseId: row.exerciseId,
className: className.value.trim(), className: className.value.trim(),
}) }, }),
},
{ {
title: "课", title: "课",
key: "tutorialOrder", key: "tutorialOrder",
@@ -289,7 +293,8 @@ onMounted(load)
</n-text> </n-text>
<!-- 口径写在表上方免得老师对着已读 0 / 累计 25 分钟猜是不是坏了 --> <!-- 口径写在表上方免得老师对着已读 0 / 累计 25 分钟猜是不是坏了 -->
<n-text depth="3" style="font-size: 12px"> <n-text depth="3" style="font-size: 12px">
已读按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算不足的只计时长 已读按累计停留满
{{ TUTORIAL_READ_SECONDS / 60 }} 分钟算不足的只计时长
</n-text> </n-text>
</n-flex> </n-flex>
@@ -78,7 +78,10 @@ function nodeTargetOptions(lang: string): SelectOption[] {
// and/or/not C && / || / ! // and/or/not C && / || / !
function operatorTargetOptions(lang: string): SelectOption[] { function operatorTargetOptions(lang: string): SelectOption[] {
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map( return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, label]) => ({ label: label === value ? value : `${label}${value}`, value }), ([value, label]) => ({
label: label === value ? value : `${label}${value}`,
value,
}),
) )
} }
@@ -173,7 +176,8 @@ function getTargetLabel(
engine: string, engine: string,
target: string, target: string,
): string | undefined { ): string | undefined {
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label if (isNodeEngine(engine))
return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
// labelastOperatorLabel // labelastOperatorLabel
// label C && and // label C && and
return undefined return undefined
@@ -252,7 +256,8 @@ watch(supportedLanguages, (langs) => {
:bordered="false" :bordered="false"
style="margin-bottom: 8px" style="margin-bottom: 8px"
> >
{{ unsupportedLanguages.join("、") }} 暂不支持代码规则检查判题机只能检查 {{ unsupportedLanguages.join("、") }}
暂不支持代码规则检查判题机只能检查
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }} {{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
</n-alert> </n-alert>
<n-tabs <n-tabs
@@ -393,9 +398,7 @@ watch(supportedLanguages, (langs) => {
<n-empty <n-empty
v-else v-else
:description=" :description="
languages.length languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'
? '当前语言不支持代码规则检查'
: '请先选择编程语言'
" "
/> />
</n-collapse-item> </n-collapse-item>
@@ -59,9 +59,7 @@ async function submit() {
props.action, props.action,
) )
const verb = props.action === "add" ? "添加" : "移除" const verb = props.action === "add" ? "添加" : "移除"
message.success( message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`,
)
close() close()
emit("done") emit("done")
} }
@@ -179,8 +179,7 @@ async function run() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const data = uploadable.value const data = uploadable.value.flatMap((f, i) => [
.flatMap((f, i) => [
{ name: `${i + 1}.in`, content: f.in }, { name: `${i + 1}.in`, content: f.in },
{ name: `${i + 1}.out`, content: f.out }, { name: `${i + 1}.out`, content: f.out },
]) ])
+5 -1
View File
@@ -896,7 +896,11 @@ watch(
v-model:value="problem.showFlowchart" v-model:value="problem.showFlowchart"
:disabled="problem.allowFlowchart" :disabled="problem.allowFlowchart"
/> />
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px"> <n-text
v-if="problem.allowFlowchart"
depth="3"
style="font-size: 12px"
>
让学生自己画图时标准流程图不会下发给学生这个开关没有意义 让学生自己画图时标准流程图不会下发给学生这个开关没有意义
</n-text> </n-text>
</n-flex> </n-flex>
+1 -3
View File
@@ -122,9 +122,7 @@ async function saveTag(tag: AdminTag) {
} }
const res = await renameTag(tag.id, name) const res = await renameTag(tag.id, name)
if (res.merged) { if (res.merged) {
message.success( message.success(`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`)
`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`,
)
} else { } else {
message.success("已重命名") message.success("已重命名")
} }
+1 -3
View File
@@ -110,9 +110,7 @@ function startRolling(finalName: string) {
async function getRandom() { async function getRandom() {
const res = await randomUser10(query.classroom) const res = await randomUser10(query.classroom)
const names = (res as string[]).map( const names = (res as string[]).map((name) => name.split(query.classroom)[1])
(name) => name.split(query.classroom)[1],
)
rollingNames.value = names rollingNames.value = names
const finalName = names[names.length - 1] const finalName = names[names.length - 1]
startRolling(finalName) startRolling(finalName)
@@ -23,8 +23,7 @@ defineEmits<{
*/ */
const maskable = computed( const maskable = computed(
() => () =>
props.user.adminType !== USER_TYPE.REGULAR_USER && props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
!!props.user.rawPassword,
) )
</script> </script>
<template> <template>
+3 -1
View File
@@ -57,7 +57,9 @@ async function uploadUsers() {
message.success("用户已上传成功") message.success("用户已上传成功")
// //
// //
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n") const csv = users.value
.map(([username, password]) => `${username},${password}`)
.join("\n")
const hiddenElement = document.createElement("a") const hiddenElement = document.createElement("a")
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv) hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
hiddenElement.target = "_blank" hiddenElement.target = "_blank"
+2 -1
View File
@@ -77,7 +77,8 @@ const columns: DataTableColumn<User>[] = [
user: row, user: row,
revealed: revealedPasswords.value.has(row.id), revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => { onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id) if (!revealedPasswords.value.delete(id))
revealedPasswords.value.add(id)
}, },
}), }),
}, },
+3 -1
View File
@@ -74,7 +74,9 @@ aiStore.targetUsername = urlUsername.value
aiStore.duration = urlDuration.value aiStore.duration = urlDuration.value
const subOptions = computed<Duration>( const subOptions = computed<Duration>(
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!, () =>
durationFromValue(aiStore.duration) ??
durationFromValue(DURATION_OPTIONS[0].value)!,
) )
const start = computed(() => formatISO(sub(new Date(), subOptions.value))) const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
@@ -31,7 +31,8 @@ const { chartKey } = useChartTheme()
// tab // tab
const items = computed(() => const items = computed(() =>
[...aiStore.detailsData.flowcharts].sort( [...aiStore.detailsData.flowcharts].sort(
(a, b) => b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId), (a, b) =>
b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
), ),
) )
+7 -8
View File
@@ -255,12 +255,9 @@ export function getContestAccess(id: string) {
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch // 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
export function checkContestPassword(contestID: string, password: string) { export function checkContestPassword(contestID: string, password: string) {
return api.post<boolean>( return api.post<boolean>(`contests/${encodeURIComponent(contestID)}/access`, {
`contests/${encodeURIComponent(contestID)}/access`,
{
password, password,
}, })
)
} }
export async function getContestProblems(contestID: string) { export async function getContestProblems(contestID: string) {
@@ -295,9 +292,12 @@ export function updateProfile(data: { realName: string; mood: string }) {
} }
export function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", { return api.get<{ results: AnnouncementListItem[]; total: number }>(
"announcements",
{
params: { limit, offset }, params: { limit, offset },
}) },
)
} }
export function getAnnouncement(id: number) { export function getAnnouncement(id: number) {
@@ -465,7 +465,6 @@ export function joinProblemSet(problemSetId: number) {
return api.post("problem-set-progress", { problemSetId }) return api.post("problem-set-progress", { problemSetId })
} }
export function getUserBadges(username?: string) { export function getUserBadges(username?: string) {
return api.get<UserBadge[]>( return api.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`, `users/${encodeURIComponent(username ?? "me")}/badges`,
+3 -1
View File
@@ -66,7 +66,9 @@ const timeRangeOptions: SelectOption[] = [
] ]
// value null // value null
const subOptions = computed<Duration | null>(() => durationFromValue(duration.value)) const subOptions = computed<Duration | null>(() =>
durationFromValue(duration.value),
)
// //
function getTimeRange(): { function getTimeRange(): {
@@ -55,7 +55,10 @@ function submit() {
/** 给老师看的一句人话:选项按 A/B/C 报,报下标没人看得懂 */ /** 给老师看的一句人话:选项按 A/B/C 报,报下标没人看得懂 */
function describe(sel: Set<number>) { function describe(sel: Set<number>) {
return `选了 ${[...sel].sort((a, b) => a - b).map((i) => String.fromCharCode(65 + i)).join("、")}` return `选了 ${[...sel]
.sort((a, b) => a - b)
.map((i) => String.fromCharCode(65 + i))
.join("、")}`
} }
function reset() { function reset() {
@@ -23,10 +23,7 @@ const IDLE_MS = 10 * 60 * 1000
* @param tutorialId 0 * @param tutorialId 0
* @param enabled false * @param enabled false
*/ */
export function useLearnTrace( export function useLearnTrace(tutorialId: Ref<number>, enabled: Ref<boolean>) {
tutorialId: Ref<number>,
enabled: Ref<boolean>,
) {
const visibility = useDocumentVisibility() const visibility = useDocumentVisibility()
const { idle } = useIdle(IDLE_MS) const { idle } = useIdle(IDLE_MS)
+9 -2
View File
@@ -126,7 +126,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types" import type {
Tutorial,
Exercise,
LANGUAGE,
TutorialProgress,
} from "utils/types"
import { import {
getTutorial, getTutorial,
getTutorials, getTutorials,
@@ -220,7 +225,9 @@ async function loadProgress() {
} }
try { try {
const rows = await getLearnProgress(type.value) const rows = await getLearnProgress(type.value)
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row])) progress.value = Object.fromEntries(
rows.map((row) => [row.tutorialId, row]),
)
} catch { } catch {
progress.value = {} progress.value = {}
} }
@@ -311,7 +311,9 @@ function type(status: ProblemStatus) {
</p> </p>
<n-list bordered style="margin-bottom: 8px"> <n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i"> <n-list-item v-for="(rule, i) in rules" :key="i">
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag> <n-tag :type="KIND_TAG_TYPE[rule.kind]">{{
rule.description
}}</n-tag>
</n-list-item> </n-list-item>
</n-list> </n-list>
</div> </div>
@@ -111,8 +111,7 @@ const POLL_INTERVAL = 3000
const POLL_TIMEOUT = 3 * 60 * 1000 const POLL_TIMEOUT = 3 * 60 * 1000
type Outcome = type Outcome =
| { ok: true; score: number; grade: string } { ok: true; score: number; grade: string } | { ok: false; error?: string }
| { ok: false; error?: string }
const { pause: pausePolling, resume: resumePolling } = useIntervalFn( const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
async () => { async () => {
@@ -509,11 +508,7 @@ onUnmounted(() => {
</n-card> </n-card>
<!-- 详细评分 --> <!-- 详细评分 -->
<n-card <n-card v-if="sortedCriteria.length" size="small" title="详细评分">
v-if="sortedCriteria.length"
size="small"
title="详细评分"
>
<div <div
v-for="[key, detail] in sortedCriteria" v-for="[key, detail] in sortedCriteria"
:key="key" :key="key"
+2 -10
View File
@@ -182,11 +182,7 @@ watch(
<n-tab-pane name="content" tab="题目描述"> <n-tab-pane name="content" tab="题目描述">
<ProblemContent /> <ProblemContent />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
v-if="canShowFlowchart"
name="flowchart"
tab="流程图表"
>
<ProblemFlowchart /> <ProblemFlowchart />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId"> <n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
@@ -234,11 +230,7 @@ watch(
<n-tab-pane name="content" tab="题目描述"> <n-tab-pane name="content" tab="题目描述">
<ProblemContent /> <ProblemContent />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
v-if="canShowFlowchart"
name="flowchart"
tab="流程图表"
>
<ProblemFlowchart /> <ProblemFlowchart />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId"> <n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
@@ -42,11 +42,15 @@ function getProgressPercentage() {
// N 10 9 / 9 // N 10 9 / 9
const optionalCount = computed( const optionalCount = computed(
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0), () =>
props.problemSet.problemsCount -
(props.problemSet.userProgress?.totalCount ?? 0),
) )
const endTimeText = computed(() => const endTimeText = computed(() =>
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "", props.problemSet.endTime
? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm")
: "",
) )
function handleJoin() { function handleJoin() {
+3 -1
View File
@@ -286,7 +286,9 @@ const options: SelectOption[] = [...LONG_DURATION_OPTIONS]
// 退 options[1] duration // 退 options[1] duration
const subOptions = computed<Duration>( const subOptions = computed<Duration>(
() => durationFromValue(duration.value) ?? durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!, () =>
durationFromValue(duration.value) ??
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
) )
onMounted(() => { onMounted(() => {
@@ -69,11 +69,7 @@
</n-card> </n-card>
<!-- 详细评分 --> <!-- 详细评分 -->
<n-card <n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
v-if="sortedCriteria.length > 0"
size="small"
title="详细评分"
>
<div <div
v-for="[key, detail] in sortedCriteria" v-for="[key, detail] in sortedCriteria"
:key="key" :key="key"
@@ -148,7 +144,9 @@ const criteriaDetails = computed<
) )
}) })
// jsonb 40 // jsonb 40
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value)) const sortedCriteria = computed(() =>
sortFlowchartCriteria(criteriaDetails.value),
)
const loading = ref(false) const loading = ref(false)
const rendering = ref(false) const rendering = ref(false)
+3 -1
View File
@@ -43,7 +43,9 @@ const loading = ref(false)
* 测试点明细`info` 在契约里是完整形状或空对象的联合非管理员拿到的是空对象 * 测试点明细`info` 在契约里是完整形状或空对象的联合非管理员拿到的是空对象
* `data` 本身也可能为 null 两种情况都由这个访问器归成空数组模板里不再直接取 * `data` 本身也可能为 null 两种情况都由这个访问器归成空数组模板里不再直接取
*/ */
const caseResults = computed(() => submissionCaseResults(submission.value?.info)) const caseResults = computed(() =>
submissionCaseResults(submission.value?.info),
)
async function init() { async function init() {
submission.value = props.submission submission.value = props.submission
+2 -8
View File
@@ -80,14 +80,8 @@ async function init() {
const metricsRes = await getMetrics(res.user.id) const metricsRes = await getMetrics(res.user.id)
firstSubmissionAt.value = parseTime(metricsRes.first) firstSubmissionAt.value = parseTime(metricsRes.first)
latestSubmissionAt.value = parseTime(metricsRes.latest) latestSubmissionAt.value = parseTime(metricsRes.latest)
toLatestAt.value = durationToDays( toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
metricsRes.latest, learnDuration.value = durationToDays(metricsRes.first, metricsRes.latest)
metricsRes.now,
)
learnDuration.value = durationToDays(
metricsRes.first,
metricsRes.latest,
)
} }
} finally { } finally {
toggle(false) toggle(false)
+1 -3
View File
@@ -41,7 +41,5 @@ export function getHitokoto() {
} }
export function getClassUsernames(classroom: string) { export function getClassUsernames(classroom: string) {
return api.get<string[]>( return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
`classes/${encodeURIComponent(classroom)}/usernames`,
)
} }
@@ -158,7 +158,10 @@
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract" import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api" import { getFlowchartStatistics } from "oj/api"
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants" import {
PANEL_DURATION_OPTIONS,
FLOWCHART_CRITERIA_ORDER,
} from "utils/constants"
import { durationFromValue } from "utils/functions" import { durationFromValue } from "utils/functions"
import { useHiddenStudents } from "../composables/hiddenStudents" import { useHiddenStudents } from "../composables/hiddenStudents"
import { Doughnut, Radar, Bar } from "vue-chartjs" import { Doughnut, Radar, Bar } from "vue-chartjs"
@@ -471,7 +474,8 @@ function renderWordCloud() {
const subOptions = computed<Duration>( const subOptions = computed<Duration>(
() => () =>
durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!, durationFromValue(query.duration) ??
durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
) )
async function handleStatistics() { async function handleStatistics() {
@@ -249,15 +249,19 @@ const ATTEMPT_COLORS: Record<string, string> = {
* 已通过的沉底它们只是做完了不需要再看 * 已通过的沉底它们只是做完了不需要再看
*/ */
function groupByProblem(list: SubmissionStatisticsItems["items"]) { function groupByProblem(list: SubmissionStatisticsItems["items"]) {
const groups = new Map<string, { const groups = new Map<
string,
{
problem: string problem: string
problemTitle: string problemTitle: string
items: SubmissionStatisticsItems["items"] items: SubmissionStatisticsItems["items"]
}>() }
>()
for (const item of list) { for (const item of list) {
const group = groups.get(item.problem) const group = groups.get(item.problem)
if (group) group.items.push(item) if (group) group.items.push(item)
else groups.set(item.problem, { else
groups.set(item.problem, {
problem: item.problem, problem: item.problem,
problemTitle: item.problemTitle, problemTitle: item.problemTitle,
items: [item], items: [item],
@@ -276,7 +280,8 @@ function groupByProblem(list: SubmissionStatisticsItems["items"]) {
item.result === SubmissionStatus.ast_check_failed, item.result === SubmissionStatus.ast_check_failed,
), ),
})) }))
.sort((a, b) => .sort(
(a, b) =>
Number(a.solved) - Number(b.solved) || b.items.length - a.items.length, Number(a.solved) - Number(b.solved) || b.items.length - a.items.length,
) )
} }
@@ -298,9 +303,18 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [ h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
h( h(
NFlex, NFlex,
{ size: 4, align: "center", wrap: false, style: "width: 200px; flex: none" }, {
size: 4,
align: "center",
wrap: false,
style: "width: 200px; flex: none",
},
() => [ () => [
h(NTag, { size: "small", bordered: false }, () => group.problem), h(
NTag,
{ size: "small", bordered: false },
() => group.problem,
),
h( h(
NText, NText,
{ {
@@ -318,9 +332,13 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
depth: 3, depth: 3,
style: "width: 104px; flex: none", style: "width: 104px; flex: none",
}, },
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`, () =>
`${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
), ),
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () => h(
NFlex,
{ size: 4, wrap: true, style: "flex: 1; min-width: 0" },
() =>
group.items.map((item) => group.items.map((item) =>
h( h(
NTooltip, NTooltip,
@@ -337,7 +355,10 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
border: "none", border: "none",
borderRadius: "3px", borderRadius: "3px",
cursor: "pointer", cursor: "pointer",
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"], background:
ATTEMPT_COLORS[
JUDGE_STATUS[item.result]?.type ?? "default"
],
}, },
onClick: (event: MouseEvent) => { onClick: (event: MouseEvent) => {
event.stopPropagation() event.stopPropagation()
@@ -358,7 +379,8 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
? h( ? h(
NText, NText,
{ depth: 3 }, { depth: 3 },
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`, () =>
`只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
) )
: null, : null,
]) ])
@@ -372,7 +394,11 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
render: (row) => render: (row) =>
h( h(
NTag, NTag,
{ size: "small", type: row.done ? "success" : "default", bordered: false }, {
size: "small",
type: row.done ? "success" : "default",
bordered: false,
},
() => (row.done ? "已完成" : "未完成"), () => (row.done ? "已完成" : "未完成"),
), ),
}, },
@@ -693,7 +719,9 @@ const completionChartOptions = {
const subOptions = computed<Duration>( const subOptions = computed<Duration>(
// all退 `?? options[0]` // all退 `?? options[0]`
// all handleStatistics // all handleStatistics
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!, () =>
durationFromValue(query.duration) ??
durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
) )
function goSubmissions() { function goSubmissions() {
@@ -84,7 +84,8 @@ const handleEditorReady = (payload: EditorReadyPayload) => {
watch( watch(
() => collabStore.room, () => collabStore.room,
(room) => { (room) => {
if (room && !collabStore.isTeacher && editorView.value) bind(editorView.value) if (room && !collabStore.isTeacher && editorView.value)
bind(editorView.value)
else stop() else stop()
}, },
) )

Some files were not shown because too many files have changed in this diff Show More