Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed56a209ea | ||
|
|
e600fd24cf |
@@ -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
|
||||
@@ -56,8 +56,16 @@ bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路
|
||||
bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑
|
||||
cd apps/web && bun run type-check # 前端类型检查
|
||||
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` 这个脚本。** 两条看起来等价的路子都会**静默
|
||||
通过**:`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` +
|
||||
references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build` 根本不做类型检查。
|
||||
|
||||
@@ -5,6 +5,8 @@ export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./src/db",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
|
||||
url:
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,20 +51,27 @@ function requireRole(
|
||||
return async (c, next) => {
|
||||
const session = await resolveSession(c)
|
||||
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)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧 `@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` */
|
||||
export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType))
|
||||
export const requireTeacher = requireRole((user) =>
|
||||
TEACHER_ROLES.includes(user.adminType),
|
||||
)
|
||||
|
||||
/** 旧 `@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。
|
||||
@@ -72,5 +79,6 @@ export const requireSuperAdmin = requireRole((user) => user.adminType === "Super
|
||||
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
|
||||
*/
|
||||
export const requireProblemPermission = requireRole(
|
||||
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
|
||||
(user) =>
|
||||
ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
|
||||
)
|
||||
|
||||
@@ -16,7 +16,11 @@ async function verifyDjangoPbkdf2(password: string, encoded: string) {
|
||||
|
||||
const iterations = Number(iterationsText)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,12 @@ export async function createSession(
|
||||
// 全压在登录这一下上
|
||||
const pipeline = redis
|
||||
.pipeline()
|
||||
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
|
||||
.set(
|
||||
sessionKey(token),
|
||||
JSON.stringify(value),
|
||||
"EX",
|
||||
config.sessionTtlSeconds,
|
||||
)
|
||||
.sadd(userSessionsKey(userId), token)
|
||||
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
||||
markOnline(pipeline, userId)
|
||||
@@ -156,7 +161,9 @@ export type SessionResult =
|
||||
| { user: AuthUser; reason?: undefined }
|
||||
| { 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" }
|
||||
|
||||
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)
|
||||
if (!session) return false
|
||||
session.value.contestPasswords[String(contestId)] = password
|
||||
|
||||
@@ -86,7 +86,10 @@ export function handleCollabOpen(ws: CollabSocket) {
|
||||
addTeacher(ws)
|
||||
// 新上线的老师要立刻看到当前队列,不能等下一次变更
|
||||
ws.send(
|
||||
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
|
||||
JSON.stringify({
|
||||
type: "requests",
|
||||
list: listRequests().map(serializeRequest),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -159,7 +162,8 @@ export function handleCollabClose(ws: CollabSocket) {
|
||||
closeRoom(room.studentId)
|
||||
room.studentSocket.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" }))
|
||||
|
||||
if (ws === room.teacherSocket) {
|
||||
@@ -197,7 +201,9 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
||||
|
||||
// 心跳不查库,和 /ws/submissions 的处理一致
|
||||
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
|
||||
}
|
||||
|
||||
@@ -261,7 +267,9 @@ async function handleHelpRequest(
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }))
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -339,7 +347,12 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||||
const [teacher] = await db
|
||||
.select({ adminType: schema.user.adminType })
|
||||
.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)
|
||||
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
|
||||
ws.close(1008, "Permission revoked")
|
||||
@@ -363,7 +376,10 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||||
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
|
||||
// 回一份最新列表让老师端自己纠正
|
||||
ws.send(
|
||||
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
|
||||
JSON.stringify({
|
||||
type: "requests",
|
||||
list: listRequests().map(serializeRequest),
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -404,7 +420,12 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
|
||||
const [teacher] = await db
|
||||
.select({ adminType: schema.user.adminType })
|
||||
.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)
|
||||
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
|
||||
ws.close(1008, "Permission revoked")
|
||||
@@ -461,7 +482,10 @@ function teardownRoom(
|
||||
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
|
||||
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
|
||||
*/
|
||||
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
|
||||
export function handleCollabBinary(
|
||||
ws: CollabSocket,
|
||||
data: Buffer | Uint8Array,
|
||||
) {
|
||||
// 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值,
|
||||
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
|
||||
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
|
||||
@@ -469,7 +493,8 @@ export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array)
|
||||
|
||||
const room = roomOf(ws)
|
||||
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)
|
||||
// Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
|
||||
// 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
|
||||
*/
|
||||
|
||||
export type CollabSocket = Bun.ServerWebSocket<import("../websocket").SubmissionSocketData>
|
||||
export type CollabSocket = Bun.ServerWebSocket<
|
||||
import("../websocket").SubmissionSocketData
|
||||
>
|
||||
|
||||
/**
|
||||
* 协作支持的语言。和前端 utils/types.ts 里的 LANGUAGE 对齐,去掉 Flowchart ——
|
||||
@@ -69,7 +71,6 @@ export function removeRequest(studentId: number) {
|
||||
return requests.delete(studentId)
|
||||
}
|
||||
|
||||
|
||||
/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */
|
||||
export function listRequests() {
|
||||
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
|
||||
let ahead = 0
|
||||
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
|
||||
}
|
||||
@@ -132,4 +134,3 @@ export function roomOf(ws: CollabSocket) {
|
||||
const ownerId = ws.data.roomOwnerId
|
||||
return ownerId === undefined ? undefined : rooms.get(ownerId)
|
||||
}
|
||||
|
||||
|
||||
+13
-4
@@ -25,7 +25,10 @@ function loadRepoRootEnv() {
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
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 {
|
||||
// 根目录没有 .env 是正常情况(例如生产用真实环境变量注入),静默跳过
|
||||
@@ -64,18 +67,24 @@ export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
|
||||
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",
|
||||
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
||||
judgeServerToken: judgeServerToken(),
|
||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
|
||||
// 判题沙箱把这个目录挂成只读的 /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"),
|
||||
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
|
||||
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
|
||||
hitokotoDirectory: repoPath(process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto"),
|
||||
hitokotoDirectory: repoPath(
|
||||
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
|
||||
),
|
||||
/**
|
||||
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
|
||||
* 同源本来就放行,只有前后端分处不同域名时才需要配。
|
||||
|
||||
@@ -3,7 +3,9 @@ import postgres from "postgres"
|
||||
|
||||
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),
|
||||
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。
|
||||
|
||||
@@ -57,7 +57,9 @@ export async function runMigrations() {
|
||||
process.exit(2)
|
||||
}
|
||||
if (files.length === 0) {
|
||||
console.error(`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`)
|
||||
console.error(
|
||||
`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`,
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
@@ -136,10 +138,16 @@ export async function runMigrations() {
|
||||
// 自举时不拦:空库上没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转。
|
||||
// 拦下来只会逼着每个新环境都带一次 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(
|
||||
"待执行的迁移里有破坏性语句,已停下:\n" +
|
||||
blocked.map(({ tag, reasons }) => ` · ${tag}:${reasons.join(" / ")}`).join("\n") +
|
||||
blocked
|
||||
.map(({ tag, reasons }) => ` · ${tag}:${reasons.join(" / ")}`)
|
||||
.join("\n") +
|
||||
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
|
||||
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
|
||||
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
|
||||
@@ -180,7 +188,9 @@ export async function runMigrations() {
|
||||
|
||||
function destructiveReasons(sql: string) {
|
||||
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> {
|
||||
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 }>
|
||||
}
|
||||
return new Map((journal.entries ?? []).map((e) => [e.when, e.tag]))
|
||||
@@ -234,7 +246,9 @@ async function applyMigration(
|
||||
) {
|
||||
// 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后
|
||||
// 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。
|
||||
const statements = migration.sql.filter((stmt) => stripComments(stmt).trim() !== "")
|
||||
const statements = migration.sql.filter(
|
||||
(stmt) => stripComments(stmt).trim() !== "",
|
||||
)
|
||||
if (statements.length === 0) {
|
||||
// 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过
|
||||
throw new Error(`${tag} 没有任何可执行语句`)
|
||||
|
||||
+1263
-752
File diff suppressed because it is too large
Load Diff
+29
-10
@@ -13,7 +13,10 @@ export const configUpdateChannel = "config:updates"
|
||||
export const configTopic = "events:config"
|
||||
|
||||
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 },
|
||||
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 {
|
||||
try {
|
||||
const value = JSON.parse(raw) as SessionRevoked
|
||||
if (typeof value.token !== "string" && !Number.isInteger(value.userId)) return null
|
||||
if (value.reason !== "session-ended" && value.reason !== "account-disabled") return null
|
||||
if (typeof value.token !== "string" && !Number.isInteger(value.userId))
|
||||
return null
|
||||
if (value.reason !== "session-ended" && value.reason !== "account-disabled")
|
||||
return null
|
||||
return value
|
||||
} catch {
|
||||
return null
|
||||
@@ -71,7 +79,10 @@ export function userEventTopic(userId: number) {
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -80,16 +91,24 @@ export async function publishAchievementNotification(
|
||||
achievements: AchievementNotification[],
|
||||
) {
|
||||
if (!achievements.length) return
|
||||
await redis.publish(userEventChannel, JSON.stringify({
|
||||
userId,
|
||||
data: { type: "achievement_unlocked", achievements },
|
||||
}))
|
||||
await redis.publish(
|
||||
userEventChannel,
|
||||
JSON.stringify({
|
||||
userId,
|
||||
data: { type: "achievement_unlocked", achievements },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function parseUserEvent(raw: string): UserEvent | null {
|
||||
try {
|
||||
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
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -32,14 +32,18 @@ function parseEvaluation(value: string) {
|
||||
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
||||
if (!json) throw new Error("AI response did not contain JSON")
|
||||
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))
|
||||
return {
|
||||
score,
|
||||
grade: gradeForScore(score),
|
||||
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
||||
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,30 +51,46 @@ export async function evaluateFlowchart(
|
||||
job: FlowchartJobData,
|
||||
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
|
||||
) {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1)
|
||||
const [row] = await db
|
||||
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
|
||||
.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
|
||||
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()
|
||||
try {
|
||||
const reference = row.problem.mermaidCode ? `\n标准答案参考:\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。"
|
||||
const result = parseEvaluation(await completeChat(
|
||||
evaluationPrompt(row.problem),
|
||||
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
|
||||
))
|
||||
await db.update(schema.flowchartSubmission).set({
|
||||
status: 2,
|
||||
aiScore: result.score,
|
||||
aiGrade: result.grade,
|
||||
aiFeedback: result.feedback,
|
||||
aiSuggestions: result.suggestions,
|
||||
aiCriteriaDetails: result.criteria,
|
||||
aiProvider: "deepseek",
|
||||
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
|
||||
processingTime: (performance.now() - started) / 1000,
|
||||
evaluationTime: new Date().toISOString(),
|
||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
const reference = row.problem.mermaidCode
|
||||
? `\n标准答案参考:\n${row.problem.mermaidCode}`
|
||||
: "\n此题没有标准流程图。"
|
||||
const result = parseEvaluation(
|
||||
await completeChat(
|
||||
evaluationPrompt(row.problem),
|
||||
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
|
||||
),
|
||||
)
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
.set({
|
||||
status: 2,
|
||||
aiScore: result.score,
|
||||
aiGrade: result.grade,
|
||||
aiFeedback: result.feedback,
|
||||
aiSuggestions: result.suggestions,
|
||||
aiCriteriaDetails: result.criteria,
|
||||
aiProvider: "deepseek",
|
||||
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
|
||||
processingTime: (performance.now() - started) / 1000,
|
||||
evaluationTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await publishFlowchartUpdate(row.flowchart.userId, {
|
||||
type: "flowchart_evaluation_completed",
|
||||
submissionId: row.flowchart.id,
|
||||
@@ -90,7 +110,10 @@ export async function evaluateFlowchart(
|
||||
// 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
|
||||
// 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
|
||||
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, {
|
||||
type: "flowchart_evaluation_failed",
|
||||
submissionId: row.flowchart.id,
|
||||
|
||||
+89
-73
@@ -44,16 +44,16 @@ app.route("/api", judgeServerRoutes)
|
||||
app.route("/api/admin", adminRoutes)
|
||||
|
||||
app.onError((error, c) => {
|
||||
console.error(error)
|
||||
return c.json(
|
||||
{ error: { code: "internal-error", message: "Internal server error" } },
|
||||
500,
|
||||
)
|
||||
console.error(error)
|
||||
return c.json(
|
||||
{ error: { code: "internal-error", message: "Internal server error" } },
|
||||
500,
|
||||
)
|
||||
})
|
||||
|
||||
/** 头像取不到时的占位图,避免每个没设头像的学生都打一次 404 */
|
||||
const DEFAULT_AVATAR_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>'
|
||||
|
||||
/**
|
||||
* 伺服 /public 下的用户上传文件。
|
||||
@@ -64,76 +64,92 @@ const DEFAULT_AVATAR_SVG =
|
||||
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
|
||||
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
|
||||
*/
|
||||
async function serveUpload(pathname: string, prefix: string, directory: string) {
|
||||
const decoded = decodeURIComponent(pathname)
|
||||
const filename = basename(decoded)
|
||||
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
const file = Bun.file(resolve(directory, filename))
|
||||
if (await file.exists()) {
|
||||
// 文件名由后端生成且内容不变,可以放心长缓存
|
||||
return new Response(file, { headers: { "cache-control": "public, max-age=86400" } })
|
||||
}
|
||||
return null
|
||||
async function serveUpload(
|
||||
pathname: string,
|
||||
prefix: string,
|
||||
directory: string,
|
||||
) {
|
||||
const decoded = decodeURIComponent(pathname)
|
||||
const filename = basename(decoded)
|
||||
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
const file = Bun.file(resolve(directory, filename))
|
||||
if (await file.exists()) {
|
||||
// 文件名由后端生成且内容不变,可以放心长缓存
|
||||
return new Response(file, {
|
||||
headers: { "cache-control": "public, max-age=86400" },
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const server = Bun.serve<SubmissionSocketData>({
|
||||
port: config.port,
|
||||
async fetch(request, bunServer) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
|
||||
const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory)
|
||||
if (hit) return hit
|
||||
if (basename(decodeURIComponent(url.pathname)) === "default.png") {
|
||||
return new Response(DEFAULT_AVATAR_SVG, {
|
||||
headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" },
|
||||
})
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
// 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx,
|
||||
// 但没有任何路由伺服它,题面图片一律 404。
|
||||
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
|
||||
return (
|
||||
(await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ??
|
||||
new Response("Not found", { status: 404 })
|
||||
)
|
||||
}
|
||||
if (
|
||||
url.pathname === "/ws/submissions" ||
|
||||
url.pathname === "/ws/config" ||
|
||||
url.pathname === "/ws/collab"
|
||||
) {
|
||||
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
|
||||
return new Response("Forbidden", { status: 403 })
|
||||
}
|
||||
const user = await getRequestSessionUser(request)
|
||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||
const kind =
|
||||
url.pathname === "/ws/config"
|
||||
? "config"
|
||||
: url.pathname === "/ws/collab"
|
||||
? "collab"
|
||||
: "submissions"
|
||||
if (
|
||||
bunServer.upgrade(request, {
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind,
|
||||
token: readRequestSessionToken(request),
|
||||
username: user.username,
|
||||
adminType: user.adminType,
|
||||
},
|
||||
})
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return new Response("WebSocket upgrade failed", { status: 400 })
|
||||
}
|
||||
return app.fetch(request)
|
||||
},
|
||||
websocket: submissionWebSocketHandler(),
|
||||
port: config.port,
|
||||
async fetch(request, bunServer) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
|
||||
const hit = await serveUpload(
|
||||
url.pathname,
|
||||
config.avatarUriPrefix,
|
||||
config.avatarDirectory,
|
||||
)
|
||||
if (hit) return hit
|
||||
if (basename(decodeURIComponent(url.pathname)) === "default.png") {
|
||||
return new Response(DEFAULT_AVATAR_SVG, {
|
||||
headers: {
|
||||
"content-type": "image/svg+xml",
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
// 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx,
|
||||
// 但没有任何路由伺服它,题面图片一律 404。
|
||||
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
|
||||
return (
|
||||
(await serveUpload(
|
||||
url.pathname,
|
||||
config.uploadUriPrefix,
|
||||
config.uploadDirectory,
|
||||
)) ?? new Response("Not found", { status: 404 })
|
||||
)
|
||||
}
|
||||
if (
|
||||
url.pathname === "/ws/submissions" ||
|
||||
url.pathname === "/ws/config" ||
|
||||
url.pathname === "/ws/collab"
|
||||
) {
|
||||
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
|
||||
return new Response("Forbidden", { status: 403 })
|
||||
}
|
||||
const user = await getRequestSessionUser(request)
|
||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||
const kind =
|
||||
url.pathname === "/ws/config"
|
||||
? "config"
|
||||
: url.pathname === "/ws/collab"
|
||||
? "collab"
|
||||
: "submissions"
|
||||
if (
|
||||
bunServer.upgrade(request, {
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind,
|
||||
token: readRequestSessionToken(request),
|
||||
username: user.username,
|
||||
adminType: user.adminType,
|
||||
},
|
||||
})
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return new Response("WebSocket upgrade failed", { status: 400 })
|
||||
}
|
||||
return app.fetch(request)
|
||||
},
|
||||
websocket: submissionWebSocketHandler(),
|
||||
})
|
||||
|
||||
await bridgeSubmissionEvents(server)
|
||||
|
||||
+33
-18
@@ -39,17 +39,19 @@ async function loadLanguage(language: string) {
|
||||
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null
|
||||
// locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找,
|
||||
// 单二进制里那个目录是 /$bunfs/root,它自己找不着
|
||||
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
|
||||
if (!initPromise)
|
||||
initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
|
||||
await initPromise
|
||||
|
||||
const cached = languages.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const wasmPath = language === "C"
|
||||
? cWasmPath
|
||||
: language === "C++"
|
||||
? cppWasmPath
|
||||
: pythonWasmPath
|
||||
const wasmPath =
|
||||
language === "C"
|
||||
? cWasmPath
|
||||
: language === "C++"
|
||||
? cppWasmPath
|
||||
: pythonWasmPath
|
||||
const loaded = await Language.load(wasmPath)
|
||||
languages.set(language, loaded)
|
||||
return loaded
|
||||
@@ -136,9 +138,10 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
|
||||
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。
|
||||
*/
|
||||
export function astRequirements(value: unknown): AstRequirements | null {
|
||||
const grouped = value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
const grouped =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
if (!grouped) return null
|
||||
const out: AstRequirements = {}
|
||||
for (const [language, rules] of Object.entries(grouped)) {
|
||||
@@ -148,10 +151,12 @@ export function astRequirements(value: unknown): AstRequirements | null {
|
||||
const parsed = astRuleSchema.safeParse(rule)
|
||||
if (!parsed.success) return []
|
||||
if (!astRuleIsMeaningful(parsed.data)) return []
|
||||
return [{
|
||||
description: describeAstRule(parsed.data, language),
|
||||
kind: requirementKind(parsed.data.engine),
|
||||
}]
|
||||
return [
|
||||
{
|
||||
description: describeAstRule(parsed.data, language),
|
||||
kind: requirementKind(parsed.data.engine),
|
||||
},
|
||||
]
|
||||
})
|
||||
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 target = rule.target ?? ""
|
||||
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") {
|
||||
if (!(target in operators)) return `${at}:${language} 没有「${target}」运算符`
|
||||
if (!(target in operators))
|
||||
return `${at}:${language} 没有「${target}」运算符`
|
||||
} else if (rule.engine === "must_have_nesting") {
|
||||
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()) {
|
||||
return `${at}:要检查的函数名/方法名不能为空`
|
||||
@@ -204,7 +212,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
|
||||
* 早年配过 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
|
||||
const out: 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 nodeType = astTargetNodeType(target, language)
|
||||
|
||||
|
||||
@@ -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>> = {
|
||||
C: {
|
||||
|
||||
+118
-62
@@ -6,17 +6,16 @@ import { and, eq, inArray } from "drizzle-orm"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { publishAchievementNotification } from "../events"
|
||||
import { updateAchievementsForProblemSet, updateAchievementsForSubmission } from "../services/achievements"
|
||||
import {
|
||||
updateAchievementsForProblemSet,
|
||||
updateAchievementsForSubmission,
|
||||
} from "../services/achievements"
|
||||
import { recordSolvedProblem } from "../services/problemset"
|
||||
import { checkAst, type AstRule } from "./ast"
|
||||
import { publishSubmissionUpdate } from "./events"
|
||||
import type { JudgeJobData } from "./job"
|
||||
import { languageConfigs } from "./languages"
|
||||
import {
|
||||
isAccepted,
|
||||
JudgeStatus,
|
||||
type JudgeStatusValue,
|
||||
} from "./status"
|
||||
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
|
||||
import { parseProblemTemplate } from "./template"
|
||||
import { runSqlCase } from "./sql"
|
||||
import { readInfo } from "../services/test-case"
|
||||
@@ -79,7 +78,8 @@ async function requestJudge(
|
||||
testCaseId: string,
|
||||
) {
|
||||
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")
|
||||
.update(config.judgeServerToken)
|
||||
@@ -173,8 +173,7 @@ async function persistResult(
|
||||
.update(schema.problem)
|
||||
.set({
|
||||
submissionNumber: problem.submissionNumber + 1,
|
||||
acceptedNumber:
|
||||
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
|
||||
acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
|
||||
statisticInfo: problemStatistics,
|
||||
})
|
||||
.where(eq(schema.problem.id, problemId))
|
||||
@@ -233,7 +232,10 @@ async function persistResult(
|
||||
submissionInfo: {},
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
|
||||
target: [
|
||||
schema.acmContestRank.contestId,
|
||||
schema.acmContestRank.userId,
|
||||
],
|
||||
})
|
||||
|
||||
const [rank] = await tx
|
||||
@@ -267,7 +269,8 @@ async function persistResult(
|
||||
const acTime = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) /
|
||||
(Date.parse(submissionCreateTime) -
|
||||
Date.parse(contest.startTime)) /
|
||||
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 updated = await db
|
||||
.update(schema.submission)
|
||||
@@ -337,7 +344,10 @@ async function markSystemError(submissionId: string, userId: number, error: unkn
|
||||
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
|
||||
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
|
||||
*/
|
||||
export async function failAbandonedSubmission(submissionId: string, error: unknown) {
|
||||
export async function failAbandonedSubmission(
|
||||
submissionId: string,
|
||||
error: unknown,
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({ userId: schema.submission.userId })
|
||||
.from(schema.submission)
|
||||
@@ -354,7 +364,10 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
problem: schema.problem,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.submission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.id, job.submissionId),
|
||||
@@ -364,7 +377,11 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
.limit(1)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -391,15 +408,16 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
|
||||
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
|
||||
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
|
||||
const response = row.submission.language === "SQL"
|
||||
? await judgeSqlSubmission(row.problem, row.submission.code)
|
||||
: await requestJudge(
|
||||
row.submission.language,
|
||||
source,
|
||||
row.problem.timeLimit,
|
||||
row.problem.memoryLimit,
|
||||
row.problem.testCaseId,
|
||||
)
|
||||
const response =
|
||||
row.submission.language === "SQL"
|
||||
? await judgeSqlSubmission(row.problem, row.submission.code)
|
||||
: await requestJudge(
|
||||
row.submission.language,
|
||||
source,
|
||||
row.problem.timeLimit,
|
||||
row.problem.memoryLimit,
|
||||
row.problem.testCaseId,
|
||||
)
|
||||
|
||||
let result: JudgeStatusValue
|
||||
let info: unknown = {}
|
||||
@@ -422,11 +440,19 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
(left, right) => Number(left.test_case) - Number(right.test_case),
|
||||
)
|
||||
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)
|
||||
statisticInfo = {
|
||||
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
|
||||
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
|
||||
time_cost: Math.max(
|
||||
0,
|
||||
...cases.map((item) => Number(item.cpu_time) || 0),
|
||||
),
|
||||
memory_cost: Math.max(
|
||||
0,
|
||||
...cases.map((item) => Number(item.memory) || 0),
|
||||
),
|
||||
score: 0,
|
||||
}
|
||||
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
|
||||
@@ -435,7 +461,8 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
const failedMessage = cases.find(
|
||||
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
|
||||
)?.error_message
|
||||
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
|
||||
if (typeof failedMessage === "string")
|
||||
statisticInfo.err_info = failedMessage
|
||||
|
||||
if (result === JudgeStatus.ACCEPTED) {
|
||||
const rules = astRulesForLanguage(
|
||||
@@ -483,43 +510,60 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
row.submission.createTime,
|
||||
)
|
||||
if (earned.length > 0) {
|
||||
await publishAchievementNotification(row.submission.userId, earned.map((badge) => ({
|
||||
id: badge.id,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
rarity: "bronze",
|
||||
kind: "badge",
|
||||
})))
|
||||
await publishAchievementNotification(
|
||||
row.submission.userId,
|
||||
earned.map((badge) => ({
|
||||
id: badge.id,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
rarity: "bronze",
|
||||
kind: "badge",
|
||||
})),
|
||||
)
|
||||
}
|
||||
if (updated > 0) {
|
||||
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
|
||||
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
})))
|
||||
const unlocked = await updateAchievementsForProblemSet(
|
||||
row.submission.userId,
|
||||
)
|
||||
await publishAchievementNotification(
|
||||
row.submission.userId,
|
||||
unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
})),
|
||||
)
|
||||
}
|
||||
} 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 {
|
||||
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
||||
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
})))
|
||||
await publishAchievementNotification(
|
||||
row.submission.userId,
|
||||
unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
})),
|
||||
)
|
||||
} 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, {
|
||||
@@ -528,7 +572,9 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
result,
|
||||
status: "finished",
|
||||
score:
|
||||
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
|
||||
typeof statisticInfo.score === "number"
|
||||
? statisticInfo.score
|
||||
: undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to judge submission ${row.submission.id}`, error)
|
||||
@@ -536,7 +582,6 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
|
||||
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
|
||||
@@ -554,15 +599,23 @@ async function judgeSqlSubmission(
|
||||
const answers = Array.isArray(problem.answers) ? problem.answers : []
|
||||
const refSql = answers
|
||||
.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 标准答案")
|
||||
|
||||
const info = await readInfo(problem.testCaseId)
|
||||
if (!info) throw new Error("测试点信息读取失败")
|
||||
if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
|
||||
if (!info.sql)
|
||||
throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
|
||||
|
||||
// 按 "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("题目没有任何测试点")
|
||||
|
||||
const cases: JudgeCase[] = []
|
||||
@@ -571,7 +624,9 @@ async function judgeSqlSubmission(
|
||||
const initSql = await readFile(
|
||||
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
|
||||
"utf8",
|
||||
).catch(() => { throw new Error(`测试点脚本 ${inputName} 读取失败`) })
|
||||
).catch(() => {
|
||||
throw new Error(`测试点脚本 ${inputName} 读取失败`)
|
||||
})
|
||||
|
||||
const outcome = await runSqlCase({
|
||||
kind: "judge",
|
||||
@@ -585,7 +640,8 @@ async function judgeSqlSubmission(
|
||||
})
|
||||
if (!outcome.ok) {
|
||||
// 初始化/标准答案执行失败属出题配置问题,整题 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({
|
||||
test_case: String(index + 1),
|
||||
|
||||
@@ -29,7 +29,12 @@ export type SqlJob =
|
||||
timeLimitMs: number
|
||||
memoryLimitMb: number
|
||||
}
|
||||
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
|
||||
| {
|
||||
kind: "display"
|
||||
initSql: string
|
||||
refSql: string
|
||||
mode: "query" | "modify"
|
||||
}
|
||||
|
||||
/**
|
||||
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
|
||||
@@ -90,10 +95,13 @@ export async function runSqlChild() {
|
||||
// WASM 堆触顶时 emscripten 抛的是普通 Error("Aborted"/"out of memory"),
|
||||
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
|
||||
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({
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,10 +45,17 @@ const DISPLAY_ROW_LIMIT = 20
|
||||
const ERROR_MESSAGE_MAX_LEN = 200
|
||||
|
||||
/** 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 {
|
||||
constructor(readonly result: JudgeStatusValue, readonly detail: string) {
|
||||
constructor(
|
||||
readonly result: JudgeStatusValue,
|
||||
readonly detail: string,
|
||||
) {
|
||||
super(detail)
|
||||
}
|
||||
}
|
||||
@@ -82,9 +89,11 @@ type Canonical = string
|
||||
*/
|
||||
function canonicalValue(value: unknown): Canonical {
|
||||
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 (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")
|
||||
return `num:${formatG6(value)}`
|
||||
}
|
||||
@@ -96,7 +105,10 @@ function canonicalValue(value: unknown): Canonical {
|
||||
function formatG6(value: number) {
|
||||
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
|
||||
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)
|
||||
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
|
||||
@@ -138,9 +150,11 @@ interface PreparedStatement {
|
||||
}
|
||||
|
||||
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
|
||||
return (db as unknown as {
|
||||
iterateStatements(sql: string): Iterable<PreparedStatement>
|
||||
}).iterateStatements(script)
|
||||
return (
|
||||
db as unknown as {
|
||||
iterateStatements(sql: string): Iterable<PreparedStatement>
|
||||
}
|
||||
).iterateStatements(script)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,9 +171,17 @@ function leadingKeyword(statement: PreparedStatement) {
|
||||
}
|
||||
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
|
||||
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)
|
||||
: 8 // 数字和 NULL 按定长算,撑不出内存
|
||||
if (bytes > this.maxBytes) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||
"单个数据值超出内存限制",
|
||||
)
|
||||
}
|
||||
this.used += bytes
|
||||
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)
|
||||
rows.push(canonicalRow(row))
|
||||
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 }
|
||||
} else {
|
||||
while (statement.step()) { /* 无结果集语句,推进到结束 */ }
|
||||
while (statement.step()) {
|
||||
/* 无结果集语句,推进到结束 */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
statement.free()
|
||||
@@ -242,7 +275,10 @@ function executeStatements(
|
||||
|
||||
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
|
||||
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[] }> = {}
|
||||
for (const table of names) {
|
||||
const quoted = String(table).replaceAll('"', '""')
|
||||
@@ -253,7 +289,10 @@ function dumpTables(db: Database, budget?: ByteBudget) {
|
||||
return canonicalRow(row as unknown[])
|
||||
})
|
||||
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)] = {
|
||||
// 空表 exec 不返回结果,列数用 table_info 兜底
|
||||
@@ -278,14 +317,25 @@ function trustedErrorText(message: string) {
|
||||
}
|
||||
|
||||
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
|
||||
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) {
|
||||
function executeTrusted(
|
||||
db: Database,
|
||||
script: string,
|
||||
deadline: number,
|
||||
prefix: string,
|
||||
) {
|
||||
try {
|
||||
return executeStatements(db, script, deadline)
|
||||
} catch (error) {
|
||||
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 白名单
|
||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||
// 把题目的 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 {
|
||||
const last = executeStatements(db, script, deadline, (statement) => {
|
||||
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
|
||||
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
|
||||
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
|
||||
if (leadingKeyword(statement) === "PRAGMA") {
|
||||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
|
||||
}
|
||||
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
|
||||
applyLimits(db, memoryLimitMb)
|
||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||
}, budget)
|
||||
const last = executeStatements(
|
||||
db,
|
||||
script,
|
||||
deadline,
|
||||
(statement) => {
|
||||
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
|
||||
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
|
||||
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
|
||||
if (leadingKeyword(statement) === "PRAGMA") {
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.RUNTIME_ERROR,
|
||||
"禁止使用 PRAGMA 语句",
|
||||
)
|
||||
}
|
||||
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
|
||||
applyLimits(db, memoryLimitMb)
|
||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||
},
|
||||
budget,
|
||||
)
|
||||
if (mode === "query") return last
|
||||
return dumpTables(db, budget)
|
||||
} catch (error) {
|
||||
if (error instanceof SqlCaseError) throw error
|
||||
const message = String((error as Error).message)
|
||||
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")) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||
"数据量超出内存限制",
|
||||
)
|
||||
}
|
||||
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
|
||||
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
|
||||
if (
|
||||
message.includes("too big") ||
|
||||
message.includes("out of memory") ||
|
||||
message.includes("Aborted")
|
||||
) {
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||
"单个数据值超出内存限制",
|
||||
)
|
||||
}
|
||||
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))) {
|
||||
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
|
||||
@@ -337,7 +414,11 @@ function runStudent(
|
||||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
|
||||
} finally {
|
||||
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)
|
||||
try {
|
||||
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
||||
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
|
||||
const last = executeTrusted(
|
||||
refDb,
|
||||
refSql,
|
||||
trustedDeadline,
|
||||
"标准答案执行失败",
|
||||
)
|
||||
if (options.mode === "query") {
|
||||
expected = last
|
||||
} else {
|
||||
try {
|
||||
expected = dumpTables(refDb)
|
||||
} catch (error) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`)
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.SYSTEM_ERROR,
|
||||
`标准答案结果超出限制: ${(error as SqlCaseError).detail}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -460,7 +549,13 @@ export async function runCase(
|
||||
} catch (error) {
|
||||
elapsed = Date.now() - start
|
||||
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
|
||||
} finally {
|
||||
@@ -496,20 +591,35 @@ interface DisplayTable {
|
||||
|
||||
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
|
||||
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[] = []
|
||||
for (const raw of names) {
|
||||
const name = String(raw)
|
||||
if (only && !only.has(name)) continue
|
||||
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]),
|
||||
type: String(row[2] ?? ""),
|
||||
}))
|
||||
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0)
|
||||
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 })
|
||||
const total = Number(
|
||||
db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0,
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -546,17 +656,27 @@ export async function buildDisplay(
|
||||
for (const statement of iterate(db, refSql)) {
|
||||
try {
|
||||
const names = statement.getColumnNames()
|
||||
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
|
||||
if (names.length === 0) {
|
||||
while (statement.step()) {
|
||||
/* 无结果集 */
|
||||
}
|
||||
continue
|
||||
}
|
||||
const rows: unknown[][] = []
|
||||
while (statement.step()) {
|
||||
rows.push(statement.get())
|
||||
if (rows.length > ROW_LIMIT) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT} 行`)
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.SYSTEM_ERROR,
|
||||
`标准答案结果超过 ${ROW_LIMIT} 行`,
|
||||
)
|
||||
}
|
||||
}
|
||||
expected = {
|
||||
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,
|
||||
truncated: rows.length > DISPLAY_ROW_LIMIT,
|
||||
}
|
||||
@@ -566,10 +686,16 @@ export async function buildDisplay(
|
||||
}
|
||||
} catch (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) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.SYSTEM_ERROR,
|
||||
"标准答案未产生查询结果集",
|
||||
)
|
||||
}
|
||||
return { tables, expected }
|
||||
}
|
||||
@@ -578,18 +704,31 @@ export async function buildDisplay(
|
||||
executeTrusted(db, refSql, deadline, "标准答案执行失败")
|
||||
const after = dumpTables(db)
|
||||
const changed = new Set<string>()
|
||||
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
|
||||
for (const name of new Set([
|
||||
...Object.keys(before),
|
||||
...Object.keys(after),
|
||||
])) {
|
||||
if (JSON.stringify(before[name]) !== JSON.stringify(after[name]))
|
||||
changed.add(name)
|
||||
}
|
||||
if (changed.size === 0) {
|
||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
|
||||
throw new SqlCaseError(
|
||||
JudgeStatus.SYSTEM_ERROR,
|
||||
"标准答案未修改任何表数据,请检查题目配置",
|
||||
)
|
||||
}
|
||||
const changedTables = dumpDisplayTables(db, changed)
|
||||
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
|
||||
const existing = new Set(changedTables.map((table) => table.name))
|
||||
for (const table of tables) {
|
||||
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 } }
|
||||
|
||||
@@ -85,16 +85,24 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
|
||||
result: JudgeStatus.SYSTEM_ERROR,
|
||||
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 子进程 —— 见文件头「为什么必须有这道闸」。
|
||||
if (process.env[CHILD_MARKER]) {
|
||||
return {
|
||||
ok: false,
|
||||
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))
|
||||
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 = ""
|
||||
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
|
||||
const readStderr = (async () => {
|
||||
@@ -132,7 +143,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
||||
phase = latest
|
||||
if (phase === "student" && budget.studentMs !== null) {
|
||||
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 = ""
|
||||
try {
|
||||
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr])
|
||||
;[stdout] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
readStderr,
|
||||
])
|
||||
await child.exited
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
@@ -160,12 +177,15 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as
|
||||
| { ok: true; case?: CaseResult; display?: unknown }
|
||||
| SqlJobFailure
|
||||
{ ok: true; case?: CaseResult; display?: unknown } | SqlJobFailure
|
||||
if (!parsed.ok) return parsed
|
||||
return { ok: true, value: (parsed.case ?? parsed.display) as T }
|
||||
} 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 决定,就是契约里的
|
||||
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
|
||||
return runJob<SqlDisplay>(
|
||||
|
||||
@@ -16,7 +16,9 @@ export const JudgeStatus = {
|
||||
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
|
||||
|
||||
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,那也是答案对了)
|
||||
|
||||
@@ -38,7 +38,9 @@ switch (command) {
|
||||
// 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。
|
||||
case "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": {
|
||||
const { runSqlChild } = await import("./judge/sql/child")
|
||||
@@ -60,6 +62,8 @@ switch (command) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+275
-102
@@ -42,15 +42,28 @@ import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
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>()
|
||||
|
||||
accountRoutes.post("/users", async (c) => {
|
||||
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||
const parsed = registerRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||
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()
|
||||
@@ -58,7 +71,12 @@ accountRoutes.post("/users", async (c) => {
|
||||
const [duplicate] = await db
|
||||
.select({ username: schema.user.username, email: schema.user.email })
|
||||
.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)
|
||||
if (duplicate?.username.toLowerCase() === username) {
|
||||
return failure(c, 409, "username-exists", "Username already exists")
|
||||
@@ -70,18 +88,21 @@ accountRoutes.post("/users", async (c) => {
|
||||
const now = new Date().toISOString()
|
||||
const password = await hashPassword(parsed.data.password)
|
||||
await db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(schema.user).values({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
rawPassword: parsed.data.password.slice(0, 20),
|
||||
lastLogin: null,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
isDisabled: false,
|
||||
problemPermission: "None",
|
||||
className: null,
|
||||
}).returning({ id: schema.user.id })
|
||||
const [created] = await tx
|
||||
.insert(schema.user)
|
||||
.values({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
rawPassword: parsed.data.password.slice(0, 20),
|
||||
lastLogin: null,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
isDisabled: false,
|
||||
problemPermission: "None",
|
||||
className: null,
|
||||
})
|
||||
.returning({ id: schema.user.id })
|
||||
if (!created) throw new Error("User insert did not return an id")
|
||||
await tx.insert(schema.userProfile).values({
|
||||
userId: created.id,
|
||||
@@ -101,31 +122,57 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
||||
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
|
||||
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
|
||||
if (!c.get("user")) return success(c, null)
|
||||
const [target] = await db.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)
|
||||
const [target] = await db
|
||||
.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")
|
||||
const profile = await getUserProfileById(target.id, c.get("user")?.id === target.id)
|
||||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
const profile = await getUserProfileById(
|
||||
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)
|
||||
})
|
||||
|
||||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||||
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
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]),
|
||||
const parsed = updateProfileRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
if (!(image instanceof File)) 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")
|
||||
if (!(image instanceof File))
|
||||
return failure(c, 400, "invalid-file", "Invalid file content")
|
||||
if (image.size > 2 * 1024 * 1024)
|
||||
return failure(c, 400, "file-too-large", "Picture is too large")
|
||||
const extension = extname(image.name).toLowerCase()
|
||||
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
||||
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.write(resolve(directory, filename), image)
|
||||
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 })
|
||||
})
|
||||
|
||||
accountRoutes.get("/users/:id/metrics", async (c) => {
|
||||
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)
|
||||
.where(and(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)
|
||||
.where(
|
||||
and(
|
||||
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) => {
|
||||
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 })
|
||||
|
||||
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
|
||||
@@ -188,14 +256,22 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
|
||||
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline)
|
||||
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))
|
||||
.where(leaderboardWhere).then(([row]) => row),
|
||||
pageLimit === 0 ? [] : db
|
||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||||
.limit(pageLimit).offset(offset),
|
||||
.where(leaderboardWhere)
|
||||
.then(([row]) => row),
|
||||
pageLimit === 0
|
||||
? []
|
||||
: db
|
||||
.select({ profile: schema.userProfile, user: schema.user })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere)
|
||||
.orderBy(...leaderboardOrder)
|
||||
.limit(pageLimit)
|
||||
.offset(offset),
|
||||
myLeaderboardRank(c.get("user")?.id),
|
||||
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
|
||||
])
|
||||
@@ -207,10 +283,16 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
} satisfies UserRank)
|
||||
})
|
||||
|
||||
function serializeRankRow({ profile, user }: {
|
||||
profile: typeof schema.userProfile.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
}, online: Set<number> | null = null) {
|
||||
function serializeRankRow(
|
||||
{
|
||||
profile,
|
||||
user,
|
||||
}: {
|
||||
profile: typeof schema.userProfile.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
},
|
||||
online: Set<number> | null = null,
|
||||
) {
|
||||
return {
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
@@ -231,26 +313,35 @@ function serializeRankRow({ profile, user }: {
|
||||
async function myLeaderboardRank(userId: number | undefined) {
|
||||
if (!userId) return null
|
||||
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))
|
||||
.where(and(leaderboardWhere, eq(schema.user.id, userId))).limit(1)
|
||||
.where(and(leaderboardWhere, eq(schema.user.id, userId)))
|
||||
.limit(1)
|
||||
if (!mine) return null
|
||||
|
||||
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))
|
||||
.where(and(leaderboardWhere, or(
|
||||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||||
leaderboardWhere,
|
||||
or(
|
||||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||||
),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||||
lt(schema.user.id, userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||||
lt(schema.user.id, userId),
|
||||
),
|
||||
)))
|
||||
)
|
||||
|
||||
return {
|
||||
...serializeRankRow(mine),
|
||||
@@ -260,7 +351,8 @@ async function myLeaderboardRank(userId: number | undefined) {
|
||||
|
||||
accountRoutes.get("/rankings/activity", async (c) => {
|
||||
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` 分组的话,
|
||||
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
|
||||
@@ -268,43 +360,105 @@ accountRoutes.get("/rankings/activity", async (c) => {
|
||||
*
|
||||
* 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)
|
||||
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
||||
.where(and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, start),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
ne(schema.user.adminType, "Super Admin"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, start),
|
||||
inArray(schema.submission.result, [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
ne(schema.user.adminType, "Super Admin"),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.user.username)
|
||||
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
||||
return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem)))
|
||||
.orderBy(desc(countDistinct(schema.submission.problemId)))
|
||||
.limit(10)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
username: row.username,
|
||||
count: row.value,
|
||||
}) satisfies ActivityRankItem,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [problem] = await db.select({ id: schema.problem.id }).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 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 [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.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 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 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
|
||||
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 }]
|
||||
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)))
|
||||
let rank = -1
|
||||
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
|
||||
}
|
||||
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,25 +473,44 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
|
||||
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
|
||||
*/
|
||||
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id)).limit(1)
|
||||
const status = objectValue(profile?.value)
|
||||
const problems = objectValue(status.problems)
|
||||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||||
if (ids.length > 0) {
|
||||
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.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)) {
|
||||
const item = objectValue(value)
|
||||
const displayId = displayIds.get(id)
|
||||
if (displayId) item._id = displayId
|
||||
problems[id] = item
|
||||
accountRoutes.post(
|
||||
"/me/problem-display-ids/refresh",
|
||||
requireAuth,
|
||||
async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [profile] = await db
|
||||
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const status = objectValue(profile?.value)
|
||||
const problems = objectValue(status.problems)
|
||||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||||
if (ids.length > 0) {
|
||||
const rows = await db
|
||||
.select({ id: schema.problem.id, displayId: schema.problem.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)) {
|
||||
const item = objectValue(value)
|
||||
const displayId = displayIds.get(id)
|
||||
if (displayId) item._id = displayId
|
||||
problems[id] = item
|
||||
}
|
||||
status.problems = problems
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set({ acmProblemsStatus: status })
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
}
|
||||
status.problems = problems
|
||||
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,16 +17,29 @@ export const achievementRoutes = new Hono<AppEnv>()
|
||||
|
||||
async function resolveUser(requested: string | undefined, currentId: number) {
|
||||
if (!requested) {
|
||||
const [current] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
||||
.where(eq(schema.user.id, currentId)).limit(1)
|
||||
const [current] = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, currentId))
|
||||
.limit(1)
|
||||
return current ?? null
|
||||
}
|
||||
const [target] = await db.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)
|
||||
const [target] = await db
|
||||
.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
|
||||
}
|
||||
|
||||
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
|
||||
function pendingData(row: {
|
||||
achievement: typeof schema.achievement.$inferSelect
|
||||
}) {
|
||||
return {
|
||||
id: row.achievement.id,
|
||||
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)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
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.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)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.achievement)
|
||||
.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 metrics = objectValue(statRows[0]?.metrics)
|
||||
@@ -66,22 +93,50 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
unlockTime: record?.unlockTime ?? null,
|
||||
backfilled: record?.backfilled ?? false,
|
||||
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
|
||||
})
|
||||
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) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
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.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)),
|
||||
db
|
||||
.select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
|
||||
.from(schema.achievement)
|
||||
.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 total = achievements.length
|
||||
const unlocked = unlockedRows.length
|
||||
@@ -89,33 +144,54 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
||||
username: target.username,
|
||||
total,
|
||||
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,
|
||||
label: labels[rarity],
|
||||
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),
|
||||
} satisfies AchievementSummary)
|
||||
})
|
||||
|
||||
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||
const rows = await 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, c.get("user")!.id), eq(schema.userAchievement.notified, false), eq(schema.achievement.visible, true)))
|
||||
const rows = await 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, c.get("user")!.id),
|
||||
eq(schema.userAchievement.notified, false),
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.userAchievement.unlockTime))
|
||||
return success(c, rows.map(pendingData))
|
||||
})
|
||||
|
||||
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
|
||||
const parsed = markAchievementsReadSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||
const parsed = markAchievementsReadSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||
if (parsed.data.ids.length > 0) {
|
||||
await db.update(schema.userAchievement).set({ notified: true }).where(and(
|
||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
||||
))
|
||||
await db
|
||||
.update(schema.userAchievement)
|
||||
.set({ notified: true })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
||||
),
|
||||
)
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
@@ -14,7 +14,18 @@ import {
|
||||
} from "@oj2/contract"
|
||||
import { randomInt } from "node:crypto"
|
||||
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 { hashPassword } from "../../auth/password"
|
||||
@@ -38,11 +49,16 @@ const CLASS_NAME_MAX_DIGITS = 4
|
||||
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
|
||||
* 对齐旧 `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)
|
||||
if (!matched) return { ok: true, value: null }
|
||||
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 {
|
||||
ok: false,
|
||||
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 的题目权限。
|
||||
*/
|
||||
function normalizePermission(adminType: AdminType, requested: ProblemPermission): ProblemPermission {
|
||||
function normalizePermission(
|
||||
adminType: AdminType,
|
||||
requested: ProblemPermission,
|
||||
): ProblemPermission {
|
||||
if (adminType === "Super Admin") return "All"
|
||||
if (adminType === "Regular User") return "None"
|
||||
return requested || "Own"
|
||||
}
|
||||
|
||||
function serialize(row: {
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
}, isOnline: boolean) {
|
||||
function serialize(
|
||||
row: {
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
},
|
||||
isOnline: boolean,
|
||||
) {
|
||||
return {
|
||||
id: row.user.id,
|
||||
username: row.user.username,
|
||||
@@ -83,10 +105,12 @@ function serialize(row: {
|
||||
}
|
||||
|
||||
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)
|
||||
.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,29 +134,39 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
|
||||
)
|
||||
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.userProfile)
|
||||
.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)
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.userProfile)
|
||||
.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(
|
||||
desc(schema.userProfile.acceptedNumber),
|
||||
asc(schema.userProfile.submissionNumber),
|
||||
asc(schema.user.id),
|
||||
)
|
||||
.limit(limit).offset(offset),
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
return success(c, {
|
||||
results: rows.map(({ profile, user }) => ({
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
acceptedNumber: profile.acceptedNumber,
|
||||
submissionNumber: profile.submissionNumber,
|
||||
mood: profile.mood,
|
||||
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
|
||||
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
|
||||
isOnline: null,
|
||||
} satisfies RankProfile)),
|
||||
results: rows.map(
|
||||
({ profile, user }) =>
|
||||
({
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
acceptedNumber: profile.acceptedNumber,
|
||||
submissionNumber: profile.submissionNumber,
|
||||
mood: profile.mood,
|
||||
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
|
||||
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
|
||||
isOnline: null,
|
||||
}) satisfies RankProfile,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies AdminUserRank)
|
||||
})
|
||||
@@ -147,15 +181,18 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
||||
// 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。
|
||||
// 列加了 $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))
|
||||
}
|
||||
if (keyword) {
|
||||
filters.push(or(
|
||||
ilike(schema.user.username, `%${keyword}%`),
|
||||
ilike(schema.userProfile.realName, `%${keyword}%`),
|
||||
ilike(schema.user.email, `%${keyword}%`),
|
||||
)!)
|
||||
filters.push(
|
||||
or(
|
||||
ilike(schema.user.username, `%${keyword}%`),
|
||||
ilike(schema.userProfile.realName, `%${keyword}%`),
|
||||
ilike(schema.user.email, `%${keyword}%`),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const where = filters.length ? and(...filters) : undefined
|
||||
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
|
||||
@@ -166,23 +203,40 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
||||
// 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来
|
||||
// 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。
|
||||
// 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。
|
||||
const order = orderBy === "-online"
|
||||
? [
|
||||
...(online.size
|
||||
? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`]
|
||||
: []),
|
||||
sql`${schema.user.lastLogin} desc nulls last`,
|
||||
]
|
||||
: orderBy === "-lastLogin"
|
||||
? [sql`${schema.user.lastLogin} desc nulls last`]
|
||||
: [desc(schema.user.createTime)]
|
||||
const order =
|
||||
orderBy === "-online"
|
||||
? [
|
||||
...(online.size
|
||||
? [
|
||||
sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`,
|
||||
]
|
||||
: []),
|
||||
sql`${schema.user.lastLogin} desc nulls last`,
|
||||
]
|
||||
: orderBy === "-lastLogin"
|
||||
? [sql`${schema.user.lastLogin} desc nulls last`]
|
||||
: [desc(schema.user.createTime)]
|
||||
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.user)
|
||||
.leftJoin(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),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.leftJoin(
|
||||
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, {
|
||||
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) => {
|
||||
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) {
|
||||
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 [existing] = await selectUser(id)
|
||||
@@ -209,14 +270,24 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||
const username = data.username.trim().toLowerCase()
|
||||
const email = data.email.trim().toLowerCase()
|
||||
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)
|
||||
.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")
|
||||
const [dupUsername] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.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):存量数据里有大小写混着的邮箱,按原值比会漏掉冲突
|
||||
const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user)
|
||||
.where(and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id))).limit(1)
|
||||
const [dupEmail] = await db
|
||||
.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")
|
||||
|
||||
const patch: Partial<typeof schema.user.$inferInsert> = {
|
||||
@@ -225,7 +296,10 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||
className: className.value,
|
||||
adminType: data.adminType,
|
||||
isDisabled: data.isDisabled,
|
||||
problemPermission: normalizePermission(data.adminType, data.problemPermission),
|
||||
problemPermission: normalizePermission(
|
||||
data.adminType,
|
||||
data.problemPermission,
|
||||
),
|
||||
}
|
||||
if (data.password) {
|
||||
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
|
||||
@@ -248,10 +322,14 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
|
||||
*/
|
||||
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))
|
||||
}
|
||||
await tx.update(schema.userProfile).set({ realName: data.realName })
|
||||
await tx
|
||||
.update(schema.userProfile)
|
||||
.set({ realName: data.realName })
|
||||
.where(eq(schema.userProfile.userId, id))
|
||||
})
|
||||
|
||||
@@ -272,12 +350,26 @@ adminAccountRoutes.put("/users/:id", 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) {
|
||||
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
|
||||
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 才看到报错。
|
||||
@@ -289,48 +381,94 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
|
||||
for (const [username, password, email, realName] of rows) {
|
||||
const name = username.toLowerCase()
|
||||
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()
|
||||
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
|
||||
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
|
||||
// 必然重号。存进去不会报错(库里没有唯一约束),但这两个账号从此**编辑不了**:
|
||||
// PUT 一保存就撞自己的查重回 409,老师只看到「Email already exists」。
|
||||
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 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))
|
||||
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))
|
||||
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)
|
||||
.where(or(
|
||||
inArray(schema.user.username, prepared.map((item) => item.username)),
|
||||
inArray(sql`lower(${schema.user.email})`, prepared.map((item) => item.email)),
|
||||
))
|
||||
.where(
|
||||
or(
|
||||
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 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) {
|
||||
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 clashMails = existing
|
||||
.map((row) => row.email?.toLowerCase())
|
||||
.filter((mail): mail is string => !!mail && takenMails.has(mail))
|
||||
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
|
||||
@@ -339,36 +477,48 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
|
||||
// 固定 4 路并发,瞬时峰值 76MiB 封顶。
|
||||
const HASH_CONCURRENCY = 4
|
||||
let cursor = 0
|
||||
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => {
|
||||
while (cursor < prepared.length) {
|
||||
const item = prepared[cursor++]!
|
||||
item.password = await hashPassword(item.raw)
|
||||
}
|
||||
}))
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(HASH_CONCURRENCY, prepared.length) },
|
||||
async () => {
|
||||
while (cursor < prepared.length) {
|
||||
const item = prepared[cursor++]!
|
||||
item.password = await hashPassword(item.raw)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const users = await tx.insert(schema.user).values(prepared.map((item) => ({
|
||||
username: item.username,
|
||||
password: item.password,
|
||||
rawPassword: item.raw,
|
||||
email: item.email,
|
||||
className: item.className,
|
||||
adminType: "Regular User" as const,
|
||||
problemPermission: "None" as const,
|
||||
createTime: new Date().toISOString(),
|
||||
isDisabled: false,
|
||||
}))).returning({ id: schema.user.id, username: schema.user.username })
|
||||
const users = await tx
|
||||
.insert(schema.user)
|
||||
.values(
|
||||
prepared.map((item) => ({
|
||||
username: item.username,
|
||||
password: item.password,
|
||||
rawPassword: item.raw,
|
||||
email: item.email,
|
||||
className: item.className,
|
||||
adminType: "Regular User" as const,
|
||||
problemPermission: "None" as const,
|
||||
createTime: new Date().toISOString(),
|
||||
isDisabled: false,
|
||||
})),
|
||||
)
|
||||
.returning({ id: schema.user.id, username: schema.user.username })
|
||||
const byName = new Map(users.map((row) => [row.username, row.id]))
|
||||
await tx.insert(schema.userProfile).values(prepared.map((item) => ({
|
||||
userId: byName.get(item.username)!,
|
||||
realName: item.realName,
|
||||
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
|
||||
avatar: "/public/avatar/default.png",
|
||||
acmProblemsStatus: {},
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
})))
|
||||
await tx.insert(schema.userProfile).values(
|
||||
prepared.map((item) => ({
|
||||
userId: byName.get(item.username)!,
|
||||
realName: item.realName,
|
||||
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
|
||||
avatar: "/public/avatar/default.png",
|
||||
acmProblemsStatus: {},
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
})),
|
||||
)
|
||||
return users.length
|
||||
})
|
||||
return success(c, { imported: created }, 201)
|
||||
@@ -380,7 +530,11 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
|
||||
* 删除失败都当成系统故障报 500。
|
||||
*/
|
||||
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
|
||||
}
|
||||
return false
|
||||
@@ -390,11 +544,19 @@ function isForeignKeyViolation(error: unknown) {
|
||||
class UserHasSubmissionsError extends Error {}
|
||||
|
||||
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
||||
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
|
||||
const parsed = deleteUsersRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "ids is required")
|
||||
const me = c.get("user")!.id
|
||||
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 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
|
||||
@@ -425,30 +587,55 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
||||
.limit(1)
|
||||
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 })
|
||||
})
|
||||
return success(c, { deleted: deleted.length })
|
||||
} catch (error) {
|
||||
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
|
||||
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
|
||||
if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error
|
||||
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
|
||||
if (
|
||||
!(error instanceof UserHasSubmissionsError) &&
|
||||
!isForeignKeyViolation(error)
|
||||
)
|
||||
throw error
|
||||
return failure(
|
||||
c,
|
||||
409,
|
||||
"user-in-use",
|
||||
"该用户还有提交、题目等历史数据,无法删除;请改为禁用账号",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [existing] = await db.select({ id: schema.user.id }).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 分不清
|
||||
const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("")
|
||||
await db.update(schema.user).set({
|
||||
password: await hashPassword(password),
|
||||
rawPassword: password,
|
||||
}).where(eq(schema.user.id, id))
|
||||
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
|
||||
await revokeUserSessions(id, "session-ended")
|
||||
return success(c, { password } satisfies ResetPasswordResponse)
|
||||
})
|
||||
adminAccountRoutes.post(
|
||||
"/users/:id/reset-password",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [existing] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.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 分不清
|
||||
const password = Array.from(
|
||||
{ length: 6 },
|
||||
() => "123456789"[randomInt(9)],
|
||||
).join("")
|
||||
await db
|
||||
.update(schema.user)
|
||||
.set({
|
||||
password: await hashPassword(password),
|
||||
rawPassword: password,
|
||||
})
|
||||
.where(eq(schema.user.id, id))
|
||||
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
|
||||
await revokeUserSessions(id, "session-ended")
|
||||
return success(c, { password } satisfies ResetPasswordResponse)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10,7 +10,11 @@ import { Hono } from "hono"
|
||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
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 { queryInteger } from "../helpers"
|
||||
|
||||
@@ -37,75 +41,135 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
|
||||
|
||||
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
|
||||
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
|
||||
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]))
|
||||
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]),
|
||||
)
|
||||
|
||||
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))
|
||||
return success(c, rows.map(serialize))
|
||||
})
|
||||
|
||||
adminAchievementRoutes.get("/achievements/:id", 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", "成就不存在")
|
||||
return success(c, serialize(row))
|
||||
})
|
||||
adminAchievementRoutes.get(
|
||||
"/achievements/:id",
|
||||
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", "成就不存在")
|
||||
return success(c, serialize(row))
|
||||
},
|
||||
)
|
||||
|
||||
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) {
|
||||
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({
|
||||
...parsed.data,
|
||||
unlockCount: 0,
|
||||
createTime: new Date().toISOString(),
|
||||
}).returning()
|
||||
const [created] = await db
|
||||
.insert(schema.achievement)
|
||||
.values({
|
||||
...parsed.data,
|
||||
unlockCount: 0,
|
||||
createTime: new Date().toISOString(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就
|
||||
// 只有从今往后的提交才算,老用户永远拿不到
|
||||
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)
|
||||
})
|
||||
|
||||
adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
|
||||
adminAchievementRoutes.put(
|
||||
"/achievements/:id",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAchievementRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
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)
|
||||
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
|
||||
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", "成就不存在")
|
||||
|
||||
const [after] = await db.update(schema.achievement).set(parsed.data)
|
||||
.where(eq(schema.achievement.id, id)).returning()
|
||||
const [after] = await db
|
||||
.update(schema.achievement)
|
||||
.set(parsed.data)
|
||||
.where(eq(schema.achievement.id, id))
|
||||
.returning()
|
||||
|
||||
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
|
||||
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
|
||||
// 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人),
|
||||
// 只看 operator/threshold 会漏掉这两种。
|
||||
const changed =
|
||||
before.metric !== after!.metric ||
|
||||
before.operator !== after!.operator ||
|
||||
before.threshold !== after!.threshold ||
|
||||
before.visible !== after!.visible
|
||||
if (after!.visible && changed) await rescanAchievement(id)
|
||||
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
|
||||
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
|
||||
// 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人),
|
||||
// 只看 operator/threshold 会漏掉这两种。
|
||||
const changed =
|
||||
before.metric !== after!.metric ||
|
||||
before.operator !== after!.operator ||
|
||||
before.threshold !== after!.threshold ||
|
||||
before.visible !== after!.visible
|
||||
if (after!.visible && changed) await rescanAchievement(id)
|
||||
|
||||
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
|
||||
return success(c, serialize(row!))
|
||||
})
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.achievement)
|
||||
.where(eq(schema.achievement.id, id))
|
||||
.limit(1)
|
||||
return success(c, serialize(row!))
|
||||
},
|
||||
)
|
||||
|
||||
adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE(0010)
|
||||
const deleted = await db.delete(schema.achievement).where(eq(schema.achievement.id, id))
|
||||
.returning({ id: schema.achievement.id })
|
||||
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在")
|
||||
return success(c, null)
|
||||
})
|
||||
adminAchievementRoutes.delete(
|
||||
"/achievements/:id",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE(0010)
|
||||
const deleted = await db
|
||||
.delete(schema.achievement)
|
||||
.where(eq(schema.achievement.id, id))
|
||||
.returning({ id: schema.achievement.id })
|
||||
if (deleted.length === 0)
|
||||
return failure(c, 404, "achievement-not-found", "成就不存在")
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -21,7 +21,13 @@ function excerpt(analysis: string | null) {
|
||||
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 {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
@@ -41,7 +47,9 @@ const listColumns = {
|
||||
|
||||
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
||||
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 返回两种形状,调用方没法照着一个类型写。
|
||||
@@ -49,7 +57,9 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
||||
// 读的是 res.results,于是拿到 undefined,`pinnedReports.length` 在渲染时抛
|
||||
// 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。
|
||||
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))
|
||||
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
|
||||
.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 offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.aiAnalysis)
|
||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).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),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.aiAnalysis)
|
||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
||||
.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, {
|
||||
results: rows.map(listItem),
|
||||
@@ -75,15 +93,20 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
|
||||
const [row] = await db.select({
|
||||
id: schema.aiAnalysis.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
createTime: schema.aiAnalysis.createTime,
|
||||
analysis: schema.aiAnalysis.analysis,
|
||||
}).from(schema.aiAnalysis)
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: schema.aiAnalysis.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
createTime: schema.aiAnalysis.createTime,
|
||||
analysis: schema.aiAnalysis.analysis,
|
||||
})
|
||||
.from(schema.aiAnalysis)
|
||||
.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")
|
||||
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
|
||||
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) => {
|
||||
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 })
|
||||
.from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1)
|
||||
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found")
|
||||
const [report] = await db
|
||||
.select({
|
||||
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
|
||||
await db.transaction(async (tx) => {
|
||||
if (next) {
|
||||
await tx.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: 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)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,11 @@ function serialize(row: {
|
||||
|
||||
function selectOne(id: number) {
|
||||
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)
|
||||
.innerJoin(schema.user, eq(schema.announcement.createdById, 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 [totalRows, rows] = await Promise.all([
|
||||
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)
|
||||
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.announcement.createdById, schema.user.id),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.orderBy(desc(schema.announcement.createTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
@@ -63,52 +78,106 @@ adminAnnouncementRoutes.get("/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) {
|
||||
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 [created] = await db.insert(schema.announcement).values({
|
||||
...parsed.data,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
createdById: c.get("user")!.id,
|
||||
}).returning({ id: schema.announcement.id })
|
||||
const [created] = await db
|
||||
.insert(schema.announcement)
|
||||
.values({
|
||||
...parsed.data,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
createdById: c.get("user")!.id,
|
||||
})
|
||||
.returning({ id: schema.announcement.id })
|
||||
const [row] = await selectOne(created!.id)
|
||||
return success(c, serialize(row!), 201)
|
||||
})
|
||||
|
||||
adminAnnouncementRoutes.get("/announcements/:id", 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))
|
||||
})
|
||||
adminAnnouncementRoutes.get(
|
||||
"/announcements/:id",
|
||||
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))
|
||||
},
|
||||
)
|
||||
|
||||
adminAnnouncementRoutes.put("/announcements/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||
}
|
||||
const updated = await db.update(schema.announcement)
|
||||
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
|
||||
.where(eq(schema.announcement.id, id))
|
||||
.returning({ id: schema.announcement.id })
|
||||
if (updated.length === 0) {
|
||||
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||||
}
|
||||
const [row] = await selectOne(id)
|
||||
return success(c, serialize(row!))
|
||||
})
|
||||
adminAnnouncementRoutes.put(
|
||||
"/announcements/:id",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAnnouncementRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const updated = await db
|
||||
.update(schema.announcement)
|
||||
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
|
||||
.where(eq(schema.announcement.id, id))
|
||||
.returning({ id: schema.announcement.id })
|
||||
if (updated.length === 0) {
|
||||
return failure(
|
||||
c,
|
||||
404,
|
||||
"announcement-not-found",
|
||||
"Announcement does not exist",
|
||||
)
|
||||
}
|
||||
const [row] = await selectOne(id)
|
||||
return success(c, serialize(row!))
|
||||
},
|
||||
)
|
||||
|
||||
adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => {
|
||||
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
|
||||
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
|
||||
const deleted = await db.delete(schema.announcement)
|
||||
.where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
||||
.returning({ id: schema.announcement.id })
|
||||
if (deleted.length === 0) {
|
||||
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
adminAnnouncementRoutes.delete(
|
||||
"/announcements/:id",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
|
||||
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
|
||||
const deleted = await db
|
||||
.delete(schema.announcement)
|
||||
.where(
|
||||
eq(
|
||||
schema.announcement.id,
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
),
|
||||
)
|
||||
.returning({ id: schema.announcement.id })
|
||||
if (deleted.length === 0) {
|
||||
return failure(
|
||||
c,
|
||||
404,
|
||||
"announcement-not-found",
|
||||
"Announcement does not exist",
|
||||
)
|
||||
}
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -14,7 +14,11 @@ import { resolve } from "node:path"
|
||||
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
||||
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 { db, schema } from "../../db"
|
||||
import { publishConfigUpdate } from "../../events"
|
||||
@@ -39,7 +43,9 @@ function aliveSince() {
|
||||
* 于是同一天的心跳永远小于阈值,**所有判题机都会被标成离线**。
|
||||
*/
|
||||
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) => {
|
||||
const parsed = updateWebsiteConfigRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
const parsed = updateWebsiteConfigRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||
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][])
|
||||
.map(([field, key]) => ({ field, key, value: parsed.data[field] }))
|
||||
const entries = (
|
||||
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
|
||||
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
|
||||
// 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({
|
||||
target: schema.optionsSysoptions.key,
|
||||
set: { value: sql`excluded.value` },
|
||||
@@ -89,45 +105,75 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
|
||||
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
|
||||
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 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)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 判题机
|
||||
|
||||
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, {
|
||||
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
|
||||
token: config.judgeServerToken,
|
||||
servers: rows.map((row) => ({
|
||||
...row,
|
||||
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
|
||||
} satisfies JudgeServer)),
|
||||
servers: rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
|
||||
}) satisfies JudgeServer,
|
||||
),
|
||||
} satisfies JudgeServerList)
|
||||
})
|
||||
|
||||
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
|
||||
const parsed = updateJudgeServerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required")
|
||||
const updated = await db.update(schema.judgeServer)
|
||||
const parsed = updateJudgeServerRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "isDisabled is required")
|
||||
const updated = await db
|
||||
.update(schema.judgeServer)
|
||||
.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 })
|
||||
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() 把积压的待判任务重新分发。
|
||||
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
|
||||
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
adminConfRoutes.delete("/judge-servers/:hostname", requireSuperAdmin, async (c) => {
|
||||
const deleted = await db.delete(schema.judgeServer)
|
||||
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
|
||||
.returning({ id: schema.judgeServer.id })
|
||||
if (deleted.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
|
||||
return success(c, null)
|
||||
})
|
||||
adminConfRoutes.delete(
|
||||
"/judge-servers/:hostname",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const deleted = await db
|
||||
.delete(schema.judgeServer)
|
||||
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
|
||||
.returning({ id: schema.judgeServer.id })
|
||||
if (deleted.length === 0)
|
||||
return failure(
|
||||
c,
|
||||
404,
|
||||
"judge-server-not-found",
|
||||
"Judge server does not exist",
|
||||
)
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 孤儿测试用例
|
||||
|
||||
@@ -140,15 +186,24 @@ async function orphanTestCaseIds() {
|
||||
db.select({ id: schema.problem.testCaseId }).from(schema.problem),
|
||||
])
|
||||
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) => {
|
||||
const ids = await orphanTestCaseIds()
|
||||
const rows = await Promise.all(ids.map(async (id) => {
|
||||
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
|
||||
return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase
|
||||
}))
|
||||
const rows = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const info = await stat(resolve(config.testCaseDirectory, id)).catch(
|
||||
() => null,
|
||||
)
|
||||
return {
|
||||
id,
|
||||
createTime: info ? info.mtimeMs / 1000 : 0,
|
||||
} satisfies OrphanTestCase
|
||||
}),
|
||||
)
|
||||
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
|
||||
if (requested && targets.length === 0) {
|
||||
return failure(c, 404, "not-an-orphan", "该用例目录不存在或仍被题目引用,未删除")
|
||||
return failure(
|
||||
c,
|
||||
404,
|
||||
"not-an-orphan",
|
||||
"该用例目录不存在或仍被题目引用,未删除",
|
||||
)
|
||||
}
|
||||
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 })
|
||||
})
|
||||
@@ -173,11 +236,17 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
||||
const now = new Date().toISOString()
|
||||
const [[users], [submissions], [contests], [servers]] = await Promise.all([
|
||||
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())),
|
||||
db.select({ value: count() }).from(schema.contest)
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.contest)
|
||||
.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())),
|
||||
])
|
||||
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
||||
@@ -195,10 +264,16 @@ adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
|
||||
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
|
||||
const classroom = c.req.query("classroom")?.trim()
|
||||
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}%`))
|
||||
.orderBy(sql`random()`).limit(10)
|
||||
return success(c, rows.map((row) => row.username))
|
||||
.orderBy(sql`random()`)
|
||||
.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 image = form?.get("image")
|
||||
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()
|
||||
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 兜。这里显式限一道:文件写在本地磁盘上,
|
||||
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
|
||||
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}`
|
||||
@@ -237,7 +324,11 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
||||
await Bun.write(resolve(config.uploadDirectory, name), image)
|
||||
} catch (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, {
|
||||
success: true,
|
||||
@@ -247,6 +338,8 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
||||
})
|
||||
|
||||
function randomFileName() {
|
||||
return Array.from({ length: 10 }, () =>
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("")
|
||||
return Array.from(
|
||||
{ length: 10 },
|
||||
() => "abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)],
|
||||
).join("")
|
||||
}
|
||||
|
||||
@@ -49,18 +49,25 @@ async function serialize(row: {
|
||||
}
|
||||
|
||||
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)
|
||||
.innerJoin(schema.user, eq(schema.contest.createdById, 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 }) {
|
||||
const start = Date.parse(data.startTime)
|
||||
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"
|
||||
return null
|
||||
}
|
||||
@@ -71,18 +78,30 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
|
||||
const user = c.get("user")!
|
||||
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()
|
||||
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
||||
const where = filters.length ? and(...filters) : undefined
|
||||
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
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)
|
||||
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset),
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.contest.createTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: await Promise.all(rows.map(serialize)),
|
||||
@@ -91,7 +110,9 @@ adminContestRoutes.get("/contests", 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)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
@@ -99,36 +120,53 @@ adminContestRoutes.get("/contests/:id", 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) {
|
||||
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)
|
||||
if (error) return failure(c, 400, "invalid-contest", error)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const [created] = await db.insert(schema.contest).values({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
tag: parsed.data.tag,
|
||||
startTime: new Date(parsed.data.startTime).toISOString(),
|
||||
endTime: new Date(parsed.data.endTime).toISOString(),
|
||||
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
|
||||
password: parsed.data.password || null,
|
||||
visible: parsed.data.visible,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
}).returning({ id: schema.contest.id })
|
||||
const [created] = await db
|
||||
.insert(schema.contest)
|
||||
.values({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
tag: parsed.data.tag,
|
||||
startTime: new Date(parsed.data.startTime).toISOString(),
|
||||
endTime: new Date(parsed.data.endTime).toISOString(),
|
||||
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
|
||||
password: parsed.data.password || null,
|
||||
visible: parsed.data.visible,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
.returning({ id: schema.contest.id })
|
||||
const [row] = await selectContest(created!.id)
|
||||
return success(c, await serialize(row!), 201)
|
||||
})
|
||||
|
||||
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateContestRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
const parsed = updateContestRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const [existing] = await selectContest(id)
|
||||
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
|
||||
@@ -137,16 +175,19 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
||||
const error = validatePayload(parsed.data)
|
||||
if (error) return failure(c, 400, "invalid-contest", error)
|
||||
|
||||
await db.update(schema.contest).set({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
tag: parsed.data.tag,
|
||||
startTime: new Date(parsed.data.startTime).toISOString(),
|
||||
endTime: new Date(parsed.data.endTime).toISOString(),
|
||||
password: parsed.data.password || null,
|
||||
visible: parsed.data.visible,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
}).where(eq(schema.contest.id, id))
|
||||
await db
|
||||
.update(schema.contest)
|
||||
.set({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description,
|
||||
tag: parsed.data.tag,
|
||||
startTime: new Date(parsed.data.startTime).toISOString(),
|
||||
endTime: new Date(parsed.data.endTime).toISOString(),
|
||||
password: parsed.data.password || null,
|
||||
visible: parsed.data.visible,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.contest.id, id))
|
||||
const [row] = await selectContest(id)
|
||||
return success(c, await serialize(row!))
|
||||
})
|
||||
@@ -169,9 +210,12 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||
//
|
||||
// 已知的副作用,别当成 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 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
|
||||
// 又不至于建出一个已经结束的比赛
|
||||
const start = new Date(Date.now() + 10 * 60 * 1000)
|
||||
@@ -180,51 +224,79 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||
const me = c.get("user")!.id
|
||||
|
||||
const cloned = await db.transaction(async (tx) => {
|
||||
const [contest] = await tx.insert(schema.contest).values({
|
||||
title: original.contest.title,
|
||||
description: original.contest.description,
|
||||
tag: original.contest.tag,
|
||||
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
|
||||
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
|
||||
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
|
||||
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
|
||||
password: null,
|
||||
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
|
||||
visible: false,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
createdById: me,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
}).returning({ id: schema.contest.id })
|
||||
const [contest] = await tx
|
||||
.insert(schema.contest)
|
||||
.values({
|
||||
title: original.contest.title,
|
||||
description: original.contest.description,
|
||||
tag: original.contest.tag,
|
||||
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
|
||||
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
|
||||
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
|
||||
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
|
||||
password: null,
|
||||
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
|
||||
visible: false,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
createdById: me,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
.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))
|
||||
if (problems.length === 0) return contest!.id
|
||||
|
||||
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
|
||||
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
|
||||
const copies = await tx.insert(schema.problem).values(problems.map(({ id: _oldId, ...rest }) => ({
|
||||
...rest,
|
||||
contestId: contest!.id,
|
||||
// 计数器归零:克隆的是题面,不是历史战绩
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
createdById: me,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
}))).returning({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
|
||||
const copies = await tx
|
||||
.insert(schema.problem)
|
||||
.values(
|
||||
problems.map(({ id: _oldId, ...rest }) => ({
|
||||
...rest,
|
||||
contestId: contest!.id,
|
||||
// 计数器归零:克隆的是题面,不是历史战绩
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
createdById: me,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})),
|
||||
)
|
||||
.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) 对应这里手工复制关系行
|
||||
const tags = await tx.select({ problemId: schema.problemTags.problemId, tagId: schema.problemTags.problemtagId })
|
||||
.from(schema.problemTags).where(inArray(schema.problemTags.problemId, problems.map((problem) => problem.id)))
|
||||
const tags = await tx
|
||||
.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) {
|
||||
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 newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? "")
|
||||
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }]
|
||||
const newId = newIdByDisplayId.get(
|
||||
displayIdByOldId.get(tag.problemId) ?? "",
|
||||
)
|
||||
return newId === undefined
|
||||
? []
|
||||
: [{ problemId: newId, problemtagId: tag.tagId }]
|
||||
})
|
||||
if (links.length) await tx.insert(schema.problemTags).values(links)
|
||||
}
|
||||
@@ -237,82 +309,128 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||
|
||||
// ---------------------------------------------------------------- ACM 赛后核查
|
||||
|
||||
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
|
||||
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
|
||||
const [contest] = await db.select().from(schema.contest)
|
||||
.where(eq(schema.contest.id, id)).limit(1)
|
||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
|
||||
const [problems, ranks] = await Promise.all([
|
||||
db.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||
.from(schema.problem).where(eq(schema.problem.contestId, id)),
|
||||
db.select({
|
||||
id: schema.acmContestRank.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
submissionInfo: schema.acmContestRank.submissionInfo,
|
||||
acceptedNumber: schema.acmContestRank.acceptedNumber,
|
||||
}).from(schema.acmContestRank)
|
||||
.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)),
|
||||
])
|
||||
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
|
||||
|
||||
const results = []
|
||||
for (const rank of ranks) {
|
||||
if (rank.acceptedNumber <= 0) continue
|
||||
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
|
||||
if (info.is_ac !== true) continue
|
||||
results.push({
|
||||
id: rank.id,
|
||||
username: rank.username,
|
||||
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
|
||||
// 接口已由 requireTeacher + ownedBy 双重把关。
|
||||
realName: rank.realName,
|
||||
problemId,
|
||||
problemDisplayId: displayIds.get(problemId) ?? problemId,
|
||||
acInfo: info,
|
||||
checked: info.checked === true,
|
||||
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
|
||||
})
|
||||
adminContestRoutes.get(
|
||||
"/contests/:id/acm-helper",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
|
||||
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
|
||||
const [contest] = await db
|
||||
.select()
|
||||
.from(schema.contest)
|
||||
.where(eq(schema.contest.id, id))
|
||||
.limit(1)
|
||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
}
|
||||
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
|
||||
results.sort((left, right) => right._acTime - left._acTime)
|
||||
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
|
||||
})
|
||||
|
||||
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAcmHelperRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||
}
|
||||
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
|
||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
|
||||
// 那样带上任意 rank_id 就能改别的比赛的核查标记
|
||||
const [rank] = await db.select().from(schema.acmContestRank).where(and(
|
||||
eq(schema.acmContestRank.id, parsed.data.rankId),
|
||||
eq(schema.acmContestRank.contestId, contestId),
|
||||
)).limit(1)
|
||||
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
|
||||
const [problems, ranks] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||
.from(schema.problem)
|
||||
.where(eq(schema.problem.contestId, id)),
|
||||
db
|
||||
.select({
|
||||
id: schema.acmContestRank.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
submissionInfo: schema.acmContestRank.submissionInfo,
|
||||
acceptedNumber: schema.acmContestRank.acceptedNumber,
|
||||
})
|
||||
.from(schema.acmContestRank)
|
||||
.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)),
|
||||
])
|
||||
const displayIds = new Map(
|
||||
problems.map((problem) => [String(problem.id), problem.displayId]),
|
||||
)
|
||||
|
||||
const info = rank.submissionInfo
|
||||
const entry = info[parsed.data.problemId]
|
||||
if (!entry) {
|
||||
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
|
||||
}
|
||||
entry.checked = parsed.data.checked
|
||||
info[parsed.data.problemId] = entry
|
||||
await db.update(schema.acmContestRank).set({ submissionInfo: info })
|
||||
.where(eq(schema.acmContestRank.id, rank.id))
|
||||
return success(c, null)
|
||||
})
|
||||
const results = []
|
||||
for (const rank of ranks) {
|
||||
if (rank.acceptedNumber <= 0) continue
|
||||
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
|
||||
if (info.is_ac !== true) continue
|
||||
results.push({
|
||||
id: rank.id,
|
||||
username: rank.username,
|
||||
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
|
||||
// 接口已由 requireTeacher + ownedBy 双重把关。
|
||||
realName: rank.realName,
|
||||
problemId,
|
||||
problemDisplayId: displayIds.get(problemId) ?? problemId,
|
||||
acInfo: info,
|
||||
checked: info.checked === true,
|
||||
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
|
||||
results.sort((left, right) => right._acTime - left._acTime)
|
||||
return success(
|
||||
c,
|
||||
results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
adminContestRoutes.put(
|
||||
"/contests/:id/acm-helper",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAcmHelperRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const [contest] = await db
|
||||
.select()
|
||||
.from(schema.contest)
|
||||
.where(eq(schema.contest.id, contestId))
|
||||
.limit(1)
|
||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
|
||||
// 那样带上任意 rank_id 就能改别的比赛的核查标记
|
||||
const [rank] = await db
|
||||
.select()
|
||||
.from(schema.acmContestRank)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.acmContestRank.id, parsed.data.rankId),
|
||||
eq(schema.acmContestRank.contestId, contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!rank)
|
||||
return failure(c, 404, "rank-not-found", "Rank id does not exist")
|
||||
|
||||
const info = rank.submissionInfo
|
||||
const entry = info[parsed.data.problemId]
|
||||
if (!entry) {
|
||||
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
|
||||
}
|
||||
entry.checked = parsed.data.checked
|
||||
info[parsed.data.problemId] = entry
|
||||
await db
|
||||
.update(schema.acmContestRank)
|
||||
.set({ submissionInfo: info })
|
||||
.where(eq(schema.acmContestRank.id, rank.id))
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
+262
-133
@@ -71,8 +71,12 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
|
||||
|
||||
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
|
||||
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
|
||||
const tutorials = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
|
||||
const tutorials = await db
|
||||
.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)
|
||||
|
||||
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
|
||||
@@ -87,93 +91,160 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
|
||||
// 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上
|
||||
// 就是这个下场,用 filter 也救不回来
|
||||
const [rows, exerciseRows] = await Promise.all([
|
||||
db.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
|
||||
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
|
||||
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
|
||||
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))
|
||||
db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
|
||||
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
|
||||
readCount:
|
||||
sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
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)
|
||||
.where(studentCondition(className.value))
|
||||
.groupBy(schema.user.id, schema.user.username, schema.userProfile.realName, schema.user.className),
|
||||
db.select({
|
||||
userId: schema.exerciseAttempt.userId,
|
||||
tried: count(),
|
||||
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
|
||||
attempts: 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.user.id,
|
||||
schema.user.username,
|
||||
schema.userProfile.realName,
|
||||
schema.user.className,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
userId: schema.exerciseAttempt.userId,
|
||||
tried: count(),
|
||||
solved:
|
||||
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
attempts:
|
||||
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),
|
||||
])
|
||||
const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
|
||||
|
||||
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))
|
||||
: [{ value: 0 }]
|
||||
|
||||
return success(c, {
|
||||
tutorialCount: tutorialIds.length,
|
||||
exerciseCount: exerciseCountRow?.value ?? 0,
|
||||
results: rows.map((row) => ({
|
||||
...row,
|
||||
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
|
||||
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
|
||||
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
|
||||
} satisfies LearnStudentProgress)),
|
||||
results: rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
|
||||
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
|
||||
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
|
||||
}) satisfies LearnStudentProgress,
|
||||
),
|
||||
} satisfies LearnStudentProgressList)
|
||||
})
|
||||
|
||||
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
|
||||
const type = tutorialTypeOf(c.req.query("type"))
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
adminLearnRoutes.get(
|
||||
"/learn-analytics/tutorials",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const type = tutorialTypeOf(c.req.query("type"))
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
|
||||
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
|
||||
.where(studentCondition(className.value))
|
||||
const studentCount = studentCountRow?.value ?? 0
|
||||
const [studentCountRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.where(studentCondition(className.value))
|
||||
const studentCount = studentCountRow?.value ?? 0
|
||||
|
||||
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
|
||||
const rows = await db.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
title: schema.tutorial.title,
|
||||
order: schema.tutorial.order,
|
||||
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
||||
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
|
||||
// 而 progress.user_id 那边永远非空,会把过滤当没发生
|
||||
readers: 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 分钟的人),分子就得是同一批人的时长,
|
||||
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
|
||||
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
|
||||
}).from(schema.tutorial)
|
||||
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
|
||||
.leftJoin(schema.user, and(
|
||||
eq(schema.user.id, schema.tutorialProgress.userId),
|
||||
studentCondition(className.value),
|
||||
))
|
||||
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
|
||||
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
|
||||
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
|
||||
const rows = await db
|
||||
.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
title: schema.tutorial.title,
|
||||
order: schema.tutorial.order,
|
||||
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
||||
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
|
||||
// 而 progress.user_id 那边永远非空,会把过滤当没发生
|
||||
readers:
|
||||
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 分钟的人),分子就得是同一批人的时长,
|
||||
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
|
||||
readSeconds:
|
||||
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.leftJoin(
|
||||
schema.tutorialProgress,
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.user,
|
||||
and(
|
||||
eq(schema.user.id, schema.tutorialProgress.userId),
|
||||
studentCondition(className.value),
|
||||
),
|
||||
)
|
||||
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
|
||||
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
|
||||
.where(
|
||||
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
|
||||
)
|
||||
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
|
||||
return success(c, {
|
||||
studentCount,
|
||||
results: rows.map(({ readSeconds, ...row }) => ({
|
||||
...row,
|
||||
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||
} satisfies LearnTutorialProgress)),
|
||||
} satisfies LearnTutorialProgressList)
|
||||
})
|
||||
return success(c, {
|
||||
studentCount,
|
||||
results: rows.map(
|
||||
({ readSeconds, ...row }) =>
|
||||
({
|
||||
...row,
|
||||
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||
}) satisfies LearnTutorialProgress,
|
||||
),
|
||||
} satisfies LearnTutorialProgressList)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 按练习:哪道练一练卡住了全班。
|
||||
@@ -181,73 +252,131 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
|
||||
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
|
||||
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
|
||||
*/
|
||||
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => {
|
||||
const type = tutorialTypeOf(c.req.query("type"))
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
adminLearnRoutes.get(
|
||||
"/learn-analytics/exercises",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const type = tutorialTypeOf(c.req.query("type"))
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
|
||||
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
|
||||
.where(studentCondition(className.value))
|
||||
const [studentCountRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.where(studentCondition(className.value))
|
||||
|
||||
const rows = await db.select({
|
||||
exerciseId: schema.exercise.id,
|
||||
tutorialId: schema.tutorial.id,
|
||||
tutorialTitle: schema.tutorial.title,
|
||||
tutorialOrder: schema.tutorial.order,
|
||||
type: schema.exercise.type,
|
||||
order: schema.exercise.order,
|
||||
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
|
||||
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
|
||||
triedUsers: sql<number>`count(distinct ${schema.user.id})`.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),
|
||||
}).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 会把没人做过的题整行滤掉
|
||||
.leftJoin(schema.user, and(
|
||||
eq(schema.user.id, schema.exerciseAttempt.userId),
|
||||
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)
|
||||
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
|
||||
const rows = await db
|
||||
.select({
|
||||
exerciseId: schema.exercise.id,
|
||||
tutorialId: schema.tutorial.id,
|
||||
tutorialTitle: schema.tutorial.title,
|
||||
tutorialOrder: schema.tutorial.order,
|
||||
type: schema.exercise.type,
|
||||
order: schema.exercise.order,
|
||||
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
|
||||
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
|
||||
triedUsers: sql<number>`count(distinct ${schema.user.id})`.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,
|
||||
),
|
||||
})
|
||||
.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 会把没人做过的题整行滤掉
|
||||
.leftJoin(
|
||||
schema.user,
|
||||
and(
|
||||
eq(schema.user.id, schema.exerciseAttempt.userId),
|
||||
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,
|
||||
)
|
||||
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
|
||||
|
||||
return success(c, {
|
||||
studentCount: studentCountRow?.value ?? 0,
|
||||
results: rows.map((row) => ({
|
||||
...row,
|
||||
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
|
||||
} satisfies LearnExerciseProgress)),
|
||||
} satisfies LearnExerciseProgressList)
|
||||
})
|
||||
return success(c, {
|
||||
studentCount: studentCountRow?.value ?? 0,
|
||||
results: rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
|
||||
}) satisfies LearnExerciseProgress,
|
||||
),
|
||||
} satisfies LearnExerciseProgressList)
|
||||
},
|
||||
)
|
||||
|
||||
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
|
||||
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
adminLearnRoutes.get(
|
||||
"/learn-analytics/exercises/:id/attempts",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const className = classFilter(c.req.query("className"))
|
||||
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
|
||||
|
||||
const rows = await db.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
attempts: schema.exerciseAttempt.attempts,
|
||||
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
|
||||
solved: schema.exerciseAttempt.solved,
|
||||
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
|
||||
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
|
||||
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
|
||||
}).from(schema.exerciseAttempt)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
|
||||
.leftJoin(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))
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
attempts: schema.exerciseAttempt.attempts,
|
||||
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
|
||||
solved: schema.exerciseAttempt.solved,
|
||||
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
|
||||
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
|
||||
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
|
||||
})
|
||||
.from(schema.exerciseAttempt)
|
||||
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
|
||||
.leftJoin(
|
||||
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),
|
||||
)
|
||||
|
||||
return success(c, rows satisfies LearnExerciseAttempt[])
|
||||
})
|
||||
return success(c, rows satisfies LearnExerciseAttempt[])
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,18 @@ import {
|
||||
type AdminProblemSetProblem,
|
||||
type AdminProblemSetProgress,
|
||||
} 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 { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
@@ -35,9 +46,16 @@ function ownedBy(user: AuthUser, row: { createdById: number }) {
|
||||
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
||||
* 旧后端每个方法开头都手抄一遍这段 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 [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
|
||||
}
|
||||
|
||||
@@ -50,18 +68,45 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||||
if (rows.length === 0) return []
|
||||
const ids = rows.map((row) => row.id)
|
||||
const [problems, participants, creators] = await Promise.all([
|
||||
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
|
||||
.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),
|
||||
db.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
|
||||
.from(schema.problemsetProgress).where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||||
db
|
||||
.select({
|
||||
problemsetId: schema.problemsetProgress.problemsetId,
|
||||
value: count(),
|
||||
})
|
||||
.from(schema.problemsetProgress)
|
||||
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||||
.groupBy(schema.problemsetProgress.problemsetId),
|
||||
db.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, [...new Set(rows.map((row) => row.createdById))])),
|
||||
db
|
||||
.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, [
|
||||
...new Set(rows.map((row) => row.createdById)),
|
||||
]),
|
||||
),
|
||||
])
|
||||
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
|
||||
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value]))
|
||||
const problemsBySet = new Map(
|
||||
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]))
|
||||
return rows.map((row) => {
|
||||
const creator = creatorById.get(row.createdById)
|
||||
@@ -73,7 +118,10 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||||
status: row.status,
|
||||
endTime: row.endTime,
|
||||
visible: row.visible,
|
||||
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
|
||||
createdBy: sampleUser(
|
||||
creator ?? { id: row.createdById, username: "" },
|
||||
creator?.realName,
|
||||
),
|
||||
createTime: row.createTime,
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
problemsCount: problemsBySet.get(row.id) ?? 0,
|
||||
@@ -92,24 +140,33 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||||
// 注意:这里**不过滤 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 difficulty = c.req.query("difficulty")?.trim()
|
||||
const status = c.req.query("status")?.trim()
|
||||
if (keyword) {
|
||||
filters.push(or(
|
||||
ilike(schema.problemset.title, `%${keyword}%`),
|
||||
ilike(schema.problemset.description, `%${keyword}%`),
|
||||
)!)
|
||||
filters.push(
|
||||
or(
|
||||
ilike(schema.problemset.title, `%${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)))
|
||||
const where = filters.length ? and(...filters) : undefined
|
||||
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
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, {
|
||||
results: await serializeMany(rows),
|
||||
@@ -118,18 +175,30 @@ adminProblemSetRoutes.get("/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) {
|
||||
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 [created] = await db.insert(schema.problemset).values({
|
||||
...parsed.data,
|
||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
}).returning()
|
||||
const [created] = await db
|
||||
.insert(schema.problemset)
|
||||
.values({
|
||||
...parsed.data,
|
||||
endTime: parsed.data.endTime
|
||||
? new Date(parsed.data.endTime).toISOString()
|
||||
: null,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
.returning()
|
||||
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) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
const parsed = updateProblemSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
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({
|
||||
...parsed.data,
|
||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
}).where(eq(schema.problemset.id, row.id)).returning()
|
||||
const [updated] = await db
|
||||
.update(schema.problemset)
|
||||
.set({
|
||||
...parsed.data,
|
||||
endTime: parsed.data.endTime
|
||||
? new Date(parsed.data.endTime).toISOString()
|
||||
: null,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.problemset.id, row.id))
|
||||
.returning()
|
||||
return success(c, await serialize(updated!))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
|
||||
const [updated] = await db.update(schema.problemset)
|
||||
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
|
||||
.where(eq(schema.problemset.id, row.id)).returning()
|
||||
return success(c, await serialize(updated!))
|
||||
})
|
||||
adminProblemSetRoutes.put(
|
||||
"/problem-sets/:id/visibility",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
|
||||
const [updated] = await db
|
||||
.update(schema.problemset)
|
||||
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
|
||||
.where(eq(schema.problemset.id, row.id))
|
||||
.returning()
|
||||
return success(c, await serialize(updated!))
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetStatusRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) 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!))
|
||||
})
|
||||
adminProblemSetRoutes.put(
|
||||
"/problem-sets/:id/status",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetStatusRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
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!))
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
@@ -186,95 +286,175 @@ adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
|
||||
|
||||
// ---------------------------------------------------------------- 题单里的题目
|
||||
|
||||
adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const rows = await db.select({ item: schema.problemsetProblem, problem: schema.problem })
|
||||
.from(schema.problemsetProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||
return success(c, rows.map(({ item, problem }) => ({
|
||||
id: item.id,
|
||||
problemsetId: item.problemsetId,
|
||||
problemId: item.problemId,
|
||||
displayId: problem.displayId,
|
||||
title: problem.title,
|
||||
difficulty: problem.difficulty,
|
||||
order: item.order,
|
||||
isRequired: item.isRequired,
|
||||
score: item.score,
|
||||
hint: item.hint,
|
||||
} satisfies AdminProblemSetProblem)))
|
||||
})
|
||||
adminProblemSetRoutes.get(
|
||||
"/problem-sets/:id/problems",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const rows = await db
|
||||
.select({ item: schema.problemsetProblem, problem: schema.problem })
|
||||
.from(schema.problemsetProblem)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.problemsetProblem.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||
.orderBy(
|
||||
asc(schema.problemsetProblem.order),
|
||||
asc(schema.problemsetProblem.id),
|
||||
)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ item, problem }) =>
|
||||
({
|
||||
id: item.id,
|
||||
problemsetId: item.problemsetId,
|
||||
problemId: item.problemId,
|
||||
displayId: problem.displayId,
|
||||
title: problem.title,
|
||||
difficulty: problem.difficulty,
|
||||
order: item.order,
|
||||
isRequired: item.isRequired,
|
||||
score: item.score,
|
||||
hint: item.hint,
|
||||
}) satisfies AdminProblemSetProblem,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = addProblemToSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
)).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
|
||||
adminProblemSetRoutes.post(
|
||||
"/problem-sets/:id/problems",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = addProblemToSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "题目不存在或不可见")
|
||||
|
||||
const [duplicate] = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem)
|
||||
.where(and(
|
||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
||||
eq(schema.problemsetProblem.problemId, problem.id),
|
||||
)).limit(1)
|
||||
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
|
||||
const [duplicate] = await db
|
||||
.select({ id: schema.problemsetProblem.id })
|
||||
.from(schema.problemsetProblem)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
||||
eq(schema.problemsetProblem.problemId, problem.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (duplicate)
|
||||
return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
|
||||
|
||||
const [created] = await db.insert(schema.problemsetProblem).values({
|
||||
problemsetId: row.id,
|
||||
problemId: problem.id,
|
||||
order: parsed.data.order,
|
||||
isRequired: parsed.data.isRequired,
|
||||
score: parsed.data.score,
|
||||
hint: parsed.data.hint,
|
||||
}).returning({ id: schema.problemsetProblem.id })
|
||||
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
|
||||
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
|
||||
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
|
||||
await resyncProgress(row.id)
|
||||
return success(c, { id: created!.id }, 201)
|
||||
})
|
||||
const [created] = await db
|
||||
.insert(schema.problemsetProblem)
|
||||
.values({
|
||||
problemsetId: row.id,
|
||||
problemId: problem.id,
|
||||
order: parsed.data.order,
|
||||
isRequired: parsed.data.isRequired,
|
||||
score: parsed.data.score,
|
||||
hint: parsed.data.hint,
|
||||
})
|
||||
.returning({ id: schema.problemsetProblem.id })
|
||||
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
|
||||
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
|
||||
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
|
||||
await resyncProgress(row.id)
|
||||
return success(c, { id: created!.id }, 201)
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemInSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
|
||||
const updated = await db.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),
|
||||
)).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)
|
||||
return success(c, null)
|
||||
})
|
||||
adminProblemSetRoutes.put(
|
||||
"/problem-sets/:id/problems/:itemId",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemInSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
|
||||
const updated = await db
|
||||
.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),
|
||||
),
|
||||
)
|
||||
.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)
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const deleted = await db.delete(schema.problemsetProblem).where(and(
|
||||
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
|
||||
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", "题目不在该题单中")
|
||||
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
|
||||
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
|
||||
await db.delete(schema.problemsetSubmission).where(and(
|
||||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||||
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
|
||||
))
|
||||
await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
})
|
||||
adminProblemSetRoutes.delete(
|
||||
"/problem-sets/:id/problems/:itemId",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const deleted = await db
|
||||
.delete(schema.problemsetProblem)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
schema.problemsetProblem.id,
|
||||
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
|
||||
),
|
||||
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", "题目不在该题单中")
|
||||
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
|
||||
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
|
||||
await db
|
||||
.delete(schema.problemsetSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||||
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
|
||||
),
|
||||
)
|
||||
await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 奖章
|
||||
|
||||
@@ -285,135 +465,235 @@ async function badgeWithCount(badge: BadgeRow) {
|
||||
/** 批量版:一条 group by 数完整批奖章的获得人数 */
|
||||
async function badgesWithCount(badges: BadgeRow[]) {
|
||||
if (badges.length === 0) return []
|
||||
const earned = await db.select({ badgeId: schema.userBadge.badgeId, value: count() })
|
||||
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)))
|
||||
const earned = await db
|
||||
.select({ badgeId: schema.userBadge.badgeId, value: count() })
|
||||
.from(schema.userBadge)
|
||||
.where(
|
||||
inArray(
|
||||
schema.userBadge.badgeId,
|
||||
badges.map((badge) => badge.id),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.userBadge.badgeId)
|
||||
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
|
||||
return badges.map((badge) => ({
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
conditionType: badge.conditionType,
|
||||
conditionValue: badge.conditionValue,
|
||||
earnedCount: countByBadge.get(badge.id) ?? 0,
|
||||
} satisfies AdminProblemSetBadge))
|
||||
return badges.map(
|
||||
(badge) =>
|
||||
({
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
conditionType: badge.conditionType,
|
||||
conditionValue: badge.conditionValue,
|
||||
earnedCount: countByBadge.get(badge.id) ?? 0,
|
||||
}) satisfies AdminProblemSetBadge,
|
||||
)
|
||||
}
|
||||
|
||||
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const badges = await db.select().from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
|
||||
return success(c, await badgesWithCount(badges))
|
||||
})
|
||||
adminProblemSetRoutes.get(
|
||||
"/problem-sets/:id/badges",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const badges = await db
|
||||
.select()
|
||||
.from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, row.id))
|
||||
.orderBy(asc(schema.problemsetBadge.id))
|
||||
return success(c, await badgesWithCount(badges))
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets/:id/badges", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = createProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [created] = await db.insert(schema.problemsetBadge).values({
|
||||
...parsed.data,
|
||||
problemsetId: row.id,
|
||||
}).returning()
|
||||
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
|
||||
await recalculateBadge(created!)
|
||||
return success(c, await badgeWithCount(created!), 201)
|
||||
})
|
||||
adminProblemSetRoutes.post(
|
||||
"/problem-sets/:id/badges",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = createProblemSetBadgeRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(schema.problemsetBadge)
|
||||
.values({
|
||||
...parsed.data,
|
||||
problemsetId: row.id,
|
||||
})
|
||||
.returning()
|
||||
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
|
||||
await recalculateBadge(created!)
|
||||
return success(c, await badgeWithCount(created!), 201)
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.put("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [updated] = await db.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),
|
||||
)).returning()
|
||||
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||
await recalculateBadge(updated)
|
||||
return success(c, await badgeWithCount(updated))
|
||||
})
|
||||
adminProblemSetRoutes.put(
|
||||
"/problem-sets/:id/badges/:badgeId",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetBadgeRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const [updated] = await db
|
||||
.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),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||
await recalculateBadge(updated)
|
||||
return success(c, await badgeWithCount(updated))
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
|
||||
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
|
||||
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
|
||||
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
|
||||
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
|
||||
const [badge] = await db.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
|
||||
.where(and(
|
||||
eq(schema.problemsetBadge.id, badgeId),
|
||||
eq(schema.problemsetBadge.problemsetId, row.id),
|
||||
)).limit(1)
|
||||
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||
// 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE(0010)
|
||||
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
|
||||
return success(c, null)
|
||||
})
|
||||
adminProblemSetRoutes.delete(
|
||||
"/problem-sets/:id/badges/:badgeId",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
|
||||
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
|
||||
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
|
||||
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
|
||||
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
|
||||
const [badge] = await db
|
||||
.select({ id: schema.problemsetBadge.id })
|
||||
.from(schema.problemsetBadge)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetBadge.id, badgeId),
|
||||
eq(schema.problemsetBadge.problemsetId, row.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||
// 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE(0010)
|
||||
await db
|
||||
.delete(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.id, badge.id))
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 学生进度
|
||||
|
||||
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const rows = await db.select({
|
||||
progress: schema.problemsetProgress,
|
||||
username: schema.user.username,
|
||||
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(eq(schema.problemsetProgress.problemsetId, row.id))
|
||||
.orderBy(desc(schema.problemsetProgress.joinTime))
|
||||
return success(c, rows.map(({ progress, username, realName }) =>
|
||||
({
|
||||
id: progress.id,
|
||||
userId: progress.userId,
|
||||
username,
|
||||
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
|
||||
realName,
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
} satisfies AdminProblemSetProgress)))
|
||||
})
|
||||
adminProblemSetRoutes.get(
|
||||
"/problem-sets/:id/progress",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const rows = await db
|
||||
.select({
|
||||
progress: schema.problemsetProgress,
|
||||
username: schema.user.username,
|
||||
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(eq(schema.problemsetProgress.problemsetId, row.id))
|
||||
.orderBy(desc(schema.problemsetProgress.joinTime))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ progress, username, realName }) =>
|
||||
({
|
||||
id: progress.id,
|
||||
userId: progress.userId,
|
||||
username,
|
||||
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
|
||||
realName,
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
}) satisfies AdminProblemSetProgress,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
|
||||
const badges = await tx.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, row.id))
|
||||
if (badges.length) {
|
||||
await tx.delete(schema.userBadge).where(and(
|
||||
eq(schema.userBadge.userId, userId),
|
||||
inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)),
|
||||
))
|
||||
}
|
||||
await tx.delete(schema.problemsetSubmission).where(and(
|
||||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||||
eq(schema.problemsetSubmission.userId, userId),
|
||||
))
|
||||
return tx.delete(schema.problemsetProgress).where(and(
|
||||
eq(schema.problemsetProgress.problemsetId, row.id),
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
)).returning({ id: schema.problemsetProgress.id })
|
||||
})
|
||||
if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单")
|
||||
return success(c, null)
|
||||
})
|
||||
adminProblemSetRoutes.delete(
|
||||
"/problem-sets/:id/progress/:userId",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
|
||||
const badges = await tx
|
||||
.select({ id: schema.problemsetBadge.id })
|
||||
.from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, row.id))
|
||||
if (badges.length) {
|
||||
await tx.delete(schema.userBadge).where(
|
||||
and(
|
||||
eq(schema.userBadge.userId, userId),
|
||||
inArray(
|
||||
schema.userBadge.badgeId,
|
||||
badges.map((badge) => badge.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
await tx
|
||||
.delete(schema.problemsetSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||||
eq(schema.problemsetSubmission.userId, userId),
|
||||
),
|
||||
)
|
||||
return tx
|
||||
.delete(schema.problemsetProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProgress.problemsetId, row.id),
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: schema.problemsetProgress.id })
|
||||
})
|
||||
if (deleted.length === 0)
|
||||
return failure(c, 404, "progress-not-found", "用户未加入该题单")
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
+334
-152
@@ -9,10 +9,28 @@ import {
|
||||
type RenameTagResponse,
|
||||
type StuckProblem,
|
||||
} 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 { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import {
|
||||
requireProblemPermission,
|
||||
requireTeacher,
|
||||
type AppEnv,
|
||||
} from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
@@ -25,7 +43,11 @@ import { findTagsByName, normalizeTagNames } from "./problem"
|
||||
export const adminTagRoutes = new Hono<AppEnv>()
|
||||
|
||||
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 */
|
||||
function canManageAllProblems(user: AuthUser) {
|
||||
@@ -36,51 +58,91 @@ function canManageAllProblems(user: AuthUser) {
|
||||
|
||||
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const rows = await db.select({
|
||||
id: schema.problemTag.id,
|
||||
name: schema.problemTag.name,
|
||||
problemCount: countDistinct(schema.problemTags.problemId),
|
||||
}).from(schema.problemTag)
|
||||
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problemTag.id,
|
||||
name: schema.problemTag.name,
|
||||
problemCount: countDistinct(schema.problemTags.problemId),
|
||||
})
|
||||
.from(schema.problemTag)
|
||||
.leftJoin(
|
||||
schema.problemTags,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||
// 后台标签管理要看到 problemCount=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[])
|
||||
})
|
||||
|
||||
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = renameTagRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "标签名不能为空")
|
||||
const parsed = renameTagRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "标签名不能为空")
|
||||
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", "标签不存在,请刷新后重试")
|
||||
|
||||
const [target] = await db.select().from(schema.problemTag)
|
||||
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1)
|
||||
const [target] = await db
|
||||
.select()
|
||||
.from(schema.problemTag)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problemTag.name}) = lower(${name})`,
|
||||
ne(schema.problemTag.id, id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!target) {
|
||||
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
|
||||
return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse)
|
||||
await db
|
||||
.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 links = await tx.select({ problemId: schema.problemTags.problemId })
|
||||
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||
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))
|
||||
const links = await tx
|
||||
.select({ problemId: schema.problemTags.problemId })
|
||||
.from(schema.problemTags)
|
||||
.where(eq(schema.problemTags.problemtagId, id))
|
||||
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) 唯一约束
|
||||
const missing = links.filter((link) => !already.has(link.problemId))
|
||||
if (missing.length) {
|
||||
await tx.insert(schema.problemTags).values(missing.map((link) => ({
|
||||
problemId: link.problemId,
|
||||
problemtagId: target.id,
|
||||
})))
|
||||
await tx.insert(schema.problemTags).values(
|
||||
missing.map((link) => ({
|
||||
problemId: link.problemId,
|
||||
problemtagId: target.id,
|
||||
})),
|
||||
)
|
||||
}
|
||||
// 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE(0010)。
|
||||
// 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。
|
||||
@@ -88,93 +150,158 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
return links.length
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
|
||||
const deleted = await db.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||
.returning({ id: schema.problemTag.id })
|
||||
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||
return success(c, null)
|
||||
})
|
||||
adminTagRoutes.delete(
|
||||
"/problem-tags/:id",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
|
||||
const deleted = await db
|
||||
.delete(schema.problemTag)
|
||||
.where(eq(schema.problemTag.id, id))
|
||||
.returning({ id: schema.problemTag.id })
|
||||
if (deleted.length === 0)
|
||||
return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
|
||||
adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) => {
|
||||
const parsed = batchProblemTagRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const user = c.get("user")!
|
||||
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)]
|
||||
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", "没有可操作的题目")
|
||||
adminTagRoutes.post(
|
||||
"/problems/batch-tag",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const parsed = batchProblemTagRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const user = c.get("user")!
|
||||
const filters = [
|
||||
inArray(schema.problem.id, parsed.data.problemIds),
|
||||
isNull(schema.problem.contestId),
|
||||
]
|
||||
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 一致
|
||||
const wanted = normalizeTagNames(parsed.data.tagNames)
|
||||
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
|
||||
const wanted = normalizeTagNames(parsed.data.tagNames)
|
||||
|
||||
const tagIds = await db.transaction(async (tx) => {
|
||||
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
|
||||
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||
if (parsed.data.action === "add") {
|
||||
const missing = wanted.filter((name) => !existing.has(name.toLowerCase()))
|
||||
if (missing.length) {
|
||||
const created = await tx.insert(schema.problemTag).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)
|
||||
const tagIds = await db.transaction(async (tx) => {
|
||||
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
|
||||
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||
if (parsed.data.action === "add") {
|
||||
const missing = wanted.filter(
|
||||
(name) => !existing.has(name.toLowerCase()),
|
||||
)
|
||||
if (missing.length) {
|
||||
const created = await tx
|
||||
.insert(schema.problemTag)
|
||||
.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)
|
||||
})
|
||||
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
|
||||
return wanted
|
||||
.map((name) => existing.get(name.toLowerCase()))
|
||||
.filter((id) => id !== undefined)
|
||||
})
|
||||
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
|
||||
|
||||
const problemIds = problems.map((problem) => problem.id)
|
||||
await db.transaction(async (tx) => {
|
||||
if (parsed.data.action === "remove") {
|
||||
await tx.delete(schema.problemTags).where(and(
|
||||
inArray(schema.problemTags.problemId, problemIds),
|
||||
inArray(schema.problemTags.problemtagId, tagIds),
|
||||
))
|
||||
return
|
||||
}
|
||||
const existing = await tx.select().from(schema.problemTags).where(and(
|
||||
inArray(schema.problemTags.problemId, problemIds),
|
||||
inArray(schema.problemTags.problemtagId, tagIds),
|
||||
))
|
||||
const have = new Set(existing.map((row) => `${row.problemId}:${row.problemtagId}`))
|
||||
const rows = []
|
||||
for (const problemId of problemIds) {
|
||||
for (const tagId of tagIds) {
|
||||
if (!have.has(`${problemId}:${tagId}`)) rows.push({ problemId, problemtagId: tagId })
|
||||
const problemIds = problems.map((problem) => problem.id)
|
||||
await db.transaction(async (tx) => {
|
||||
if (parsed.data.action === "remove") {
|
||||
await tx
|
||||
.delete(schema.problemTags)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.problemTags.problemId, problemIds),
|
||||
inArray(schema.problemTags.problemtagId, tagIds),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
||||
})
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(schema.problemTags)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.problemTags.problemId, problemIds),
|
||||
inArray(schema.problemTags.problemtagId, tagIds),
|
||||
),
|
||||
)
|
||||
const have = new Set(
|
||||
existing.map((row) => `${row.problemId}:${row.problemtagId}`),
|
||||
)
|
||||
const rows = []
|
||||
for (const problemId of problemIds) {
|
||||
for (const tagId of tagIds) {
|
||||
if (!have.has(`${problemId}:${tagId}`))
|
||||
rows.push({ problemId, problemtagId: tagId })
|
||||
}
|
||||
}
|
||||
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
||||
})
|
||||
|
||||
return success(c, {
|
||||
problemCount: problems.length,
|
||||
tagCount: tagIds.length,
|
||||
} satisfies BatchProblemTagResponse)
|
||||
})
|
||||
return success(c, {
|
||||
problemCount: problems.length,
|
||||
tagCount: tagIds.length,
|
||||
} satisfies BatchProblemTagResponse)
|
||||
},
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 题目可见性
|
||||
|
||||
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => {
|
||||
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 })
|
||||
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
|
||||
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
|
||||
// AttributeError(500)。这里正常返回 404。
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
const user = c.get("user")!
|
||||
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
||||
return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
}
|
||||
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id))
|
||||
return success(c, { visible: !problem.visible })
|
||||
})
|
||||
adminTagRoutes.put(
|
||||
"/problems/:id/visibility",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
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,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(eq(schema.problem.id, id))
|
||||
.limit(1)
|
||||
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
|
||||
// AttributeError(500)。这里正常返回 404。
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
const user = c.get("user")!
|
||||
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
||||
return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
}
|
||||
await db
|
||||
.update(schema.problem)
|
||||
.set({ visible: !problem.visible })
|
||||
.where(eq(schema.problem.id, id))
|
||||
return success(c, { visible: !problem.visible })
|
||||
},
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
|
||||
|
||||
@@ -184,15 +311,26 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
|
||||
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
|
||||
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
|
||||
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
|
||||
const rows = await db.select({
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
||||
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
|
||||
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
|
||||
}).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
const rows = await db
|
||||
.select({
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
total: count(),
|
||||
accepted:
|
||||
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
|
||||
failedUsers:
|
||||
sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.submission.problemId, schema.problem.id),
|
||||
)
|
||||
/**
|
||||
* 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题
|
||||
* 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题
|
||||
@@ -207,17 +345,27 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
|
||||
*/
|
||||
.where(isNull(schema.submission.contestId))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
|
||||
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
|
||||
.having(
|
||||
sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`,
|
||||
)
|
||||
.orderBy(
|
||||
desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`),
|
||||
)
|
||||
.limit(40)
|
||||
return success(c, rows.map((row) => ({
|
||||
problemId: row.displayId,
|
||||
problemTitle: row.title,
|
||||
total: row.total,
|
||||
failed: row.failed,
|
||||
failedUsers: row.failedUsers,
|
||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||
} satisfies StuckProblem)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
problemId: row.displayId,
|
||||
problemTitle: row.title,
|
||||
total: row.total,
|
||||
failed: row.failed,
|
||||
failedUsers: row.failedUsers,
|
||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||
}) satisfies StuckProblem,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
|
||||
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)
|
||||
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
|
||||
|
||||
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
|
||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(Number)
|
||||
const rows = await db.select({
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
year,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
||||
}).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(year, sinceYear),
|
||||
lte(year, untilYear),
|
||||
))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
|
||||
const year =
|
||||
sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
|
||||
Number,
|
||||
)
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
year,
|
||||
total: count(),
|
||||
accepted:
|
||||
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.submission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(year, sinceYear),
|
||||
lte(year, untilYear),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
schema.problem.id,
|
||||
schema.problem.displayId,
|
||||
schema.problem.title,
|
||||
year,
|
||||
)
|
||||
.orderBy(asc(schema.problem.id), asc(year))
|
||||
|
||||
const required = new Set<number>()
|
||||
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) {
|
||||
const bucket = grouped.get(row.problemId)
|
||||
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 = []
|
||||
@@ -283,20 +458,27 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||
|
||||
// ---------------------------------------------------------------- Python → 流程图
|
||||
|
||||
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
|
||||
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空")
|
||||
try {
|
||||
const flowchart = await completeChat(
|
||||
`你是一个可以将Python代码转换为mermaid的助手。
|
||||
adminTagRoutes.post(
|
||||
"/problems/flowchart",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const parsed = generateFlowchartRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "python 代码不能为空")
|
||||
try {
|
||||
const flowchart = await completeChat(
|
||||
`你是一个可以将Python代码转换为mermaid的助手。
|
||||
请将用户提供的Python代码转换为 Mermaid 纯文本。
|
||||
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
|
||||
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
|
||||
parsed.data.python,
|
||||
)
|
||||
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
|
||||
} catch (error) {
|
||||
console.error("Flowchart generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
}
|
||||
})
|
||||
parsed.data.python,
|
||||
)
|
||||
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
|
||||
} catch (error) {
|
||||
console.error("Flowchart generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -40,7 +40,11 @@ function serializeTutorial(row: {
|
||||
|
||||
function selectTutorial(id: number) {
|
||||
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)
|
||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, 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) => {
|
||||
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)
|
||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, 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) => {
|
||||
const parsed = createTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
const parsed = createTutorialRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const [created] = await db.insert(schema.tutorial).values({
|
||||
...parsed.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdById: c.get("user")!.id,
|
||||
}).returning({ id: schema.tutorial.id })
|
||||
const [created] = await db
|
||||
.insert(schema.tutorial)
|
||||
.values({
|
||||
...parsed.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdById: c.get("user")!.id,
|
||||
})
|
||||
.returning({ id: schema.tutorial.id })
|
||||
const [row] = await selectTutorial(created!.id)
|
||||
return success(c, serializeTutorial(row!), 201)
|
||||
})
|
||||
|
||||
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const [row] = await selectTutorial(
|
||||
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))
|
||||
})
|
||||
|
||||
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
const parsed = updateTutorialRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||
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() })
|
||||
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
|
||||
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
.where(eq(schema.tutorial.id, id))
|
||||
.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)
|
||||
return success(c, serializeTutorial(row!))
|
||||
})
|
||||
|
||||
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required")
|
||||
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
|
||||
const updated = await db.update(schema.tutorial)
|
||||
.set({ isPublic: parsed.data.isPublic })
|
||||
.where(eq(schema.tutorial.id, id)).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)
|
||||
return success(c, serializeTutorial(row!))
|
||||
})
|
||||
adminTutorialRoutes.put(
|
||||
"/tutorials/:id/visibility",
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setTutorialVisibilityRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "isPublic is required")
|
||||
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
|
||||
const updated = await db
|
||||
.update(schema.tutorial)
|
||||
.set({ isPublic: parsed.data.isPublic })
|
||||
.where(eq(schema.tutorial.id, id))
|
||||
.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)
|
||||
return success(c, serializeTutorial(row!))
|
||||
},
|
||||
)
|
||||
|
||||
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
|
||||
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
|
||||
// 别默认新表会自己连坐 —— 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 })
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -136,52 +180,96 @@ function serializeExercise(row: typeof schema.exercise.$inferSelect) {
|
||||
|
||||
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
|
||||
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
|
||||
adminTutorialRoutes.get("/tutorials/:id/exercises", 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))
|
||||
return success(c, rows.map(serializeExercise))
|
||||
})
|
||||
adminTutorialRoutes.get(
|
||||
"/tutorials/:id/exercises",
|
||||
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))
|
||||
return success(c, rows.map(serializeExercise))
|
||||
},
|
||||
)
|
||||
|
||||
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) {
|
||||
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)
|
||||
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.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)
|
||||
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
||||
const [created] = await db.insert(schema.exercise).values({
|
||||
tutorialId: parsed.data.tutorialId,
|
||||
type: parsed.data.type,
|
||||
data: parsed.data.data,
|
||||
order: parsed.data.order,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).returning()
|
||||
const [created] = await db
|
||||
.insert(schema.exercise)
|
||||
.values({
|
||||
tutorialId: parsed.data.tutorialId,
|
||||
type: parsed.data.type,
|
||||
data: parsed.data.data,
|
||||
order: parsed.data.order,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.returning()
|
||||
return success(c, serializeExercise(created!), 201)
|
||||
})
|
||||
|
||||
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) {
|
||||
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)
|
||||
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
||||
const [updated] = await db.update(schema.exercise)
|
||||
.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 })))
|
||||
const [updated] = await db
|
||||
.update(schema.exercise)
|
||||
.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()
|
||||
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))
|
||||
})
|
||||
|
||||
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
|
||||
const deleted = await db.delete(schema.exercise)
|
||||
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
||||
const deleted = await db
|
||||
.delete(schema.exercise)
|
||||
.where(
|
||||
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
|
||||
)
|
||||
.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)
|
||||
})
|
||||
|
||||
+732
-216
File diff suppressed because it is too large
Load Diff
@@ -38,25 +38,36 @@ async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
|
||||
]
|
||||
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
||||
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
|
||||
const rows = await db.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
}).from(schema.user).innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(and(...filters))
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
})
|
||||
.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)
|
||||
}
|
||||
|
||||
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[]) {
|
||||
if (!values.length) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
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) {
|
||||
@@ -73,35 +84,59 @@ function quantile(values: number[], p: number) {
|
||||
function sampleStdDev(values: number[]) {
|
||||
if (values.length <= 1) return 0
|
||||
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) => {
|
||||
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 groups = new Map<string, ClassUser[]>()
|
||||
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||
const result = [...groups].map(([className, members]) => {
|
||||
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
|
||||
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
|
||||
return {
|
||||
className,
|
||||
userCount: members.length,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(totalAc / members.length),
|
||||
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)))
|
||||
for (const user of users)
|
||||
groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||
const result = [...groups]
|
||||
.map(([className, members]) => {
|
||||
const totalAc = members.reduce(
|
||||
(sum, member) => sum + member.acceptedNumber,
|
||||
0,
|
||||
)
|
||||
const totalSubmission = members.reduce(
|
||||
(sum, member) => sum + member.submissionNumber,
|
||||
0,
|
||||
)
|
||||
return {
|
||||
className,
|
||||
userCount: members.length,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(totalAc / members.length),
|
||||
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,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||
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(
|
||||
(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) => ({
|
||||
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)
|
||||
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) => {
|
||||
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required")
|
||||
const parsed = classComparisonRequestSchema.safeParse(
|
||||
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 allAc = users.map((user) => user.acceptedNumber)
|
||||
const globalQ1 = quantile(allAc, 0.25)
|
||||
const globalQ3 = quantile(allAc, 0.75)
|
||||
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 recentSubmissionCount = new Map<string, number>()
|
||||
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
|
||||
if (hasTimeRange) {
|
||||
const rows = await db.select({ 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!),
|
||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
||||
))
|
||||
const userClass = new Map(users.map((user) => [user.userId, user.className]))
|
||||
const rows = await db
|
||||
.select({
|
||||
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!),
|
||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
||||
),
|
||||
)
|
||||
const userClass = new Map(
|
||||
users.map((user) => [user.userId, user.className]),
|
||||
)
|
||||
for (const row of rows) {
|
||||
const className = userClass.get(row.userId)
|
||||
if (!className) continue
|
||||
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
|
||||
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
|
||||
recentSubmissionCount.set(
|
||||
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>()
|
||||
set.add(row.problemId)
|
||||
recentByUser.set(row.userId, set)
|
||||
@@ -158,12 +222,17 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
}
|
||||
|
||||
const comparisons = [...byClass].map(([className, members]) => {
|
||||
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
|
||||
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
|
||||
const ac = members
|
||||
.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 topCount = Math.max(1, Math.ceil(userCount * 0.1))
|
||||
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 totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
|
||||
const base: ClassComparison = {
|
||||
@@ -180,19 +249,30 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
top10Avg: rounded(mean(ac.slice(0, topCount))),
|
||||
middle80Avg: rounded(mean(middle)),
|
||||
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
|
||||
excellentRate: rounded(ac.filter((value) => value >= globalQ3).length / userCount * 100),
|
||||
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,
|
||||
excellentRate: rounded(
|
||||
(ac.filter((value) => value >= globalQ3).length / userCount) * 100,
|
||||
),
|
||||
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,
|
||||
}
|
||||
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.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
|
||||
base.recentAvgAc = rounded(mean(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
|
||||
}
|
||||
return base
|
||||
@@ -201,14 +281,19 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
|
||||
for (const item of comparisons) {
|
||||
item.compositeScore = rounded(
|
||||
0.4 * (item.medianAc / maxMedian * 100) +
|
||||
0.15 * (item.middle80Avg / maxMiddle * 100) +
|
||||
0.2 * item.activeRate +
|
||||
0.15 * item.passRate +
|
||||
0.1 * item.excellentRate,
|
||||
0.4 * ((item.medianAc / maxMedian) * 100) +
|
||||
0.15 * ((item.middle80Avg / maxMiddle) * 100) +
|
||||
0.2 * item.activeRate +
|
||||
0.15 * item.passRate +
|
||||
0.1 * item.excellentRate,
|
||||
1,
|
||||
)
|
||||
}
|
||||
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
|
||||
return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse)
|
||||
comparisons.sort(
|
||||
(a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc,
|
||||
)
|
||||
return success(c, {
|
||||
comparisons,
|
||||
hasTimeRange,
|
||||
} satisfies ClassComparisonResponse)
|
||||
})
|
||||
|
||||
+387
-168
@@ -33,34 +33,75 @@ contentRoutes.get("/announcements", async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.announcement).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))
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.announcement)
|
||||
.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))
|
||||
.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, {
|
||||
results: rows.map(({ announcement, user, realName }) => ({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
} satisfies AnnouncementListItem)),
|
||||
results: rows.map(
|
||||
({ announcement, user, realName }) =>
|
||||
({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
}) satisfies AnnouncementListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies AnnouncementList)
|
||||
})
|
||||
|
||||
contentRoutes.get("/announcements/:id", async (c) => {
|
||||
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 })
|
||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
const [row] = await 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(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")
|
||||
.where(
|
||||
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, {
|
||||
id: row.announcement.id,
|
||||
title: row.announcement.title,
|
||||
@@ -78,36 +119,62 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
|
||||
db.select({ 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),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.message)
|
||||
.where(eq(schema.message.recipientId, user.id)),
|
||||
db
|
||||
.select({
|
||||
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, {
|
||||
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||
language: submission.language,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
} satisfies EmbeddedSubmission,
|
||||
} satisfies Message)),
|
||||
results: rows.map(
|
||||
({ message, sender, realName, submission, displayId }) =>
|
||||
({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||
language: submission.language,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
} satisfies EmbeddedSubmission,
|
||||
}) satisfies Message,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies MessageList)
|
||||
})
|
||||
@@ -120,15 +187,39 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
*/
|
||||
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
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 parsed = createMessageRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
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([
|
||||
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.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
|
||||
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
|
||||
.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 (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
if (!recipient)
|
||||
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({
|
||||
message: parsed.data.message,
|
||||
createTime: new Date().toISOString(),
|
||||
@@ -140,11 +231,22 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
async function reactionState(problemId: number, userId: number) {
|
||||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
||||
const [mine] = await db
|
||||
.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
|
||||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
||||
const rows = await db
|
||||
.select({ type: schema.reaction.type, value: count() })
|
||||
.from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId))
|
||||
.groupBy(schema.reaction.type)
|
||||
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
|
||||
// row.type 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||||
const counts = Object.fromEntries(
|
||||
@@ -161,41 +263,85 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
|
||||
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const parsed = setReactionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const user = c.get("user")!
|
||||
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.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),
|
||||
db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
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 (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
|
||||
await db.insert(schema.reaction).values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!solved)
|
||||
return failure(
|
||||
c,
|
||||
403,
|
||||
"accepted-submission-required",
|
||||
"An accepted submission is required",
|
||||
)
|
||||
await db
|
||||
.insert(schema.reaction)
|
||||
.values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [schema.reaction.problemId, schema.reaction.userId],
|
||||
})
|
||||
return success(c, await reactionState(problemId, user.id))
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials", async (c) => {
|
||||
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)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
||||
const rows = await db
|
||||
.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[])
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
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 })
|
||||
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
const [row] = await db
|
||||
.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))
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!row)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
return success(c, {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
@@ -222,48 +368,73 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
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 进度,而不是反过来:没读过的课也要有一行零,
|
||||
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
|
||||
const [rows, exerciseRows] = await Promise.all([
|
||||
db.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
firstViewedAt: schema.tutorialProgress.firstViewedAt,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
}).from(schema.tutorial)
|
||||
.leftJoin(schema.tutorialProgress, and(
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
eq(schema.tutorialProgress.userId, user.id),
|
||||
))
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
firstViewedAt: schema.tutorialProgress.firstViewedAt,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.leftJoin(
|
||||
schema.tutorialProgress,
|
||||
and(
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
eq(schema.tutorialProgress.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(visible)
|
||||
.orderBy(asc(schema.tutorial.order)),
|
||||
db.select({
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
total: count(),
|
||||
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
|
||||
}).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.userId, user.id),
|
||||
))
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
total: count(),
|
||||
solved:
|
||||
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.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.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(visible)
|
||||
.groupBy(schema.exercise.tutorialId),
|
||||
])
|
||||
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
|
||||
|
||||
return success(c, rows.map((row) => ({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
firstViewedAt: row.firstViewedAt,
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
} satisfies TutorialProgress)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
firstViewedAt: row.firstViewedAt,
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
}) satisfies TutorialProgress,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -276,31 +447,44 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) 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 parsed = tutorialProgressPingSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
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 { seconds, opened } = parsed.data
|
||||
await db.insert(schema.tutorialProgress).values({
|
||||
userId: user.id,
|
||||
tutorialId: id,
|
||||
viewCount: opened ? 1 : 0,
|
||||
totalSeconds: seconds,
|
||||
firstViewedAt: now,
|
||||
lastViewedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
|
||||
set: {
|
||||
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||
await db
|
||||
.insert(schema.tutorialProgress)
|
||||
.values({
|
||||
userId: user.id,
|
||||
tutorialId: id,
|
||||
viewCount: opened ? 1 : 0,
|
||||
totalSeconds: seconds,
|
||||
firstViewedAt: now,
|
||||
lastViewedAt: now,
|
||||
},
|
||||
})
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.tutorialProgress.userId,
|
||||
schema.tutorialProgress.tutorialId,
|
||||
],
|
||||
set: {
|
||||
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||
lastViewedAt: now,
|
||||
},
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
@@ -317,60 +501,95 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload")
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(
|
||||
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)
|
||||
.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 [exercise] = await db
|
||||
.select({ id: schema.exercise.id })
|
||||
.from(schema.exercise)
|
||||
.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 { correct } = parsed.data
|
||||
const answer = correct ? null : (parsed.data.answer ?? null)
|
||||
await db.insert(schema.exerciseAttempt).values({
|
||||
userId: user.id,
|
||||
exerciseId: id,
|
||||
attempts: 1,
|
||||
wrongAttempts: correct ? 0 : 1,
|
||||
solved: correct,
|
||||
attemptsToSolve: correct ? 1 : null,
|
||||
lastWrongAnswer: answer,
|
||||
firstAttemptAt: now,
|
||||
lastAttemptAt: now,
|
||||
solvedAt: correct ? now : null,
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
|
||||
set: {
|
||||
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||
//
|
||||
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||
attemptsToSolve: sql`case
|
||||
await db
|
||||
.insert(schema.exerciseAttempt)
|
||||
.values({
|
||||
userId: user.id,
|
||||
exerciseId: id,
|
||||
attempts: 1,
|
||||
wrongAttempts: correct ? 0 : 1,
|
||||
solved: correct,
|
||||
attemptsToSolve: correct ? 1 : null,
|
||||
lastWrongAnswer: answer,
|
||||
firstAttemptAt: now,
|
||||
lastAttemptAt: now,
|
||||
solvedAt: correct ? now : null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.exerciseAttempt.userId,
|
||||
schema.exerciseAttempt.exerciseId,
|
||||
],
|
||||
set: {
|
||||
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||
//
|
||||
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||
attemptsToSolve: sql`case
|
||||
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.attemptsToSolve}
|
||||
when ${correct} then ${schema.exerciseAttempt.attempts} + 1
|
||||
else null end`,
|
||||
solvedAt: sql`case
|
||||
solvedAt: sql`case
|
||||
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.solvedAt}
|
||||
when ${correct} then ${now}::timestamptz
|
||||
else null end`,
|
||||
lastWrongAnswer: sql`case
|
||||
lastWrongAnswer: sql`case
|
||||
when ${schema.exerciseAttempt.solved} or ${correct} then ${schema.exerciseAttempt.lastWrongAnswer}
|
||||
else ${answer} end`,
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
})
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
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 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)))
|
||||
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 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,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
+294
-133
@@ -9,7 +9,18 @@ import {
|
||||
type ProblemDetail,
|
||||
type ProblemListItem,
|
||||
} 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 { optionalAuth, requireAuth } from "../auth/middleware"
|
||||
@@ -27,7 +38,12 @@ import {
|
||||
requireContestAccess,
|
||||
type ContestEnv,
|
||||
} from "../services/contest"
|
||||
import { objectValue, publicTemplates, queryInteger, sampleUser } from "./helpers"
|
||||
import {
|
||||
objectValue,
|
||||
publicTemplates,
|
||||
queryInteger,
|
||||
sampleUser,
|
||||
} from "./helpers"
|
||||
|
||||
export const contestRoutes = new Hono<ContestEnv>()
|
||||
|
||||
@@ -35,8 +51,14 @@ export const contestRoutes = new Hono<ContestEnv>()
|
||||
async function creators(ids: number[]) {
|
||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||
if (ids.length === 0) return map
|
||||
const rows = await db.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))
|
||||
const rows = await db
|
||||
.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))
|
||||
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
||||
return map
|
||||
@@ -75,18 +97,33 @@ contestRoutes.get("/contests", async (c) => {
|
||||
if (tag) filters.push(eq(schema.contest.tag, tag))
|
||||
if (status === "1") filters.push(gte(schema.contest.startTime, 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 [totalRow, rows] = await Promise.all([
|
||||
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))])
|
||||
return success(c, {
|
||||
results: rows.map((row) => serializeContest(
|
||||
row,
|
||||
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
||||
)),
|
||||
results: rows.map((row) =>
|
||||
serializeContest(
|
||||
row,
|
||||
byId.get(row.createdById) ??
|
||||
sampleUser({ id: row.createdById, username: "" }, null),
|
||||
),
|
||||
),
|
||||
total: totalRow[0]?.value ?? 0,
|
||||
} satisfies ContestList)
|
||||
})
|
||||
@@ -94,31 +131,55 @@ contestRoutes.get("/contests", async (c) => {
|
||||
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
|
||||
// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
|
||||
contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(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 contest = await findAccessibleContest(
|
||||
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])
|
||||
return success(c, serializeContest(
|
||||
contest,
|
||||
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null),
|
||||
true,
|
||||
))
|
||||
return success(
|
||||
c,
|
||||
serializeContest(
|
||||
contest,
|
||||
byId.get(contest.createdById) ??
|
||||
sampleUser({ id: contest.createdById, username: "" }, null),
|
||||
true,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(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 parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
|
||||
const contest = await findAccessibleContest(
|
||||
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 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)) {
|
||||
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)
|
||||
return success(c, true)
|
||||
})
|
||||
|
||||
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(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 contest = await findAccessibleContest(
|
||||
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")
|
||||
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) {
|
||||
if (!userId) return {}
|
||||
const [profile] = await db.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, userId))
|
||||
.limit(1)
|
||||
return objectValue(objectValue(profile?.status).contest_problems)
|
||||
}
|
||||
|
||||
@@ -148,117 +212,214 @@ function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
|
||||
|
||||
async function contestProblemTags(problemIds: number[]) {
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags).innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
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
|
||||
}
|
||||
|
||||
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
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))
|
||||
.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 allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(c, rows.map(({ problem, user, realName }) => ({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||
difficulty: allowed ? problem.difficulty : null,
|
||||
createdBy: sampleUser(user, realName),
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: contest.id,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: myStatusOf(statuses, problem.id),
|
||||
} satisfies ProblemListItem)))
|
||||
})
|
||||
contestRoutes.get(
|
||||
"/contests/:id/problems",
|
||||
optionalAuth,
|
||||
requireContestAccess("problems"),
|
||||
async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
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))
|
||||
.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 allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ problem, user, realName }) =>
|
||||
({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||
difficulty: allowed ? problem.difficulty : null,
|
||||
createdBy: sampleUser(user, realName),
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: contest.id,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: myStatusOf(statuses, problem.id),
|
||||
}) satisfies ProblemListItem,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const [row] = 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))
|
||||
.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 allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(c, {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: allowed ? row.problem.difficulty : null,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
||||
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
||||
contestId: contest.id,
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
myStatus: myStatusOf(statuses, row.problem.id),
|
||||
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
|
||||
myFailedCount: 0,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail)
|
||||
})
|
||||
contestRoutes.get(
|
||||
"/contests/:id/problems/:displayId",
|
||||
optionalAuth,
|
||||
requireContestAccess("problems"),
|
||||
async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const [row] = 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))
|
||||
.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 allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(c, {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: allowed ? row.problem.difficulty : null,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
||||
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
||||
contestId: contest.id,
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
myStatus: myStatusOf(statuses, row.problem.id),
|
||||
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
|
||||
myFailedCount: 0,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail)
|
||||
},
|
||||
)
|
||||
|
||||
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
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 [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.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 数同罚时前两列分不出先后,而这条列表是
|
||||
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
|
||||
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, {
|
||||
results: rows.map(({ rank, user, realName }) => ({
|
||||
id: rank.id,
|
||||
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
||||
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
||||
user: sampleUser(user, realName, { includeRealName: admin }),
|
||||
submissionNumber: rank.submissionNumber,
|
||||
acceptedNumber: rank.acceptedNumber,
|
||||
totalTime: rank.totalTime,
|
||||
submissionInfo: rank.submissionInfo,
|
||||
contestId: rank.contestId,
|
||||
} satisfies ContestRankItem)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies ContestRank)
|
||||
})
|
||||
contestRoutes.get(
|
||||
"/contests/:id/rank",
|
||||
optionalAuth,
|
||||
requireContestAccess("ranks"),
|
||||
async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
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 [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
|
||||
.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 数同罚时前两列分不出先后,而这条列表是
|
||||
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
|
||||
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
|
||||
.orderBy(
|
||||
desc(schema.acmContestRank.acceptedNumber),
|
||||
asc(schema.acmContestRank.totalTime),
|
||||
asc(schema.acmContestRank.id),
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, {
|
||||
results: rows.map(
|
||||
({ rank, user, realName }) =>
|
||||
({
|
||||
id: rank.id,
|
||||
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
||||
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
||||
user: sampleUser(user, realName, { includeRealName: admin }),
|
||||
submissionNumber: rank.submissionNumber,
|
||||
acceptedNumber: rank.acceptedNumber,
|
||||
totalTime: rank.totalTime,
|
||||
submissionInfo: rank.submissionInfo,
|
||||
contestId: rank.contestId,
|
||||
}) satisfies ContestRankItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies ContestRank)
|
||||
},
|
||||
)
|
||||
|
||||
+364
-157
@@ -10,7 +10,17 @@ import {
|
||||
type FlowchartStatistics,
|
||||
type FlowchartSubmission,
|
||||
} 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 { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
@@ -38,8 +48,16 @@ function flowchartThrottleKey(userId: number) {
|
||||
return `flowchart:${userId}`
|
||||
}
|
||||
|
||||
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
|
||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
||||
function canView(
|
||||
user: import("../auth/session").AuthUser,
|
||||
row: { userId: number },
|
||||
problem: { createdById: number },
|
||||
) {
|
||||
return (
|
||||
row.userId === user.id ||
|
||||
isAdminRole(user) ||
|
||||
problem.createdById === user.id
|
||||
)
|
||||
}
|
||||
|
||||
function flowchartData(
|
||||
@@ -67,20 +85,48 @@ function flowchartData(
|
||||
}
|
||||
|
||||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
const parsed = createFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
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 parsed = createFlowchartRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
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)
|
||||
.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")
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart })
|
||||
.from(schema.problem)
|
||||
.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 调用,是和判题沙箱同级的有限资源。
|
||||
// 身份前缀单独开一个桶,**不能**直接用 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) {
|
||||
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")
|
||||
await db.insert(schema.flowchartSubmission).values({
|
||||
@@ -104,10 +150,22 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
try {
|
||||
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
||||
} catch (error) {
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
await db
|
||||
.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
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||||
isNull(schema.problem.contestId),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
return problems.length
|
||||
? inArray(schema.flowchartSubmission.problemId, problems.map((row) => row.id))
|
||||
? inArray(
|
||||
schema.flowchartSubmission.problemId,
|
||||
problems.map((row) => row.id),
|
||||
)
|
||||
: sql`false`
|
||||
}
|
||||
|
||||
async function flowchartUserFilter(username: string) {
|
||||
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,46 +252,69 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
|
||||
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
|
||||
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 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)
|
||||
}
|
||||
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
||||
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> = []
|
||||
filters.push(...await Promise.all([
|
||||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||||
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
||||
]))
|
||||
filters.push(
|
||||
...(await Promise.all([
|
||||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||||
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
||||
])),
|
||||
)
|
||||
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||
if (c.req.query("today") === "1") filters.push(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
|
||||
if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
|
||||
if (c.req.query("today") === "1")
|
||||
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 [totalRows, rows] = await Promise.all([
|
||||
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
|
||||
db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
|
||||
db.select(flowchartListColumns)
|
||||
db
|
||||
.select(flowchartListColumns)
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.flowchartSubmission.userId, schema.user.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map(({ flowchart, username, problem }) => ({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problem: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
status: flowchart.status,
|
||||
createTime: flowchart.createTime,
|
||||
aiScore: flowchart.aiScore,
|
||||
aiGrade: flowchart.aiGrade,
|
||||
aiProvider: flowchart.aiProvider,
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
} satisfies FlowchartListItem)),
|
||||
results: rows.map(
|
||||
({ flowchart, username, problem }) =>
|
||||
({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problem: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
status: flowchart.status,
|
||||
createTime: flowchart.createTime,
|
||||
aiScore: flowchart.aiScore,
|
||||
aiGrade: flowchart.aiGrade,
|
||||
aiProvider: flowchart.aiProvider,
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
}) satisfies FlowchartListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies FlowchartList)
|
||||
})
|
||||
@@ -262,20 +350,24 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
||||
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()
|
||||
if (displayId) {
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${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")
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
|
||||
}
|
||||
|
||||
@@ -286,13 +378,17 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
if (username) {
|
||||
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 roster = username
|
||||
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
||||
? matched.filter(
|
||||
(row) => !row.isDisabled && row.adminType === "Regular User",
|
||||
)
|
||||
: []
|
||||
|
||||
/**
|
||||
@@ -304,46 +400,56 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
|
||||
* 点击 18MB,而老师是开着面板反复切时段、切班的。
|
||||
*/
|
||||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
/**
|
||||
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
|
||||
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
|
||||
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
|
||||
*/
|
||||
scoreSum: 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 就不必 join user
|
||||
completedCount: sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number),
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where),
|
||||
db
|
||||
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.groupBy(schema.flowchartSubmission.aiGrade),
|
||||
/**
|
||||
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
|
||||
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
|
||||
* `typeof detail.score !== "number"` 的 continue 一致。
|
||||
*
|
||||
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
|
||||
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
|
||||
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
|
||||
*
|
||||
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
|
||||
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
|
||||
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
|
||||
*
|
||||
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
|
||||
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
|
||||
* 实测 254ms → 842ms,而满分本来就是几个常数。
|
||||
*/
|
||||
db.execute<{ key: string; avg: number }>(sql`
|
||||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
/**
|
||||
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
|
||||
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
|
||||
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
|
||||
*/
|
||||
scoreSum:
|
||||
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 就不必 join user
|
||||
completedCount:
|
||||
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where),
|
||||
db
|
||||
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.groupBy(schema.flowchartSubmission.aiGrade),
|
||||
/**
|
||||
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
|
||||
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
|
||||
* `typeof detail.score !== "number"` 的 continue 一致。
|
||||
*
|
||||
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
|
||||
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
|
||||
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
|
||||
*
|
||||
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
|
||||
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
|
||||
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
|
||||
*
|
||||
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
|
||||
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
|
||||
* 实测 254ms → 842ms,而满分本来就是几个常数。
|
||||
*/
|
||||
db.execute<{ key: string; avg: number }>(sql`
|
||||
select e.key as key, avg((e.value->>'score')::double precision) as avg
|
||||
from ${schema.flowchartSubmission}
|
||||
cross join lateral jsonb_each(
|
||||
@@ -354,25 +460,25 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
where ${where} and jsonb_typeof(e.value->'score') = 'number'
|
||||
group by e.key
|
||||
`),
|
||||
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
|
||||
db
|
||||
.select({
|
||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||
feedback: schema.flowchartSubmission.aiFeedback,
|
||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(WORDCLOUD_TEXT_LIMIT),
|
||||
// 「谁没做」只在有花名册时算得出来,行数也就一个班
|
||||
roster.length
|
||||
? db
|
||||
.selectDistinct({ userId: schema.flowchartSubmission.userId })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
: [],
|
||||
])
|
||||
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
|
||||
db
|
||||
.select({
|
||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||
feedback: schema.flowchartSubmission.aiFeedback,
|
||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(WORDCLOUD_TEXT_LIMIT),
|
||||
// 「谁没做」只在有花名册时算得出来,行数也就一个班
|
||||
roster.length
|
||||
? db
|
||||
.selectDistinct({ userId: schema.flowchartSubmission.userId })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
: [],
|
||||
])
|
||||
|
||||
if (!totals || totals.total === 0) {
|
||||
return success(c, {
|
||||
@@ -423,7 +529,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
if (!criteriaMax.has(key)) {
|
||||
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.suggestions) pushText(row.suggestions)
|
||||
@@ -431,13 +538,18 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
|
||||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||||
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))
|
||||
return success(c, {
|
||||
totalCount: totals.total,
|
||||
avgScore: totals.scoreCount ? rounded(totals.scoreSum / totals.scoreCount, 1) : 0,
|
||||
avgScore: totals.scoreCount
|
||||
? rounded(totals.scoreSum / totals.scoreCount, 1)
|
||||
: 0,
|
||||
gradeDistribution,
|
||||
criteriaAverages,
|
||||
personCount: roster.length,
|
||||
@@ -453,33 +565,74 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, 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")
|
||||
const [row] = await db
|
||||
.select({
|
||||
flowchart: schema.flowchartSubmission,
|
||||
username: schema.user.username,
|
||||
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))
|
||||
})
|
||||
|
||||
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(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")
|
||||
const [row] = await db
|
||||
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
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 调用。
|
||||
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
||||
if (!isAdminRole(user)) {
|
||||
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
||||
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({
|
||||
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
|
||||
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
|
||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
.set({
|
||||
status: 0,
|
||||
aiScore: null,
|
||||
aiGrade: null,
|
||||
aiFeedback: null,
|
||||
aiSuggestions: null,
|
||||
aiCriteriaDetails: {},
|
||||
processingTime: null,
|
||||
evaluationTime: null,
|
||||
})
|
||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
try {
|
||||
// jobId 必须**正好三段**:bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
|
||||
// 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛
|
||||
@@ -493,28 +646,82 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
)
|
||||
} catch (error) {
|
||||
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
await db
|
||||
.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) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const rows = await db.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))
|
||||
return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent)
|
||||
})
|
||||
flowchartRoutes.get(
|
||||
"/problems/:id/flowchart/current",
|
||||
requireAuth,
|
||||
async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const rows = await db
|
||||
.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))
|
||||
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) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const rows = await db.select({ 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))
|
||||
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")
|
||||
return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail)
|
||||
})
|
||||
flowchartRoutes.get(
|
||||
"/problems/:id/flowchart/history",
|
||||
requireAuth,
|
||||
async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const rows = await db
|
||||
.select({
|
||||
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))
|
||||
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")
|
||||
return success(c, {
|
||||
submission: selected
|
||||
? flowchartData(selected.flowchart, selected.username)
|
||||
: null,
|
||||
count: rows.length,
|
||||
} satisfies FlowchartDetail)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -115,7 +117,10 @@ export function rounded(value: number, digits = 2) {
|
||||
* 等待评分 / 正在评分也算成失败,连点三次提交就能让按钮亮起来,而 hint 端点排掉了
|
||||
* 这两个状态,于是按钮亮着、点下去回 `hint-locked`。
|
||||
*/
|
||||
export async function countFailedSubmissions(userId: number, problemId: number) {
|
||||
export async function countFailedSubmissions(
|
||||
userId: number,
|
||||
problemId: number,
|
||||
) {
|
||||
const [failed] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
|
||||
+463
-258
@@ -1,18 +1,25 @@
|
||||
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract"
|
||||
import type {
|
||||
ProblemAuthor,
|
||||
ProblemDetail,
|
||||
ProblemList,
|
||||
ProblemListItem,
|
||||
Tag,
|
||||
YearlyAc,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
@@ -22,296 +29,494 @@ import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
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>()
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function publicTemplates(value: unknown) {
|
||||
const templates: Record<string, string> = {}
|
||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||
if (typeof raw !== "string") continue
|
||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||
templates[language] = match?.[1] ?? ""
|
||||
}
|
||||
return templates
|
||||
const templates: Record<string, string> = {}
|
||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||
if (typeof raw !== "string") continue
|
||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||
templates[language] = match?.[1] ?? ""
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
async function getProblemStatuses(userId: number | undefined) {
|
||||
if (!userId) return {}
|
||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
|
||||
return toObject(toObject(profile?.value).problems)
|
||||
if (!userId) return {}
|
||||
const [profile] = await db
|
||||
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, userId))
|
||||
.limit(1)
|
||||
return toObject(toObject(profile?.value).problems)
|
||||
}
|
||||
|
||||
async function getProblemTags(problemIds: number[]) {
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const result = new Map<number, string[]>()
|
||||
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
|
||||
return result
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const result = new Map<number, string[]>()
|
||||
for (const row of rows)
|
||||
result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
|
||||
return result
|
||||
}
|
||||
|
||||
function listItem(
|
||||
row: { problem: typeof schema.problem.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null },
|
||||
tags: Map<number, string[]>,
|
||||
statuses: Record<string, unknown>,
|
||||
row: {
|
||||
problem: typeof schema.problem.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
},
|
||||
tags: Map<number, string[]>,
|
||||
statuses: Record<string, unknown>,
|
||||
) {
|
||||
const status = toObject(statuses[String(row.problem.id)]).status
|
||||
return {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
difficulty: row.problem.difficulty,
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
contestId: row.problem.contestId,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
myStatus: typeof status === "number" ? status : null,
|
||||
} satisfies ProblemListItem
|
||||
const status = toObject(statuses[String(row.problem.id)]).status
|
||||
return {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
difficulty: row.problem.difficulty,
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
contestId: row.problem.contestId,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
myStatus: typeof status === "number" ? status : null,
|
||||
} satisfies ProblemListItem
|
||||
}
|
||||
|
||||
problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
|
||||
const author = c.req.query("author")?.trim()
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
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 (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
|
||||
if (tag) {
|
||||
filters.push(inArray(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 limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
]
|
||||
const author = c.req.query("author")?.trim()
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
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 (difficulty)
|
||||
filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
|
||||
if (tag) {
|
||||
filters.push(
|
||||
inArray(
|
||||
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 sort = c.req.query("sort")
|
||||
const order = sort === "flowchart"
|
||||
? [desc(schema.problem.allowFlowchart), desc(schema.problem.showFlowchart), desc(schema.problem.createTime)]
|
||||
: sort === "ast"
|
||||
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
|
||||
: sort === "-accepted_number"
|
||||
? [desc(schema.problem.acceptedNumber)]
|
||||
: sort === "accepted_number"
|
||||
? [asc(schema.problem.acceptedNumber)]
|
||||
: sort === "-submission_number"
|
||||
? [desc(schema.problem.submissionNumber)]
|
||||
: sort === "submission_number"
|
||||
? [asc(schema.problem.submissionNumber)]
|
||||
: sort === "difficulty"
|
||||
? [asc(schema.problem.difficulty)]
|
||||
: sort === "create_time"
|
||||
? [asc(schema.problem.createTime)]
|
||||
: [desc(schema.problem.createTime)]
|
||||
const [totalRow] = await db.select({ value: countDistinct(schema.problem.id) }).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)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where).orderBy(...order).limit(limit).offset(offset)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||
total: totalRow?.value ?? 0,
|
||||
} satisfies ProblemList)
|
||||
const where = and(...filters)
|
||||
const sort = c.req.query("sort")
|
||||
const order =
|
||||
sort === "flowchart"
|
||||
? [
|
||||
desc(schema.problem.allowFlowchart),
|
||||
desc(schema.problem.showFlowchart),
|
||||
desc(schema.problem.createTime),
|
||||
]
|
||||
: sort === "ast"
|
||||
? [
|
||||
desc(sql`(${schema.problem.astRules} is not null)`),
|
||||
desc(schema.problem.createTime),
|
||||
]
|
||||
: sort === "-accepted_number"
|
||||
? [desc(schema.problem.acceptedNumber)]
|
||||
: sort === "accepted_number"
|
||||
? [asc(schema.problem.acceptedNumber)]
|
||||
: sort === "-submission_number"
|
||||
? [desc(schema.problem.submissionNumber)]
|
||||
: sort === "submission_number"
|
||||
? [asc(schema.problem.submissionNumber)]
|
||||
: sort === "difficulty"
|
||||
? [asc(schema.problem.difficulty)]
|
||||
: sort === "create_time"
|
||||
? [asc(schema.problem.createTime)]
|
||||
: [desc(schema.problem.createTime)]
|
||||
const [totalRow] = await db
|
||||
.select({ value: countDistinct(schema.problem.id) })
|
||||
.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)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||
total: totalRow?.value ?? 0,
|
||||
} satisfies ProblemList)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-tags", async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
||||
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
|
||||
const rows = await db.select({ id: schema.problemTag.id, name: schema.problemTag.name, problemCount: countDistinct(schema.problemTags.problemId) })
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.innerJoin(schema.problem, and(
|
||||
eq(schema.problem.id, schema.problemTags.problemId),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
))
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
.orderBy(asc(schema.problemTag.name))
|
||||
return success(c, rows satisfies Tag[])
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
||||
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problemTag.id,
|
||||
name: schema.problemTag.name,
|
||||
problemCount: countDistinct(schema.problemTags.problemId),
|
||||
})
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(
|
||||
schema.problemTags,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
and(
|
||||
eq(schema.problem.id, schema.problemTags.problemId),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
.orderBy(asc(schema.problemTag.name))
|
||||
return success(c, rows satisfies Tag[])
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/random", async (c) => {
|
||||
const [row] = await db.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")
|
||||
return success(c, row.displayId)
|
||||
const [row] = await db
|
||||
.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")
|
||||
return success(c, row.displayId)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-authors", async (c) => {
|
||||
const showAll = c.req.query("all") === "1"
|
||||
const rows = await db.select({ username: schema.user.username, 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[])
|
||||
const showAll = c.req.query("all") === "1"
|
||||
const rows = await db
|
||||
.select({
|
||||
username: schema.user.username,
|
||||
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[])
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, "0")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [mine] = await db.select({ value: count() }).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")
|
||||
// 「近两年」按东八区日历算到当天零点
|
||||
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||
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.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 solved = accepted[0]?.value ?? 0
|
||||
return success(c, total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0")
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, "0")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [mine] = await db
|
||||
.select({ value: count() })
|
||||
.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")
|
||||
// 「近两年」按东八区日历算到当天零点
|
||||
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||
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
|
||||
.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 solved = accepted[0]?.value ?? 0
|
||||
return success(
|
||||
c,
|
||||
total > 0 && solved < total
|
||||
? (((total - solved) / total) * 100).toFixed(2)
|
||||
: "0",
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||
const [target] = await db.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")
|
||||
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, [])
|
||||
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))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(
|
||||
eq(schema.problem.visible, true), isNull(schema.problem.contestId), sql`${schema.problem.id} <> ${target.id}`,
|
||||
inArray(schema.problem.id, 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)))
|
||||
const [target] = await db
|
||||
.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")
|
||||
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, [])
|
||||
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))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
sql`${schema.problem.id} <> ${target.id}`,
|
||||
inArray(
|
||||
schema.problem.id,
|
||||
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) => {
|
||||
const [problem] = await db.select({ id: schema.problem.id }).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 rows = await db.select({
|
||||
year,
|
||||
total: count(),
|
||||
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)
|
||||
return success(c, rows.map((row) => ({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 } satisfies YearlyAc)))
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.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 rows = await db
|
||||
.select({
|
||||
year,
|
||||
total: count(),
|
||||
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)
|
||||
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) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
creatorId: schema.user.id,
|
||||
creatorUsername: schema.user.username,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.displayId, c.req.param("displayId")),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
const [row] = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
creatorId: schema.user.id,
|
||||
creatorUsername: schema.user.username,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.displayId, c.req.param("displayId")),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.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
|
||||
.select({ name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTags.problemId, row.problem.id))
|
||||
const tagRows = await db
|
||||
.select({ name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTags.problemId, row.problem.id))
|
||||
|
||||
const user = c.get("user")
|
||||
let myStatus: number | null = null
|
||||
let myFailedCount = 0
|
||||
if (user) {
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const statuses = objectValue(objectValue(profile?.status).problems)
|
||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||
const user = c.get("user")
|
||||
let myStatus: number | null = null
|
||||
let myFailedCount = 0
|
||||
if (user) {
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const statuses = objectValue(objectValue(profile?.status).problems)
|
||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||
|
||||
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
|
||||
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
|
||||
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
|
||||
}
|
||||
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
|
||||
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
|
||||
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
|
||||
}
|
||||
|
||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||
const data = {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples,
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: row.problem.difficulty,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
statisticInfo: objectValue(row.problem.statisticInfo),
|
||||
contestId: row.problem.contestId,
|
||||
tags: tagRows.map((tag) => tag.name),
|
||||
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
|
||||
myStatus,
|
||||
myFailedCount,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail
|
||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||
const data = {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples,
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: row.problem.difficulty,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
statisticInfo: objectValue(row.problem.statisticInfo),
|
||||
contestId: row.problem.contestId,
|
||||
tags: tagRows.map((tag) => tag.name),
|
||||
createdBy: sampleUser(
|
||||
{ id: row.creatorId, username: row.creatorUsername },
|
||||
null,
|
||||
),
|
||||
myStatus,
|
||||
myFailedCount,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail
|
||||
|
||||
return success(c, data)
|
||||
return success(c, data)
|
||||
})
|
||||
|
||||
+438
-169
@@ -24,7 +24,12 @@ import {
|
||||
} from "drizzle-orm"
|
||||
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 { failure, success } from "../http"
|
||||
import { computeProgress } from "../services/problemset"
|
||||
@@ -34,33 +39,46 @@ export const problemsetRoutes = new Hono<AppEnv>()
|
||||
|
||||
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
||||
|
||||
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
|
||||
return progress ? {
|
||||
isJoined: true,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedCount: progress.completedProblemsCount,
|
||||
totalCount: progress.totalProblemsCount,
|
||||
isCompleted: progress.isCompleted,
|
||||
} : {
|
||||
isJoined: false,
|
||||
progressPercentage: 0,
|
||||
completedCount: 0,
|
||||
totalCount: 0,
|
||||
isCompleted: false,
|
||||
}
|
||||
function progressSummary(
|
||||
progress: typeof schema.problemsetProgress.$inferSelect | undefined,
|
||||
) {
|
||||
return progress
|
||||
? {
|
||||
isJoined: true,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedCount: progress.completedProblemsCount,
|
||||
totalCount: progress.totalProblemsCount,
|
||||
isCompleted: progress.isCompleted,
|
||||
}
|
||||
: {
|
||||
isJoined: false,
|
||||
progressPercentage: 0,
|
||||
completedCount: 0,
|
||||
totalCount: 0,
|
||||
isCompleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function problemSetCreators(ids: number[]) {
|
||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||
if (ids.length === 0) return map
|
||||
const rows = await db.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))
|
||||
const rows = await db
|
||||
.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))
|
||||
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
||||
return map
|
||||
}
|
||||
|
||||
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
|
||||
function badgeData(
|
||||
badge: typeof schema.problemsetBadge.$inferSelect,
|
||||
earned?: boolean,
|
||||
) {
|
||||
return {
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
@@ -86,26 +104,68 @@ async function serializeProblemSets(
|
||||
) {
|
||||
if (rows.length === 0) return []
|
||||
const ids = rows.map((row) => row.id)
|
||||
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([
|
||||
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
|
||||
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||
.groupBy(schema.problemsetProblem.problemsetId),
|
||||
userId ? db.select().from(schema.problemsetProgress)
|
||||
.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)[]),
|
||||
includeBadges && userId ? db.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 }[]),
|
||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
||||
])
|
||||
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value]))
|
||||
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 [problemCounts, progresses, badges, earnedRows, creators] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
problemsetId: schema.problemsetProblem.problemsetId,
|
||||
value: count(),
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||
.groupBy(schema.problemsetProblem.problemsetId),
|
||||
userId
|
||||
? db
|
||||
.select()
|
||||
.from(schema.problemsetProgress)
|
||||
.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)[]),
|
||||
includeBadges && userId
|
||||
? db
|
||||
.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 }[]),
|
||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
||||
])
|
||||
const countBySet = new Map(
|
||||
problemCounts.map((item) => [item.problemsetId, item.value]),
|
||||
)
|
||||
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))
|
||||
return rows.map((row) => {
|
||||
const progress = progressBySet.get(row.id)
|
||||
@@ -113,7 +173,9 @@ async function serializeProblemSets(
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
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,
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
difficulty: row.difficulty,
|
||||
@@ -123,7 +185,11 @@ async function serializeProblemSets(
|
||||
problemsCount: countBySet.get(row.id) ?? 0,
|
||||
completedCount: progress?.completedProblemsCount ?? 0,
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -131,17 +197,33 @@ async function serializeProblemSets(
|
||||
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
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 difficulty = c.req.query("difficulty")?.trim()
|
||||
const status = c.req.query("status")?.trim()
|
||||
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
|
||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
|
||||
if (keyword)
|
||||
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)))
|
||||
const where = and(...filters)
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
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, {
|
||||
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) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select().from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
const [row] = await db
|
||||
.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", "题单不存在")
|
||||
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
||||
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) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.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)
|
||||
const [problemSet] = await db
|
||||
.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", "题单不存在")
|
||||
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
|
||||
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
|
||||
@@ -169,32 +269,53 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
|
||||
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
|
||||
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
|
||||
const rows = await db.select({
|
||||
link: schema.problemsetProblem,
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
difficulty: schema.problem.difficulty,
|
||||
})
|
||||
const rows = await db
|
||||
.select({
|
||||
link: schema.problemsetProblem,
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
difficulty: schema.problem.difficulty,
|
||||
})
|
||||
.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))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||
.orderBy(
|
||||
asc(schema.problemsetProblem.order),
|
||||
asc(schema.problemsetProblem.id),
|
||||
)
|
||||
const progressRows = c.get("user")
|
||||
? await db.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)
|
||||
? await db
|
||||
.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)
|
||||
return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => ({
|
||||
id: link.id,
|
||||
problemsetId: link.problemsetId,
|
||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||
order: link.order,
|
||||
isRequired: link.isRequired,
|
||||
score: link.score,
|
||||
hint: link.hint,
|
||||
isCompleted: String(problemId) in completed,
|
||||
} satisfies ProblemSetProblem)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ link, problemId, displayId, title, difficulty }) =>
|
||||
({
|
||||
id: link.id,
|
||||
problemsetId: link.problemsetId,
|
||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||
order: link.order,
|
||||
isRequired: link.isRequired,
|
||||
score: link.score,
|
||||
hint: link.hint,
|
||||
isCompleted: String(problemId) in completed,
|
||||
}) satisfies ProblemSetProblem,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
async function recomputeProgress(
|
||||
@@ -202,41 +323,70 @@ async function recomputeProgress(
|
||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
const links = await tx.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
const links = await tx
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
||||
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
||||
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 }
|
||||
}
|
||||
|
||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||
const parsed = joinProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||
const parsed = joinProblemSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||
const user = c.get("user")!
|
||||
const [problemSet] = await db.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)
|
||||
const [problemSet] = await db
|
||||
.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", "题单不存在")
|
||||
const [existing] = await db.select({ id: schema.problemsetProgress.id }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))).limit(1)
|
||||
const [existing] = await db
|
||||
.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", "已经加入该题单")
|
||||
await db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(schema.problemsetProgress).values({
|
||||
problemsetId: problemSet.id,
|
||||
userId: user.id,
|
||||
joinTime: new Date().toISOString(),
|
||||
completeTime: null,
|
||||
isCompleted: false,
|
||||
progressPercentage: 0,
|
||||
completedProblemsCount: 0,
|
||||
totalProblemsCount: 0,
|
||||
totalScore: 0,
|
||||
progressDetail: {},
|
||||
}).returning()
|
||||
const [created] = await tx
|
||||
.insert(schema.problemsetProgress)
|
||||
.values({
|
||||
problemsetId: problemSet.id,
|
||||
userId: user.id,
|
||||
joinTime: new Date().toISOString(),
|
||||
completeTime: null,
|
||||
isCompleted: false,
|
||||
progressPercentage: 0,
|
||||
completedProblemsCount: 0,
|
||||
totalProblemsCount: 0,
|
||||
totalScore: 0,
|
||||
progressDetail: {},
|
||||
})
|
||||
.returning()
|
||||
if (created) await recomputeProgress(tx, created, {})
|
||||
})
|
||||
return success(c, null, 201)
|
||||
@@ -245,87 +395,206 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
|
||||
const requested = c.req.param("username")
|
||||
const username = requested === "me" ? c.get("user")?.username : requested
|
||||
if (!username) return failure(c, 401, "login-required", "Authentication required")
|
||||
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 (!username)
|
||||
return failure(c, 401, "login-required", "Authentication required")
|
||||
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", "用户不存在")
|
||||
const rows = await db.select({ userBadge: schema.userBadge, badge: schema.problemsetBadge, 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,
|
||||
userId: userBadge.userId,
|
||||
badge: badgeData(badge),
|
||||
earnedTime: userBadge.earnedTime,
|
||||
problemset: { id: problemSet.id, title: problemSet.title },
|
||||
} satisfies UserBadge)))
|
||||
const rows = await db
|
||||
.select({
|
||||
userBadge: schema.userBadge,
|
||||
badge: schema.problemsetBadge,
|
||||
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,
|
||||
userId: userBadge.userId,
|
||||
badge: badgeData(badge),
|
||||
earnedTime: userBadge.earnedTime,
|
||||
problemset: { id: problemSet.id, title: problemSet.title },
|
||||
}) satisfies UserBadge,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.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)
|
||||
const [problemSet] = await db
|
||||
.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", "题单不存在")
|
||||
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, id))
|
||||
return success(c, badges.map((badge) => badgeData(badge)))
|
||||
const badges = await db
|
||||
.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) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id, 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)一致:超管放行,
|
||||
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||
// 越权报「不存在」,不泄露题单存在与否。
|
||||
const user = c.get("user")!
|
||||
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
|
||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
}
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const className = c.req.query("className")?.trim()
|
||||
const completion = c.req.query("completionStatus")?.trim()
|
||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
||||
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
||||
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
||||
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 [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) })
|
||||
.from(schema.problemsetProgress).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))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
||||
])
|
||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
||||
const results = rows.map(({ progress, user: progressUser, realName }) => ({
|
||||
id: progress.id,
|
||||
problemsetId: progress.problemsetId,
|
||||
user: sampleUser(progressUser, realName),
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
|
||||
} satisfies ProblemSetProgress))
|
||||
const stats = statsRows[0]
|
||||
return success(c, {
|
||||
results,
|
||||
total: stats?.total ?? 0,
|
||||
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
|
||||
problems: problemRows,
|
||||
} satisfies ProblemSetProgressList)
|
||||
})
|
||||
problemsetRoutes.get(
|
||||
"/problem-sets/:id/user-progress",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db
|
||||
.select({
|
||||
id: schema.problemset.id,
|
||||
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)一致:超管放行,
|
||||
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||
// 越权报「不存在」,不泄露题单存在与否。
|
||||
const user = c.get("user")!
|
||||
if (
|
||||
!problemSet ||
|
||||
(user.adminType !== "Super Admin" && problemSet.createdById !== user.id)
|
||||
) {
|
||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
}
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const className = c.req.query("className")?.trim()
|
||||
const completion = c.req.query("completionStatus")?.trim()
|
||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
||||
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
||||
if (completion === "completed")
|
||||
filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
||||
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 [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),
|
||||
})
|
||||
.from(schema.problemsetProgress)
|
||||
.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))
|
||||
.orderBy(
|
||||
asc(schema.problemsetProblem.order),
|
||||
asc(schema.problemsetProblem.id),
|
||||
),
|
||||
])
|
||||
const problemMap = new Map(
|
||||
problemRows.map((problem) => [String(problem.id), problem]),
|
||||
)
|
||||
const results = rows.map(
|
||||
({ progress, user: progressUser, realName }) =>
|
||||
({
|
||||
id: progress.id,
|
||||
problemsetId: progress.problemsetId,
|
||||
user: sampleUser(progressUser, realName),
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
completedProblems: Object.keys(
|
||||
objectValue(progress.progressDetail),
|
||||
).flatMap((key) => problemMap.get(key) ?? []),
|
||||
}) satisfies ProblemSetProgress,
|
||||
)
|
||||
const stats = statsRows[0]
|
||||
return success(c, {
|
||||
results,
|
||||
total: stats?.total ?? 0,
|
||||
statistics: {
|
||||
total: stats?.total ?? 0,
|
||||
completed: stats?.completed ?? 0,
|
||||
avgProgress: Number(stats?.avgProgress ?? 0),
|
||||
},
|
||||
problems: problemRows,
|
||||
} satisfies ProblemSetProgressList)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,7 +36,10 @@ siteRoutes.get("/site/online", async (c) => {
|
||||
|
||||
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
|
||||
const fallbackQuotes = [
|
||||
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
||||
{
|
||||
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
|
||||
from: "Structure and Interpretation of Computer Programs",
|
||||
},
|
||||
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
|
||||
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
|
||||
]
|
||||
@@ -50,10 +53,15 @@ const sentenceCache = new Map<string, Quote[]>()
|
||||
async function loadSentences(path: string) {
|
||||
const cached = sentenceCache.get(path)
|
||||
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 : [])
|
||||
.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}`)
|
||||
sentenceCache.set(path, rows)
|
||||
return rows
|
||||
@@ -61,8 +69,12 @@ async function loadSentences(path: string) {
|
||||
|
||||
async function randomQuote() {
|
||||
if (!categoryPaths) {
|
||||
const categories = await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json() as { path?: string }[]
|
||||
const paths = categories.map((it) => it.path).filter((it): it is string => typeof it === "string")
|
||||
const categories = (await Bun.file(
|
||||
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")
|
||||
categoryPaths = paths
|
||||
}
|
||||
@@ -75,7 +87,8 @@ siteRoutes.get("/quotes/random", async (c) => {
|
||||
try {
|
||||
return success(c, (await randomQuote()) satisfies Quote)
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
@@ -83,7 +96,12 @@ siteRoutes.get("/quotes/random", async (c) => {
|
||||
siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||
const className = c.req.param("className").trim()
|
||||
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
|
||||
.select({ username: schema.user.username })
|
||||
@@ -91,5 +109,8 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||
.where(eq(schema.user.className, className))
|
||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
||||
return success(c, rows.map(({ username }) => stripClassPrefix(username, className)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(({ username }) => stripClassPrefix(username, className)),
|
||||
)
|
||||
})
|
||||
|
||||
+565
-226
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,9 @@ export function selfCommand(subcommand: string): string[] {
|
||||
* 而 docker/compose.dev.yml 挂给判题沙箱的是**仓库根**的 data/test_case —— 按 cwd 解析
|
||||
* 就会落到 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`)所在目录。
|
||||
@@ -48,4 +50,6 @@ export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, ".
|
||||
*/
|
||||
export const migrationsDir =
|
||||
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"))
|
||||
|
||||
@@ -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)) {
|
||||
const wasmPath = WASM_BY_LANGUAGE[language]
|
||||
if (!wasmPath) {
|
||||
console.log(`⚠ ${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`)
|
||||
console.log(` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`)
|
||||
console.log(
|
||||
`⚠ ${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`,
|
||||
)
|
||||
console.log(
|
||||
` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`,
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
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)) {
|
||||
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) {
|
||||
console.log(`\n⚠ ${language} 的 ${target} → "${node}"`)
|
||||
console.log(` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`)
|
||||
console.log(` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`)
|
||||
console.log(
|
||||
` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`,
|
||||
)
|
||||
console.log(
|
||||
` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`,
|
||||
)
|
||||
}
|
||||
process.exit(1)
|
||||
|
||||
@@ -63,7 +63,9 @@ export function shadows(pattern: string, target: string) {
|
||||
function collect(): Route[] {
|
||||
const routerFile = new Map<string, string>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +74,10 @@ function collect(): Route[] {
|
||||
const file = routerFile.get(router)
|
||||
if (!file) return []
|
||||
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) => ({
|
||||
method: m[1]!.toUpperCase(),
|
||||
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
|
||||
@@ -83,10 +88,14 @@ function collect(): Route[] {
|
||||
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
|
||||
const index = readFileSync(join(SRC, "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[] = []
|
||||
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
|
||||
if (router === "adminRoutes") {
|
||||
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 j = i + 1; j < routes.length; j++) {
|
||||
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) {
|
||||
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(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
|
||||
}
|
||||
process.exit(1)
|
||||
|
||||
+159
-56
@@ -3,7 +3,11 @@ import { eq, sql } from "drizzle-orm"
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus, isAccepted } from "../judge/status"
|
||||
import { objectValue } from "../routes/helpers"
|
||||
import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../services/achievements"
|
||||
import {
|
||||
metaAchievements,
|
||||
refreshUnlockedCount,
|
||||
rescanAchievement,
|
||||
} from "../services/achievements"
|
||||
|
||||
/**
|
||||
* 把反范式的计数列重算回与 submission 表一致。
|
||||
@@ -50,7 +54,11 @@ type ProblemExpected = {
|
||||
* contestId,只有 user_profile 那一段才分。
|
||||
*/
|
||||
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
|
||||
from submission
|
||||
where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]})
|
||||
@@ -93,7 +101,11 @@ type ProfileExpected = {
|
||||
* 所以按 create_time 算,不额外记判完时间。
|
||||
*/
|
||||
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,
|
||||
count(*)::int as submissions,
|
||||
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 blank = (): ProfileExpected => ({ submissionNumber: 0, acceptedNumber: 0, status: {} })
|
||||
const blank = (): ProfileExpected => ({
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
status: {},
|
||||
})
|
||||
for (const row of totals) {
|
||||
const current = expected.get(row.user_id) ?? blank()
|
||||
current.submissionNumber = row.submissions
|
||||
@@ -146,7 +162,9 @@ async function expectedProfiles() {
|
||||
function stable(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
|
||||
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 JSON.stringify(value) ?? "null"
|
||||
@@ -156,7 +174,10 @@ type Diff = { label: string; field: string; before: unknown; after: unknown }
|
||||
type Plan = {
|
||||
diffs: Diff[]
|
||||
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 不对的用户 */
|
||||
unlockedCountFixes: number[]
|
||||
/** 按正确计数已达标、却没持有元成就的 (用户, 元成就) */
|
||||
@@ -183,22 +204,43 @@ async function unlockedCountPlan(plan: Plan) {
|
||||
metaAchievements(),
|
||||
])
|
||||
const holders = metas.length
|
||||
? await db.select({ userId: schema.userAchievement.userId, achievementId: schema.userAchievement.achievementId })
|
||||
.from(schema.userAchievement)
|
||||
.where(sql`${schema.userAchievement.achievementId} in ${metas.map((meta) => meta.id)}`)
|
||||
? await db
|
||||
.select({
|
||||
userId: schema.userAchievement.userId,
|
||||
achievementId: schema.userAchievement.achievementId,
|
||||
})
|
||||
.from(schema.userAchievement)
|
||||
.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) {
|
||||
const label = `用户 ${row.user_id}`
|
||||
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)
|
||||
}
|
||||
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
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -206,26 +248,37 @@ async function unlockedCountPlan(plan: Plan) {
|
||||
|
||||
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
|
||||
async function computePlan(): Promise<Plan> {
|
||||
const [problems, profiles, expectedProblem, expectedProfile] = await Promise.all([
|
||||
db.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
submissionNumber: schema.problem.submissionNumber,
|
||||
acceptedNumber: schema.problem.acceptedNumber,
|
||||
statisticInfo: schema.problem.statisticInfo,
|
||||
}).from(schema.problem),
|
||||
db.select({
|
||||
id: schema.userProfile.id,
|
||||
userId: schema.userProfile.userId,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
acmProblemsStatus: schema.userProfile.acmProblemsStatus,
|
||||
}).from(schema.userProfile),
|
||||
expectedProblems(),
|
||||
expectedProfiles(),
|
||||
])
|
||||
const [problems, profiles, expectedProblem, expectedProfile] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
submissionNumber: schema.problem.submissionNumber,
|
||||
acceptedNumber: schema.problem.acceptedNumber,
|
||||
statisticInfo: schema.problem.statisticInfo,
|
||||
})
|
||||
.from(schema.problem),
|
||||
db
|
||||
.select({
|
||||
id: schema.userProfile.id,
|
||||
userId: schema.userProfile.userId,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
acmProblemsStatus: schema.userProfile.acmProblemsStatus,
|
||||
})
|
||||
.from(schema.userProfile),
|
||||
expectedProblems(),
|
||||
expectedProfiles(),
|
||||
])
|
||||
|
||||
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [], unlockedCountFixes: [], metaGrants: [] }
|
||||
const plan: Plan = {
|
||||
diffs: [],
|
||||
problemFixes: [],
|
||||
profileFixes: [],
|
||||
unlockedCountFixes: [],
|
||||
metaGrants: [],
|
||||
}
|
||||
|
||||
for (const problem of problems) {
|
||||
const want = expectedProblem.get(problem.id) ?? {
|
||||
@@ -236,13 +289,30 @@ async function computePlan(): Promise<Plan> {
|
||||
const label = `题目 ${problem.displayId}(id=${problem.id})`
|
||||
const rows: Diff[] = []
|
||||
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) {
|
||||
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)) {
|
||||
rows.push({ label, field: "statistic_info", before: problem.statisticInfo, after: want.statisticInfo })
|
||||
if (
|
||||
stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo)
|
||||
) {
|
||||
rows.push({
|
||||
label,
|
||||
field: "statistic_info",
|
||||
before: problem.statisticInfo,
|
||||
after: want.statisticInfo,
|
||||
})
|
||||
}
|
||||
if (rows.length) {
|
||||
plan.diffs.push(...rows)
|
||||
@@ -262,19 +332,38 @@ async function computePlan(): Promise<Plan> {
|
||||
const merged: Record<string, unknown> = { ...existing }
|
||||
delete merged.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 rows: Diff[] = []
|
||||
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) {
|
||||
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)) {
|
||||
const keys = new Set([...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} 题(含比赛桶重建)` })
|
||||
const keys = new Set([
|
||||
...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) {
|
||||
plan.diffs.push(...rows)
|
||||
@@ -286,11 +375,16 @@ async function computePlan(): Promise<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)) {
|
||||
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 = 落库后复核仍有差异 */
|
||||
@@ -309,27 +403,36 @@ export async function recount(options: { apply: boolean }) {
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const fix of plan.problemFixes) {
|
||||
await tx.update(schema.problem).set({
|
||||
submissionNumber: fix.value.submissionNumber,
|
||||
acceptedNumber: fix.value.acceptedNumber,
|
||||
statisticInfo: fix.value.statisticInfo,
|
||||
}).where(eq(schema.problem.id, fix.id))
|
||||
await tx
|
||||
.update(schema.problem)
|
||||
.set({
|
||||
submissionNumber: fix.value.submissionNumber,
|
||||
acceptedNumber: fix.value.acceptedNumber,
|
||||
statisticInfo: fix.value.statisticInfo,
|
||||
})
|
||||
.where(eq(schema.problem.id, fix.id))
|
||||
}
|
||||
for (const fix of plan.profileFixes) {
|
||||
await tx.update(schema.userProfile).set({
|
||||
submissionNumber: fix.value.submissionNumber,
|
||||
acceptedNumber: fix.value.acceptedNumber,
|
||||
acmProblemsStatus: fix.value.merged,
|
||||
}).where(eq(schema.userProfile.id, fix.id))
|
||||
await tx
|
||||
.update(schema.userProfile)
|
||||
.set({
|
||||
submissionNumber: fix.value.submissionNumber,
|
||||
acceptedNumber: fix.value.acceptedNumber,
|
||||
acmProblemsStatus: fix.value.merged,
|
||||
})
|
||||
.where(eq(schema.userProfile.id, fix.id))
|
||||
}
|
||||
})
|
||||
// 先改计数、再补发:rescanAchievement 读的是 metrics 里的计数。
|
||||
// 补发幂等(唯一键 + 冲突忽略),重跑不会重复发
|
||||
const recounted = await refreshUnlockedCount(plan.unlockedCountFixes)
|
||||
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。这里还剩差异说明口径本身有问题(不是数据脏),
|
||||
// 必须让部署脚本看见非零退出码,而不是打一行字了事。
|
||||
|
||||
@@ -8,7 +8,9 @@ import { db, schema } from "../db"
|
||||
* raw_password,其中一个还是超管。对着生产库跑一次就是把超管密码改掉,
|
||||
* 所以这里按 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 = (() => {
|
||||
try {
|
||||
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:")
|
||||
|
||||
@@ -14,24 +14,85 @@ export interface AchievementMetric {
|
||||
}
|
||||
|
||||
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
{ key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" },
|
||||
{ key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" },
|
||||
{ key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" },
|
||||
{ key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" },
|
||||
{
|
||||
key: "accepted_count",
|
||||
name: "AC 题目数",
|
||||
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: "max_ac_streak_days", name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数" },
|
||||
{
|
||||
key: "max_ac_streak_days",
|
||||
name: "最长连续 AC 天数",
|
||||
helpText: "连续每天至少 AC 一题的最长天数",
|
||||
},
|
||||
{ key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
|
||||
{ key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" },
|
||||
{
|
||||
key: "contest_joined",
|
||||
name: "参赛场次",
|
||||
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
|
||||
},
|
||||
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
|
||||
{ key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" },
|
||||
{ key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" },
|
||||
{ key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:00–5:00 之间的提交次数" },
|
||||
{ key: "early_bird_submissions", name: "早起提交次数", helpText: "5:00–7: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 },
|
||||
{
|
||||
key: "problemset_completed",
|
||||
name: "完成题单数",
|
||||
helpText: "完成的题单数量",
|
||||
},
|
||||
{
|
||||
key: "first_try_ac_count",
|
||||
name: "一发入魂次数",
|
||||
helpText: "首次提交即通过的次数",
|
||||
},
|
||||
{
|
||||
key: "midnight_submissions",
|
||||
name: "凌晨提交次数",
|
||||
helpText: "0:00–5:00 之间的提交次数",
|
||||
},
|
||||
{
|
||||
key: "early_bird_submissions",
|
||||
name: "早起提交次数",
|
||||
helpText: "5:00–7: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]))
|
||||
@@ -43,4 +104,3 @@ export function findMetric(key: string) {
|
||||
export function metricName(key: string) {
|
||||
return BY_KEY.get(key)?.name ?? key
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { publishAchievementNotification } from "../events"
|
||||
@@ -12,49 +23,90 @@ function numberMetric(metrics: Record<string, unknown>, key: string) {
|
||||
return typeof value === "number" ? value : 0
|
||||
}
|
||||
|
||||
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) {
|
||||
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement)
|
||||
async function unlockAchievements(
|
||||
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))
|
||||
const filters = [eq(schema.achievement.visible, true)]
|
||||
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
|
||||
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
|
||||
if (unlocked.length)
|
||||
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"))
|
||||
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 value = metrics[achievement.metric]
|
||||
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 []
|
||||
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
|
||||
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
|
||||
const inserted = await db.insert(schema.userAchievement).values(hits.map((achievement) => ({
|
||||
userId,
|
||||
achievementId: achievement.id,
|
||||
unlockTime: new Date().toISOString(),
|
||||
backfilled: false,
|
||||
notified: false,
|
||||
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
|
||||
const inserted = await db
|
||||
.insert(schema.userAchievement)
|
||||
.values(
|
||||
hits.map((achievement) => ({
|
||||
userId,
|
||||
achievementId: achievement.id,
|
||||
unlockTime: new Date().toISOString(),
|
||||
backfilled: false,
|
||||
notified: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.userAchievement.achievementId,
|
||||
schema.userAchievement.userId,
|
||||
],
|
||||
})
|
||||
.returning({ achievementId: schema.userAchievement.achievementId })
|
||||
if (inserted.length === 0) return []
|
||||
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]))
|
||||
return hits.filter((achievement) => insertedIds.has(achievement.id))
|
||||
}
|
||||
|
||||
export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.submission.id, submissionId)).limit(1)
|
||||
const [row] = await db
|
||||
.select({ submission: schema.submission, problem: schema.problem })
|
||||
.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 []
|
||||
|
||||
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
ne(schema.submission.id, row.submission.id),
|
||||
))
|
||||
const priorRows = await db
|
||||
.select({ result: schema.submission.result })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
ne(schema.submission.id, row.submission.id),
|
||||
),
|
||||
)
|
||||
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
|
||||
const accepted = isAccepted(row.submission.result)
|
||||
const firstAc = accepted && !priorAccepted
|
||||
@@ -63,97 +115,184 @@ export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
const hour = localHour(row.submission.createTime)
|
||||
|
||||
const metrics = await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.userStat).values({
|
||||
userId: row.submission.userId,
|
||||
metrics: {},
|
||||
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")
|
||||
await tx
|
||||
.insert(schema.userStat)
|
||||
.values({
|
||||
userId: row.submission.userId,
|
||||
metrics: {},
|
||||
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")
|
||||
if (!stat) throw new Error("User achievement stat could not be created")
|
||||
const value = objectValue(stat.metrics)
|
||||
value.submission_count = numberMetric(value, "submission_count") + 1
|
||||
if (firstAc) {
|
||||
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 === "High") 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)
|
||||
if (row.problem.difficulty === "Mid")
|
||||
value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
|
||||
if (row.problem.difficulty === "High")
|
||||
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)
|
||||
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
|
||||
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)
|
||||
value._active_dates = activeDates
|
||||
value.active_days = activeDates.length
|
||||
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) {
|
||||
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
|
||||
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
|
||||
const current = last && dayNumber(date) - dayNumber(last) === 1
|
||||
? numberMetric(value, "_current_ac_streak") + 1
|
||||
: 1
|
||||
const current =
|
||||
last && dayNumber(date) - dayNumber(last) === 1
|
||||
? numberMetric(value, "_current_ac_streak") + 1
|
||||
: 1
|
||||
value._last_ac_date = date
|
||||
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") : []
|
||||
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
|
||||
const languages = Array.isArray(value._languages)
|
||||
? 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_used = languages.length
|
||||
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
|
||||
else if (hour < 7) 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))
|
||||
if (hour < 5)
|
||||
value.midnight_submissions =
|
||||
numberMetric(value, "midnight_submissions") + 1
|
||||
else if (hour < 7)
|
||||
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
|
||||
})
|
||||
|
||||
const first = await unlockAchievements(row.submission.userId, metrics)
|
||||
if (!first.length) return []
|
||||
const [meta] = await db.select({ value: count() }).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")))
|
||||
const [meta] = await db
|
||||
.select({ value: count() })
|
||||
.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
|
||||
await db.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))]
|
||||
await db
|
||||
.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) {
|
||||
const [[badgeRow], [completedRow]] = await Promise.all([
|
||||
db.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.isCompleted, true),
|
||||
)),
|
||||
db
|
||||
.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.isCompleted, true),
|
||||
),
|
||||
),
|
||||
])
|
||||
const metrics = await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.userStat).values({
|
||||
userId,
|
||||
metrics: {},
|
||||
updateTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
||||
const [stat] = await tx.select().from(schema.userStat)
|
||||
.where(eq(schema.userStat.userId, userId)).for("update").limit(1)
|
||||
await tx
|
||||
.insert(schema.userStat)
|
||||
.values({
|
||||
userId,
|
||||
metrics: {},
|
||||
updateTime: new Date().toISOString(),
|
||||
})
|
||||
.onConflictDoNothing({ target: schema.userStat.userId })
|
||||
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")
|
||||
const value = objectValue(stat.metrics)
|
||||
value.badge_count = badgeRow?.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))
|
||||
return value
|
||||
})
|
||||
|
||||
const first = await unlockAchievements(userId, metrics)
|
||||
if (!first.length) return []
|
||||
const [meta] = await db.select({ value: count() }).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")))
|
||||
const [meta] = await db
|
||||
.select({ value: count() })
|
||||
.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
|
||||
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))
|
||||
return [...first, ...(await unlockAchievements(userId, metrics, true))]
|
||||
}
|
||||
@@ -170,22 +309,39 @@ const USER_ACHIEVEMENT_INSERT_CHUNK = 1000
|
||||
* 而不显示日期,否则一次补发会给几百人盖同一个时间戳,把「最近获得」板块冲垮。
|
||||
*/
|
||||
export async function rescanAchievement(achievementId: number) {
|
||||
const [achievement] = await db.select().from(schema.achievement)
|
||||
.where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true))).limit(1)
|
||||
const [achievement] = await db
|
||||
.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 }
|
||||
|
||||
const metric = findMetric(achievement.metric)
|
||||
if (!metric) return { scanned: 0, unlocked: 0 }
|
||||
|
||||
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
||||
if (achievement.metric === "contest_joined") await refreshContestJoinedForAll()
|
||||
if (achievement.metric === "contest_joined")
|
||||
await refreshContestJoinedForAll()
|
||||
|
||||
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)
|
||||
const eligible = stats.filter((stat) => {
|
||||
if (already.has(stat.userId)) return false
|
||||
@@ -201,39 +357,62 @@ export async function rescanAchievement(achievementId: number) {
|
||||
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
|
||||
const unlockTime = new Date().toISOString()
|
||||
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 inserted = await db.insert(schema.userAchievement).values(chunk.map((stat) => ({
|
||||
userId: stat.userId,
|
||||
achievementId: achievement.id,
|
||||
unlockTime,
|
||||
backfilled: true,
|
||||
notified: false,
|
||||
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
|
||||
const inserted = await db
|
||||
.insert(schema.userAchievement)
|
||||
.values(
|
||||
chunk.map((stat) => ({
|
||||
userId: stat.userId,
|
||||
achievementId: achievement.id,
|
||||
unlockTime,
|
||||
backfilled: true,
|
||||
notified: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.userAchievement.achievementId,
|
||||
schema.userAchievement.userId,
|
||||
],
|
||||
})
|
||||
.returning({ userId: schema.userAchievement.userId })
|
||||
unlockedUserIds.push(...inserted.map((row) => row.userId))
|
||||
}
|
||||
if (unlockedUserIds.length) {
|
||||
await db.update(schema.achievement)
|
||||
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}` })
|
||||
await db
|
||||
.update(schema.achievement)
|
||||
.set({
|
||||
unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}`,
|
||||
})
|
||||
.where(eq(schema.achievement.id, achievement.id))
|
||||
for (const userId of unlockedUserIds) {
|
||||
await publishAchievementNotification(userId, [{
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
}])
|
||||
await publishAchievementNotification(userId, [
|
||||
{
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
},
|
||||
])
|
||||
}
|
||||
// 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。
|
||||
// 旧 `rescan_achievement` 就漏了这步,OJ2 原样搬过来:2026-09-07 一次补发之后
|
||||
// 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 ——
|
||||
// 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。
|
||||
if (achievement.rarity !== "platinum" && achievement.metric !== "achievement_unlocked_count") {
|
||||
if (
|
||||
achievement.rarity !== "platinum" &&
|
||||
achievement.metric !== "achievement_unlocked_count"
|
||||
) {
|
||||
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 }
|
||||
@@ -241,9 +420,20 @@ export async function rescanAchievement(achievementId: number) {
|
||||
|
||||
/** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */
|
||||
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)
|
||||
.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)
|
||||
}
|
||||
|
||||
|
||||
/** 同上,3 个参数一行 */
|
||||
const STAT_UPSERT_CHUNK = 1000
|
||||
|
||||
@@ -299,19 +488,25 @@ const STAT_UPSERT_CHUNK = 1000
|
||||
*/
|
||||
async function refreshContestJoinedForAll() {
|
||||
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)
|
||||
.where(isNotNull(schema.submission.contestId))
|
||||
.groupBy(schema.submission.userId)
|
||||
const now = new Date().toISOString()
|
||||
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
|
||||
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
|
||||
await db.insert(schema.userStat)
|
||||
.values(chunk.map((row) => ({
|
||||
userId: row.userId,
|
||||
metrics: { contest_joined: row.value },
|
||||
updateTime: now,
|
||||
})))
|
||||
await db
|
||||
.insert(schema.userStat)
|
||||
.values(
|
||||
chunk.map((row) => ({
|
||||
userId: row.userId,
|
||||
metrics: { contest_joined: row.value },
|
||||
updateTime: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.userStat.userId,
|
||||
set: {
|
||||
|
||||
+55
-19
@@ -27,14 +27,27 @@ export async function completeChat(system: string, user: string) {
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
], false)),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${config.aiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
requestBody(
|
||||
[
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
false,
|
||||
),
|
||||
),
|
||||
})
|
||||
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
||||
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||
if (!response.ok)
|
||||
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() ?? ""
|
||||
}
|
||||
|
||||
@@ -48,21 +61,37 @@ export function streamChat(
|
||||
async start(controller) {
|
||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||
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")
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
], true)),
|
||||
})
|
||||
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
||||
const response = await fetch(
|
||||
new URL("/chat/completions", config.aiBaseUrl),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${config.aiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
requestBody(
|
||||
[
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
true,
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(
|
||||
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
|
||||
)
|
||||
send("event: start\n\n")
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
@@ -79,7 +108,12 @@ export function streamChat(
|
||||
const data = line.slice(5).trim()
|
||||
if (data === "[DONE]") continue
|
||||
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 content = choice?.delta?.content
|
||||
if (content) {
|
||||
@@ -96,7 +130,9 @@ export function streamChat(
|
||||
if (onComplete) await onComplete(full)
|
||||
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
||||
} 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 {
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
|
||||
@@ -29,23 +29,40 @@ export function contestStatus(contest: ContestRow) {
|
||||
return "0" as const
|
||||
}
|
||||
|
||||
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
|
||||
export function isContestAdmin(
|
||||
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)
|
||||
}
|
||||
|
||||
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 true
|
||||
const parts = candidate.split("#")
|
||||
if (parts.length !== 2) return false
|
||||
const [signature, expiresAt] = parts
|
||||
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
|
||||
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
|
||||
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
||||
const expectedSignature = createHash("sha256")
|
||||
.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。
|
||||
*/
|
||||
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) {
|
||||
const [contest] = await db.select().from(schema.contest)
|
||||
.where(eq(schema.contest.id, id)).limit(1)
|
||||
export async function findAccessibleContest(
|
||||
user: AuthUser | null | undefined,
|
||||
id: number,
|
||||
) {
|
||||
const [contest] = await db
|
||||
.select()
|
||||
.from(schema.contest)
|
||||
.where(eq(schema.contest.id, id))
|
||||
.limit(1)
|
||||
if (!contest) return 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",
|
||||
) {
|
||||
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 (contest.password) {
|
||||
const stored = await getContestPassword(c, contest.id)
|
||||
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") {
|
||||
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 }
|
||||
}
|
||||
@@ -104,11 +136,20 @@ export function requireContestAccess(
|
||||
): MiddlewareHandler<ContestEnv> {
|
||||
return async (c, next) => {
|
||||
const id = Number(c.req.param(paramName))
|
||||
const contest = 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 contest =
|
||||
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)
|
||||
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)
|
||||
await next()
|
||||
|
||||
@@ -34,7 +34,8 @@ export function exerciseDataError(
|
||||
case "mcq": {
|
||||
const options = strings(data.options)
|
||||
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, "正确答案")
|
||||
}
|
||||
case "sort": {
|
||||
@@ -53,12 +54,19 @@ export function exerciseDataError(
|
||||
case "match": {
|
||||
const left = strings(data.left)
|
||||
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 "连线题左右两列的行数必须相等"
|
||||
return indexAnswerError(data.answer, right.length, "连线答案", left.length)
|
||||
return indexAnswerError(
|
||||
data.answer,
|
||||
right.length,
|
||||
"连线答案",
|
||||
left.length,
|
||||
)
|
||||
}
|
||||
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) {
|
||||
return "输出预测题至少要有一个正确输出"
|
||||
}
|
||||
@@ -75,7 +83,13 @@ export function exerciseDataError(
|
||||
if (buckets.length < 2) return "归类题至少要有 2 个分组"
|
||||
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}的条数(${answer.length})和项目数(${length})对不上`
|
||||
}
|
||||
if (answer.some((item) => item < 0 || item >= bound)) return `${label}的下标越界`
|
||||
if (unique && new Set(answer).size !== answer.length) return `${label}里有重复的下标`
|
||||
if (answer.some((item) => item < 0 || item >= bound))
|
||||
return `${label}的下标越界`
|
||||
if (unique && new Set(answer).size !== answer.length)
|
||||
return `${label}里有重复的下标`
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -32,20 +32,25 @@ async function runFormatter(command: string[], code: string) {
|
||||
}
|
||||
|
||||
function formatSql(code: string) {
|
||||
return code
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean)
|
||||
.map((statement) =>
|
||||
statement.replace(
|
||||
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
|
||||
(keyword) => keyword.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
|
||||
return (
|
||||
code
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean)
|
||||
.map((statement) =>
|
||||
statement.replace(
|
||||
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
|
||||
(keyword) => keyword.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.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 === "python") {
|
||||
@@ -54,7 +59,10 @@ export async function formatCode(code: string, language: "python" | "c" | "cpp"
|
||||
code,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,14 +15,22 @@ export const websiteOptionDefaults = {
|
||||
|
||||
export async function getOptions<const T extends readonly string[]>(keys: T) {
|
||||
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)
|
||||
.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() {
|
||||
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)
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
|
||||
|
||||
@@ -6,8 +6,13 @@ import { objectValue } from "../routes/helpers"
|
||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
||||
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
|
||||
type BadgeCheck = Pick<ProgressRow,
|
||||
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail">
|
||||
type BadgeCheck = Pick<
|
||||
ProgressRow,
|
||||
| "completedProblemsCount"
|
||||
| "totalProblemsCount"
|
||||
| "totalScore"
|
||||
| "progressDetail"
|
||||
>
|
||||
|
||||
/**
|
||||
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
|
||||
@@ -24,7 +29,9 @@ export function computeProgress(
|
||||
previousCompleteTime: string | null,
|
||||
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 就会比实际做出的题还多
|
||||
const kept: Record<string, unknown> = {}
|
||||
let totalScore = 0
|
||||
@@ -44,7 +51,9 @@ export function computeProgress(
|
||||
const required = links.filter((link) => link.isRequired)
|
||||
const graded = required.length ? required : links
|
||||
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
|
||||
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
|
||||
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
|
||||
@@ -55,7 +64,8 @@ export function computeProgress(
|
||||
completedProblemsCount: completed,
|
||||
totalScore,
|
||||
// 乘 10000 四舍五入再除 100,保留两位小数
|
||||
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
|
||||
progressPercentage:
|
||||
total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
|
||||
isCompleted,
|
||||
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
|
||||
//
|
||||
@@ -78,7 +88,8 @@ async function writeProgress(rows: ProgressWrite[]) {
|
||||
for (let start = 0; start < rows.length; start += 1000) {
|
||||
const chunk = rows.slice(start, start + 1000)
|
||||
const values = sql.join(
|
||||
chunk.map((row) => sql`(
|
||||
chunk.map(
|
||||
(row) => sql`(
|
||||
${row.id}::bigint,
|
||||
${JSON.stringify(row.progressDetail)}::jsonb,
|
||||
${row.totalProblemsCount}::int,
|
||||
@@ -87,7 +98,8 @@ async function writeProgress(rows: ProgressWrite[]) {
|
||||
${row.progressPercentage}::double precision,
|
||||
${row.isCompleted}::boolean,
|
||||
${row.completeTime}::timestamptz
|
||||
)`),
|
||||
)`,
|
||||
),
|
||||
sql`, `,
|
||||
)
|
||||
await db.execute(sql`
|
||||
@@ -118,13 +130,19 @@ async function writeProgress(rows: ProgressWrite[]) {
|
||||
*/
|
||||
export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
|
||||
if (badge.conditionType === "all_problems") {
|
||||
return progress.totalProblemsCount > 0 &&
|
||||
return (
|
||||
progress.totalProblemsCount > 0 &&
|
||||
progress.completedProblemsCount === progress.totalProblemsCount
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -136,26 +154,45 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
|
||||
* 调用方手里已经有最新的进度时把它传进来(`known`),省掉一次回表;
|
||||
* 更要紧的是别用刚写完库之前的旧值去判定。
|
||||
*/
|
||||
export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
|
||||
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)
|
||||
export async function recalculateBadge(
|
||||
badge: BadgeRow,
|
||||
known?: (BadgeCheck & { userId: number })[],
|
||||
) {
|
||||
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 tx.delete(schema.userBadge).where(and(
|
||||
eq(schema.userBadge.badgeId, badge.id),
|
||||
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
|
||||
))
|
||||
await tx
|
||||
.delete(schema.userBadge)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userBadge.badgeId, badge.id),
|
||||
eligibleIds.length
|
||||
? notInArray(schema.userBadge.userId, eligibleIds)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
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))
|
||||
const have = new Set(existing.map((item) => item.userId))
|
||||
const missing = eligibleIds.filter((id) => !have.has(id))
|
||||
if (missing.length) {
|
||||
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
})))
|
||||
await tx.insert(schema.userBadge).values(
|
||||
missing.map((userId) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -174,20 +211,32 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
|
||||
*/
|
||||
export async function resyncProgress(problemsetId: number) {
|
||||
const [links, progresses, badges] = await Promise.all([
|
||||
db.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
|
||||
db.select().from(schema.problemsetProgress)
|
||||
db
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
|
||||
db.select().from(schema.problemsetBadge)
|
||||
db
|
||||
.select()
|
||||
.from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
|
||||
])
|
||||
const now = new Date().toISOString()
|
||||
const updated = progresses.map((progress) => ({
|
||||
...progress,
|
||||
...computeProgress(objectValue(progress.progressDetail), links, progress.completeTime, now),
|
||||
...computeProgress(
|
||||
objectValue(progress.progressDetail),
|
||||
links,
|
||||
progress.completeTime,
|
||||
now,
|
||||
),
|
||||
}))
|
||||
if (updated.length) await writeProgress(updated)
|
||||
for (const badge of badges) await recalculateBadge(badge, updated)
|
||||
@@ -214,59 +263,93 @@ export async function recordSolvedProblem(
|
||||
const joined = await db
|
||||
.select({ problemsetId: schema.problemsetProgress.problemsetId })
|
||||
.from(schema.problemsetProgress)
|
||||
.innerJoin(schema.problemsetProblem, and(
|
||||
eq(schema.problemsetProblem.problemsetId, schema.problemsetProgress.problemsetId),
|
||||
eq(schema.problemsetProblem.problemId, problemId),
|
||||
))
|
||||
.innerJoin(
|
||||
schema.problemsetProblem,
|
||||
and(
|
||||
eq(
|
||||
schema.problemsetProblem.problemsetId,
|
||||
schema.problemsetProgress.problemsetId,
|
||||
),
|
||||
eq(schema.problemsetProblem.problemId, problemId),
|
||||
),
|
||||
)
|
||||
.where(eq(schema.problemsetProgress.userId, userId))
|
||||
const earned: BadgeRow[] = []
|
||||
let updated = 0
|
||||
for (const { problemsetId } of joined) {
|
||||
const hits = await db.transaction(async (tx) => {
|
||||
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
|
||||
eq(schema.problemsetProgress.problemsetId, problemsetId),
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
)).for("update").limit(1)
|
||||
const [progress] = await tx
|
||||
.select()
|
||||
.from(schema.problemsetProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProgress.problemsetId, problemsetId),
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.limit(1)
|
||||
if (!progress) return []
|
||||
|
||||
// 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行
|
||||
const [existing] = await tx.select({ id: schema.problemsetSubmission.id })
|
||||
.from(schema.problemsetSubmission).where(and(
|
||||
eq(schema.problemsetSubmission.problemsetId, problemsetId),
|
||||
eq(schema.problemsetSubmission.userId, userId),
|
||||
eq(schema.problemsetSubmission.problemId, problemId),
|
||||
)).limit(1)
|
||||
const [existing] = await tx
|
||||
.select({ id: schema.problemsetSubmission.id })
|
||||
.from(schema.problemsetSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetSubmission.problemsetId, problemsetId),
|
||||
eq(schema.problemsetSubmission.userId, userId),
|
||||
eq(schema.problemsetSubmission.problemId, problemId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!existing) {
|
||||
await tx.insert(schema.problemsetSubmission)
|
||||
await tx
|
||||
.insert(schema.problemsetSubmission)
|
||||
.values({ problemsetId, userId, submissionId, problemId })
|
||||
}
|
||||
|
||||
const detail = objectValue(progress.progressDetail)
|
||||
if (String(problemId) in detail) return []
|
||||
const links = await tx.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem)
|
||||
const links = await tx
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, problemsetId))
|
||||
const link = links.find((item) => item.problemId === problemId)
|
||||
if (!link) return []
|
||||
detail[String(problemId)] = { score: link.score, submit_time: solvedAt }
|
||||
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))
|
||||
updated += 1
|
||||
|
||||
const badges = await tx.select().from(schema.problemsetBadge)
|
||||
const badges = await tx
|
||||
.select()
|
||||
.from(schema.problemsetBadge)
|
||||
.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 []
|
||||
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
|
||||
const inserted = await tx.insert(schema.userBadge).values(eligible.map((badge) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
}))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] })
|
||||
const inserted = await tx
|
||||
.insert(schema.userBadge)
|
||||
.values(
|
||||
eligible.map((badge) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [schema.userBadge.badgeId, schema.userBadge.userId],
|
||||
})
|
||||
.returning({ badgeId: schema.userBadge.badgeId })
|
||||
const ids = new Set(inserted.map((row) => row.badgeId))
|
||||
return eligible.filter((badge) => ids.has(badge.id))
|
||||
|
||||
@@ -3,7 +3,10 @@ import { and, eq } from "drizzle-orm"
|
||||
|
||||
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
|
||||
.select({ profile: schema.userProfile, user: schema.user })
|
||||
.from(schema.userProfile)
|
||||
|
||||
@@ -95,13 +95,20 @@ export async function processTestCaseZip(
|
||||
|
||||
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
|
||||
// 条目名一律不参与路径拼接,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()
|
||||
if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
|
||||
const selected = options.sql
|
||||
? collectSqlScripts(names)
|
||||
: collectPairs(names).flat()
|
||||
if (selected.length === 0)
|
||||
throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
|
||||
if (options.sql && selected.length < 2) {
|
||||
// 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC
|
||||
throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")
|
||||
throw new TestCaseError(
|
||||
"SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果",
|
||||
)
|
||||
}
|
||||
|
||||
let total = 0
|
||||
@@ -109,12 +116,16 @@ export async function processTestCaseZip(
|
||||
for (const name of selected) {
|
||||
const raw = files[name]!
|
||||
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)
|
||||
total += content.length
|
||||
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)
|
||||
}
|
||||
@@ -149,7 +160,9 @@ export async function processTestCaseZip(
|
||||
collectPairs(names).forEach(([input, output], index) => {
|
||||
const outputContent = contents.get(output)!
|
||||
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,
|
||||
output_size: outputContent.length,
|
||||
input_name: input,
|
||||
@@ -179,7 +192,9 @@ export async function packTestCaseZip(testCaseId: string) {
|
||||
throw new TestCaseError("Test case does not exists")
|
||||
}
|
||||
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 bundle: Record<string, Uint8Array> = {}
|
||||
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 scripts: { name: string; content: string }[] = []
|
||||
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
|
||||
}
|
||||
@@ -216,5 +234,7 @@ function randomId() {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join("")
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,11 +81,17 @@ type ThrottleRedis = typeof redis & {
|
||||
): Promise<[number, string]>
|
||||
}
|
||||
|
||||
function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
|
||||
function parseBucketConfig(
|
||||
value: unknown,
|
||||
fallback: BucketConfig,
|
||||
): BucketConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
return fallback
|
||||
const raw = value as Record<string, unknown>
|
||||
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)
|
||||
: fallback[key]
|
||||
return {
|
||||
@@ -106,7 +112,10 @@ function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig
|
||||
* `throttling` 没有后台界面,只能直接改库,改完最多一分钟后生效。
|
||||
*/
|
||||
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> {
|
||||
const cached = bucketCache.get(scope)
|
||||
@@ -117,9 +126,13 @@ export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
|
||||
try {
|
||||
const values = await getOptions(["throttling"])
|
||||
const throttling = values.throttling
|
||||
value = !throttling || typeof throttling !== "object" || Array.isArray(throttling)
|
||||
? fallback
|
||||
: parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
|
||||
value =
|
||||
!throttling || typeof throttling !== "object" || Array.isArray(throttling)
|
||||
? fallback
|
||||
: parseBucketConfig(
|
||||
(throttling as Record<string, unknown>)[scope],
|
||||
fallback,
|
||||
)
|
||||
} catch {
|
||||
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
|
||||
// 全站都按默认参数限流
|
||||
|
||||
@@ -22,14 +22,44 @@ const STOPWORDS = new Set(
|
||||
)
|
||||
|
||||
const CUSTOM_WORDS = [
|
||||
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件",
|
||||
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束",
|
||||
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误",
|
||||
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断",
|
||||
"缺少循环", "死循环", "无限循环", "循环出口", "循环体",
|
||||
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用",
|
||||
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作",
|
||||
"终止条件", "退出条件", "返回值",
|
||||
"循环结构",
|
||||
"条件判断",
|
||||
"判断条件",
|
||||
"结束条件",
|
||||
"循环条件",
|
||||
"异常处理",
|
||||
"边界条件",
|
||||
"输入输出",
|
||||
"输入验证",
|
||||
"开始结束",
|
||||
"结束节点",
|
||||
"开始节点",
|
||||
"判断节点",
|
||||
"流程走向",
|
||||
"逻辑错误",
|
||||
"逻辑缺陷",
|
||||
"逻辑不清",
|
||||
"缺少分支",
|
||||
"缺少步骤",
|
||||
"缺少判断",
|
||||
"缺少循环",
|
||||
"死循环",
|
||||
"无限循环",
|
||||
"循环出口",
|
||||
"循环体",
|
||||
"条件分支",
|
||||
"分支结构",
|
||||
"分支不全",
|
||||
"分支缺失",
|
||||
"符号使用",
|
||||
"符号不规范",
|
||||
"连线混乱",
|
||||
"变量初始化",
|
||||
"赋值操作",
|
||||
"累加操作",
|
||||
"终止条件",
|
||||
"退出条件",
|
||||
"返回值",
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,9 @@ function fromWallClock(wall: Date): Date {
|
||||
}
|
||||
|
||||
/** 北京时间的日历日,形如 `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)
|
||||
}
|
||||
|
||||
@@ -64,7 +66,9 @@ export function localWeekday(day: number): number {
|
||||
|
||||
/** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */
|
||||
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()
|
||||
wall.setUTCDate(1)
|
||||
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))
|
||||
return fromWallClock(wall)
|
||||
}
|
||||
|
||||
Vendored
+3
-2
@@ -54,8 +54,9 @@ export async function withBuiltinDict(): Promise<JiebaInstance> {
|
||||
with: { type: "file" },
|
||||
})
|
||||
).default as unknown as string
|
||||
const dictPath = (await import("@node-rs/jieba/dict.txt", { with: { type: "file" } }))
|
||||
.default as unknown as string
|
||||
const dictPath = (
|
||||
await import("@node-rs/jieba/dict.txt", { with: { type: "file" } })
|
||||
).default as unknown as string
|
||||
|
||||
let addon: { Jieba: { withDict(dict: Buffer): JiebaInstance } }
|
||||
try {
|
||||
|
||||
+50
-12
@@ -114,7 +114,10 @@ function consume(bucket: RateBucket, burst: number, refillPerSecond: number) {
|
||||
|
||||
/** 文本帧:严格档。会查库,走这一档的都按最坏情况算 */
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -213,7 +216,10 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
||||
liveSockets.add(ws)
|
||||
ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() }
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -292,7 +298,10 @@ async function handleMessage(
|
||||
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
|
||||
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" }))
|
||||
return
|
||||
}
|
||||
@@ -336,19 +345,43 @@ async function handleMessage(
|
||||
|
||||
if (!submission) {
|
||||
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)
|
||||
.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)
|
||||
if (!flowchart) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Submission not found" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
const replay = flowchart.status === 2
|
||||
? { type: "flowchart_evaluation_completed" as const, submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined }
|
||||
: flowchart.status === 3
|
||||
? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id }
|
||||
: { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id }
|
||||
const replay =
|
||||
flowchart.status === 2
|
||||
? {
|
||||
type: "flowchart_evaluation_completed" as const,
|
||||
submissionId: flowchart.id,
|
||||
score: flowchart.score ?? undefined,
|
||||
grade: flowchart.grade ?? undefined,
|
||||
}
|
||||
: flowchart.status === 3
|
||||
? {
|
||||
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))
|
||||
return
|
||||
}
|
||||
@@ -407,7 +440,12 @@ export async function bridgeSubmissionEvents(
|
||||
const [activeUser] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.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)
|
||||
if (!activeUser) return
|
||||
server.publish(topic, JSON.stringify(event.data))
|
||||
|
||||
+17
-7
@@ -20,9 +20,10 @@ const flowchartWorker = new Worker<FlowchartJobData>(
|
||||
flowchartQueueName,
|
||||
// attemptsMade 是「此前已经失败过几次」,当前这次还没计进去,
|
||||
// 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts
|
||||
async (job) => evaluateFlowchart(job.data, {
|
||||
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
|
||||
}),
|
||||
async (job) =>
|
||||
evaluateFlowchart(job.data, {
|
||||
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
|
||||
}),
|
||||
{ connection: createBlockingRedis(), concurrency: 2 },
|
||||
)
|
||||
|
||||
@@ -38,15 +39,24 @@ worker.on("failed", async (job, error) => {
|
||||
try {
|
||||
await failAbandonedSubmission(submissionId, error)
|
||||
} 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) => {
|
||||
console.error("Judge worker error", error)
|
||||
})
|
||||
flowchartWorker.on("ready", () => console.log("Flowchart worker ready (concurrency=2)"))
|
||||
flowchartWorker.on("failed", (job, error) => console.error(`Flowchart job ${job?.id ?? "unknown"} failed`, error))
|
||||
flowchartWorker.on("error", (error) => console.error("Flowchart worker error", error))
|
||||
flowchartWorker.on("ready", () =>
|
||||
console.log("Flowchart worker ready (concurrency=2)"),
|
||||
)
|
||||
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() {
|
||||
await worker.close()
|
||||
|
||||
+3
-2
@@ -22,14 +22,15 @@ Vite(Rolldown 内核)、Naive UI、Pinia、Vue Router。
|
||||
bun run dev # 只起前端 dev server(5173),后端得另外起
|
||||
bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
|
||||
bun run build # 生产构建
|
||||
bun run fmt # Prettier
|
||||
```
|
||||
|
||||
⚠️ **验证只认 `bun run type-check`。** `vue-tsc --noEmit -p tsconfig.json` 会**静默
|
||||
通过**——那个 tsconfig 是 `files: []` + references 的壳,真正的配置在
|
||||
`tsconfig.app.json`(0.2 秒跑完就是没在检查的信号);`vite build` 也不做类型检查。
|
||||
|
||||
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier。
|
||||
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier,**脚本在仓库根目录**
|
||||
(`cd ../.. && bun run fmt`,一把把后端、契约、前端全格式化)—— 前端这边原来那个
|
||||
只管 `apps/web` 的 `fmt` 已经删掉,配置也收到了根目录的 `.prettierrc.toml`。
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"build": "vite build",
|
||||
"build:staging": "vite build --mode staging",
|
||||
"build:test": "vite build --mode test",
|
||||
"fmt": "prettier --write src *.ts",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -64,7 +63,6 @@
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.9.6",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.2.2",
|
||||
|
||||
@@ -94,9 +94,7 @@ export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||
}
|
||||
|
||||
export function toggleProblemVisible(problemID: number) {
|
||||
return api.put<{ visible: boolean }>(
|
||||
`admin/problems/${problemID}/visibility`,
|
||||
)
|
||||
return api.put<{ visible: boolean }>(`admin/problems/${problemID}/visibility`)
|
||||
}
|
||||
|
||||
export function generateFlowchartFromPythonCode(python: string) {
|
||||
@@ -135,7 +133,11 @@ export function batchTagProblems(
|
||||
}
|
||||
|
||||
// 用户排名(后台版,无 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", {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
@@ -236,9 +238,7 @@ export function previewSQLTestcase(data: {
|
||||
|
||||
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
|
||||
export function getSQLTestcaseScripts(problemId: number) {
|
||||
return api.get<SqlTestCaseScript[]>(
|
||||
`admin/problems/${problemId}/sql-scripts`,
|
||||
)
|
||||
return api.get<SqlTestCaseScript[]>(`admin/problems/${problemId}/sql-scripts`)
|
||||
}
|
||||
|
||||
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
|
||||
@@ -412,10 +412,7 @@ export function createTutorial(data: Partial<Tutorial>) {
|
||||
}
|
||||
|
||||
export function updateTutorial(data: Partial<Tutorial>) {
|
||||
return api.put<Tutorial>(
|
||||
`admin/tutorials/${data.id}`,
|
||||
toTutorialBody(data),
|
||||
)
|
||||
return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data))
|
||||
}
|
||||
|
||||
export function deleteTutorial(id: number) {
|
||||
|
||||
@@ -168,10 +168,14 @@ const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
|
||||
|
||||
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
|
||||
() => [
|
||||
{ type: "expand", renderExpand: (row) => h(ExerciseAttempts, {
|
||||
exerciseId: row.exerciseId,
|
||||
className: className.value.trim(),
|
||||
}) },
|
||||
{
|
||||
type: "expand",
|
||||
renderExpand: (row) =>
|
||||
h(ExerciseAttempts, {
|
||||
exerciseId: row.exerciseId,
|
||||
className: className.value.trim(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "课",
|
||||
key: "tutorialOrder",
|
||||
@@ -289,7 +293,8 @@ onMounted(load)
|
||||
</n-text>
|
||||
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
「已读」按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
|
||||
「已读」按累计停留满
|
||||
{{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ function nodeTargetOptions(lang: string): SelectOption[] {
|
||||
// 逻辑名 and/or/not 在 C 里显示成 && / || / !,存进去的还是逻辑名
|
||||
function operatorTargetOptions(lang: string): SelectOption[] {
|
||||
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,
|
||||
target: string,
|
||||
): 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
|
||||
// 运算符不写 label:判题结果的文案按语言翻译(astOperatorLabel),
|
||||
// 存一个固定 label 反而会把 C 的 && 钉死成 and
|
||||
return undefined
|
||||
@@ -252,7 +256,8 @@ watch(supportedLanguages, (langs) => {
|
||||
:bordered="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
{{ unsupportedLanguages.join("、") }} 暂不支持代码规则检查,判题机只能检查
|
||||
{{ unsupportedLanguages.join("、") }}
|
||||
暂不支持代码规则检查,判题机只能检查
|
||||
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
|
||||
</n-alert>
|
||||
<n-tabs
|
||||
@@ -393,9 +398,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-empty
|
||||
v-else
|
||||
:description="
|
||||
languages.length
|
||||
? '当前语言不支持代码规则检查'
|
||||
: '请先选择编程语言'
|
||||
languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'
|
||||
"
|
||||
/>
|
||||
</n-collapse-item>
|
||||
|
||||
@@ -59,9 +59,7 @@ async function submit() {
|
||||
props.action,
|
||||
)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(
|
||||
`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`,
|
||||
)
|
||||
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
|
||||
close()
|
||||
emit("done")
|
||||
}
|
||||
|
||||
@@ -179,11 +179,10 @@ async function run() {
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const data = uploadable.value
|
||||
.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
const data = uploadable.value.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
@@ -896,7 +896,11 @@ watch(
|
||||
v-model:value="problem.showFlowchart"
|
||||
: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-flex>
|
||||
|
||||
@@ -122,9 +122,7 @@ async function saveTag(tag: AdminTag) {
|
||||
}
|
||||
const res = await renameTag(tag.id, name)
|
||||
if (res.merged) {
|
||||
message.success(
|
||||
`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`,
|
||||
)
|
||||
message.success(`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
}
|
||||
|
||||
@@ -110,9 +110,7 @@ function startRolling(finalName: string) {
|
||||
|
||||
async function getRandom() {
|
||||
const res = await randomUser10(query.classroom)
|
||||
const names = (res as string[]).map(
|
||||
(name) => name.split(query.classroom)[1],
|
||||
)
|
||||
const names = (res as string[]).map((name) => name.split(query.classroom)[1])
|
||||
rollingNames.value = names
|
||||
const finalName = names[names.length - 1]
|
||||
startRolling(finalName)
|
||||
|
||||
@@ -23,8 +23,7 @@ defineEmits<{
|
||||
*/
|
||||
const maskable = computed(
|
||||
() =>
|
||||
props.user.adminType !== USER_TYPE.REGULAR_USER &&
|
||||
!!props.user.rawPassword,
|
||||
props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -57,7 +57,9 @@ async function uploadUsers() {
|
||||
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")
|
||||
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
|
||||
hiddenElement.target = "_blank"
|
||||
|
||||
@@ -77,7 +77,8 @@ const columns: DataTableColumn<User>[] = [
|
||||
user: row,
|
||||
revealed: revealedPasswords.value.has(row.id),
|
||||
onToggle: (id: number) => {
|
||||
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
|
||||
if (!revealedPasswords.value.delete(id))
|
||||
revealedPasswords.value.add(id)
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -74,7 +74,9 @@ aiStore.targetUsername = urlUsername.value
|
||||
aiStore.duration = urlDuration.value
|
||||
|
||||
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)))
|
||||
|
||||
@@ -31,7 +31,8 @@ const { chartKey } = useChartTheme()
|
||||
// 第二个 tab 里列成表格。这里直接画出来,不用改后端和契约
|
||||
const items = computed(() =>
|
||||
[...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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+15
-10
@@ -38,6 +38,7 @@ import {
|
||||
type FlowchartStatistics,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsItems,
|
||||
type TodaySubmissionStatistics,
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { contract } from "utils/contract"
|
||||
@@ -159,6 +160,11 @@ export function getTodaySubmissionCount(language?: string) {
|
||||
return api.get<number>("submissions/today-count", { params: { language } })
|
||||
}
|
||||
|
||||
/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */
|
||||
export function getTodaySubmissionStatistics() {
|
||||
return api.get<TodaySubmissionStatistics>("submissions/today-statistics")
|
||||
}
|
||||
|
||||
export function adminRejudge(id: string) {
|
||||
return api.post<{ ok: boolean }>(
|
||||
`submissions/${encodeURIComponent(id)}/rejudge`,
|
||||
@@ -249,12 +255,9 @@ export function getContestAccess(id: string) {
|
||||
|
||||
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
|
||||
export function checkContestPassword(contestID: string, password: string) {
|
||||
return api.post<boolean>(
|
||||
`contests/${encodeURIComponent(contestID)}/access`,
|
||||
{
|
||||
password,
|
||||
},
|
||||
)
|
||||
return api.post<boolean>(`contests/${encodeURIComponent(contestID)}/access`, {
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
@@ -289,9 +292,12 @@ export function updateProfile(data: { realName: string; mood: string }) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>(
|
||||
"announcements",
|
||||
{
|
||||
params: { limit, offset },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
@@ -459,7 +465,6 @@ export function joinProblemSet(problemSetId: number) {
|
||||
return api.post("problem-set-progress", { problemSetId })
|
||||
}
|
||||
|
||||
|
||||
export function getUserBadges(username?: string) {
|
||||
return api.get<UserBadge[]>(
|
||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||||
|
||||
@@ -66,7 +66,9 @@ const timeRangeOptions: SelectOption[] = [
|
||||
]
|
||||
|
||||
// 「全部时间」的 value 是空串,解不出来就是 null —— 正是不带时间条件的意思
|
||||
const subOptions = computed<Duration | null>(() => durationFromValue(duration.value))
|
||||
const subOptions = computed<Duration | null>(() =>
|
||||
durationFromValue(duration.value),
|
||||
)
|
||||
|
||||
// 根据时间段选项计算开始和结束时间
|
||||
function getTimeRange(): {
|
||||
|
||||
@@ -55,7 +55,10 @@ function submit() {
|
||||
|
||||
/** 给老师看的一句人话:选项按 A/B/C 报,报下标没人看得懂 */
|
||||
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() {
|
||||
|
||||
@@ -23,10 +23,7 @@ const IDLE_MS = 10 * 60 * 1000
|
||||
* @param tutorialId 当前这一课,0 表示还没加载好
|
||||
* @param enabled 是否留痕。未登录时为 false:教程本身保持免登录可读,只是不记
|
||||
*/
|
||||
export function useLearnTrace(
|
||||
tutorialId: Ref<number>,
|
||||
enabled: Ref<boolean>,
|
||||
) {
|
||||
export function useLearnTrace(tutorialId: Ref<number>, enabled: Ref<boolean>) {
|
||||
const visibility = useDocumentVisibility()
|
||||
const { idle } = useIdle(IDLE_MS)
|
||||
|
||||
|
||||
@@ -126,7 +126,12 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
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 {
|
||||
getTutorial,
|
||||
getTutorials,
|
||||
@@ -220,7 +225,9 @@ async function loadProgress() {
|
||||
}
|
||||
try {
|
||||
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 {
|
||||
progress.value = {}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,9 @@ function type(status: ProblemStatus) {
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -111,8 +111,7 @@ const POLL_INTERVAL = 3000
|
||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||
|
||||
type Outcome =
|
||||
| { ok: true; score: number; grade: string }
|
||||
| { ok: false; error?: string }
|
||||
{ ok: true; score: number; grade: string } | { ok: false; error?: string }
|
||||
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
@@ -509,11 +508,7 @@ onUnmounted(() => {
|
||||
</n-card>
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card
|
||||
v-if="sortedCriteria.length"
|
||||
size="small"
|
||||
title="详细评分"
|
||||
>
|
||||
<n-card v-if="sortedCriteria.length" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
|
||||
@@ -182,11 +182,7 @@ watch(
|
||||
<n-tab-pane name="content" tab="题目描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="canShowFlowchart"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
@@ -234,11 +230,7 @@ watch(
|
||||
<n-tab-pane name="content" tab="题目描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="canShowFlowchart"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
|
||||
@@ -42,11 +42,15 @@ function getProgressPercentage() {
|
||||
|
||||
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
|
||||
const optionalCount = computed(
|
||||
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
|
||||
() =>
|
||||
props.problemSet.problemsCount -
|
||||
(props.problemSet.userProgress?.totalCount ?? 0),
|
||||
)
|
||||
|
||||
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() {
|
||||
|
||||
@@ -286,7 +286,9 @@ const options: SelectOption[] = [...LONG_DURATION_OPTIONS]
|
||||
|
||||
// 认不出来退回 options[1](一个月内),和 duration 的初值一致
|
||||
const subOptions = computed<Duration>(
|
||||
() => durationFromValue(duration.value) ?? durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||
() =>
|
||||
durationFromValue(duration.value) ??
|
||||
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -69,11 +69,7 @@
|
||||
</n-card>
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card
|
||||
v-if="sortedCriteria.length > 0"
|
||||
size="small"
|
||||
title="详细评分"
|
||||
>
|
||||
<n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
@@ -148,7 +144,9 @@ const criteriaDetails = computed<
|
||||
)
|
||||
})
|
||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
||||
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
|
||||
const sortedCriteria = computed(() =>
|
||||
sortFlowchartCriteria(criteriaDetails.value),
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const rendering = ref(false)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getTodaySubmissionStatistics } from "oj/api"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { zonedParts } from "utils/functions"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import type { TodaySubmissionStatistics } from "@oj2/contract"
|
||||
|
||||
const emit = defineEmits<{ openProblem: [problem: string] }>()
|
||||
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const stats = ref<TodaySubmissionStatistics | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
// 柱子高度用的像素上限。CSS 里 .hour-bars 的高度跟着它
|
||||
const BAR_MAX_HEIGHT = 72
|
||||
|
||||
// 「现在是几点」按东八区取,不跟浏览器时区走 —— 高亮错一格比不高亮更糟
|
||||
const currentHour = zonedParts(new Date())!.hour
|
||||
|
||||
const maxHour = computed(() => Math.max(1, ...(stats.value?.hours ?? [])))
|
||||
const maxLanguage = computed(() =>
|
||||
Math.max(1, ...(stats.value?.languages ?? []).map((row) => row.count)),
|
||||
)
|
||||
|
||||
/** 0 条的钟点不画柱子,横轴那条基线本身就代表「这个钟点没人交」 */
|
||||
function barHeight(value: number) {
|
||||
if (value <= 0) return "0px"
|
||||
return `${Math.max(4, Math.round((value / maxHour.value) * BAR_MAX_HEIGHT))}px`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
stats.value = await getTodaySubmissionStatistics()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-spin :show="loading">
|
||||
<n-empty
|
||||
v-if="stats && stats.total === 0"
|
||||
description="今天还没有人提交"
|
||||
style="margin: 40px 0"
|
||||
/>
|
||||
<template v-else-if="stats">
|
||||
<n-flex justify="space-around">
|
||||
<div class="stat-item">
|
||||
<n-text>总提交</n-text>
|
||||
<n-gradient-text type="info" font-size="28">
|
||||
{{ stats.total }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确提交</n-text>
|
||||
<n-gradient-text type="primary" font-size="28">
|
||||
{{ stats.accepted }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item" v-if="stats.judging > 0">
|
||||
<n-text>判题中</n-text>
|
||||
<n-gradient-text type="info" font-size="28">
|
||||
{{ stats.judging }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确率</n-text>
|
||||
<n-gradient-text type="warning" font-size="28">
|
||||
{{ stats.correctRate }}%
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>参与人数</n-text>
|
||||
<n-gradient-text type="error" font-size="28">
|
||||
{{ stats.userCount }}
|
||||
</n-gradient-text>
|
||||
</div>
|
||||
</n-flex>
|
||||
|
||||
<n-divider style="margin: 16px 0">按小时</n-divider>
|
||||
<div class="hours">
|
||||
<div class="hour" v-for="(value, hour) in stats.hours" :key="hour">
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<!--
|
||||
基线是每一格自己的下边框拼出来的(整条横轴一根 border 也行,但那样
|
||||
「现在」这一格就没法单独加粗)。**现在这一格加粗成主色**,
|
||||
原来是给整格垫一层底色,结果那块浅灰看着就像一根柱子。
|
||||
-->
|
||||
<div
|
||||
class="hour-bars"
|
||||
:style="{
|
||||
borderBottomColor:
|
||||
hour === currentHour
|
||||
? themeVars.primaryColor
|
||||
: themeVars.dividerColor,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="bar"
|
||||
:style="{
|
||||
height: barHeight(value),
|
||||
backgroundColor: themeVars.primaryColor,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
{{ hour }}:00 - {{ hour }}:59 共 {{ value }} 条
|
||||
</n-tooltip>
|
||||
<!-- 每 3 小时标一个刻度。标签占位始终留着,柱子才对得齐 -->
|
||||
<div class="hour-label">{{ hour % 3 === 0 ? hour : "" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-divider style="margin: 16px 0">按语言</n-divider>
|
||||
<div class="rows">
|
||||
<div class="row" v-for="row in stats.languages" :key="row.language">
|
||||
<n-text class="row-name">{{
|
||||
LANGUAGE_SHOW_VALUE[row.language]
|
||||
}}</n-text>
|
||||
<n-progress
|
||||
class="row-bar"
|
||||
type="line"
|
||||
:percentage="(row.count / maxLanguage) * 100"
|
||||
:show-indicator="false"
|
||||
:height="10"
|
||||
/>
|
||||
<n-text class="row-count">{{ row.count }}</n-text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-divider style="margin: 16px 0">按状态</n-divider>
|
||||
<n-flex align="center">
|
||||
<n-flex
|
||||
align="center"
|
||||
:size="4"
|
||||
v-for="row in stats.results"
|
||||
:key="row.result"
|
||||
>
|
||||
<SubmissionResultTag :result="row.result" />
|
||||
<n-text>{{ row.count }}</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
<template v-if="stats.problems.length">
|
||||
<n-divider style="margin: 16px 0">今天最热的题</n-divider>
|
||||
<div class="rows">
|
||||
<div class="row" v-for="row in stats.problems" :key="row.problem">
|
||||
<n-button
|
||||
class="problem"
|
||||
text
|
||||
type="info"
|
||||
@click="emit('openProblem', row.problem)"
|
||||
>
|
||||
{{ row.problem }} {{ row.problemTitle }}
|
||||
</n-button>
|
||||
<n-text class="row-count" depth="3">
|
||||
{{ row.count }} 条 / 正确 {{ row.acceptedCount }}
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 请求失败时 stats 还是 null,转圈停下来总得留句话 -->
|
||||
<n-empty
|
||||
v-else-if="!loading"
|
||||
description="统计拉取失败"
|
||||
style="margin: 40px 0"
|
||||
/>
|
||||
<div v-else style="height: 200px"></div>
|
||||
</n-spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hours {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.hour {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hour-bars {
|
||||
height: 72px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
cursor: default;
|
||||
/* 每格一段,拼成整条横轴。「现在」那一格换主色,粗细不动 —— 变粗会让那一格的
|
||||
柱子底比别人高 1px */
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
border-radius: 2px 2px 0 0;
|
||||
transition: height 0.2s;
|
||||
}
|
||||
|
||||
.hour-label {
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-bar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-count {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.problem {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -43,7 +43,9 @@ const loading = ref(false)
|
||||
* 测试点明细。`info` 在契约里是「完整形状或空对象」的联合(非管理员拿到的是空对象),
|
||||
* `data` 本身也可能为 null —— 两种情况都由这个访问器归成空数组,模板里不再直接取。
|
||||
*/
|
||||
const caseResults = computed(() => submissionCaseResults(submission.value?.info))
|
||||
const caseResults = computed(() =>
|
||||
submissionCaseResults(submission.value?.info),
|
||||
)
|
||||
|
||||
async function init() {
|
||||
submission.value = props.submission
|
||||
|
||||
@@ -49,6 +49,9 @@ const StatisticsPanel = defineAsyncComponent(
|
||||
const FlowchartStatisticsPanel = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
||||
)
|
||||
const TodayStatistics = defineAsyncComponent(
|
||||
() => import("./components/TodayStatistics.vue"),
|
||||
)
|
||||
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||
const FlowchartScoreDetail = defineAsyncComponent(
|
||||
() => import("./components/FlowchartScoreDetail.vue"),
|
||||
@@ -114,6 +117,8 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||
const submissionID = ref("")
|
||||
const problemDisplayID = ref("")
|
||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||
// 「今日提交数」旁边那颗「统计」按钮的弹框
|
||||
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||||
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false)
|
||||
@@ -241,6 +246,12 @@ function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// 今日统计弹框里点题目。那颗按钮只在 route.name === "submissions" 上出现
|
||||
// (今日提交数本身就只在那一页拉),所以不用管比赛里的题目路由
|
||||
function openProblem(displayId: string) {
|
||||
window.open("/problem/" + displayId, "_blank")
|
||||
}
|
||||
|
||||
function showCodePanel(id: string, problem: string) {
|
||||
toggleCodePanel(true)
|
||||
submissionID.value = id
|
||||
@@ -581,19 +592,33 @@ const flowchartColumns = computed(() => {
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-tag
|
||||
v-if="todayCount > 0"
|
||||
checkable
|
||||
:checked="query.today === '1'"
|
||||
type="success"
|
||||
size="large"
|
||||
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
||||
>
|
||||
<n-gradient-text v-if="query.today !== '1'" type="success">
|
||||
今日提交数:{{ todayCount }}
|
||||
</n-gradient-text>
|
||||
<template v-else>今日提交数:{{ todayCount }}</template>
|
||||
</n-tag>
|
||||
<n-flex v-if="todayCount > 0" align="center" :size="8">
|
||||
<n-tag
|
||||
checkable
|
||||
:checked="query.today === '1'"
|
||||
type="success"
|
||||
size="large"
|
||||
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
||||
>
|
||||
<n-gradient-text v-if="query.today !== '1'" type="success">
|
||||
今日提交数:{{ todayCount }}
|
||||
</n-gradient-text>
|
||||
<template v-else>今日提交数:{{ todayCount }}</template>
|
||||
</n-tag>
|
||||
<!--
|
||||
筛到今天之后才出现。流程图那档不给 —— 这个统计只算代码提交
|
||||
(流程图提交在另一张表、只有 AI 评级没有判题状态),点开会是一份
|
||||
对不上标签数字的统计。
|
||||
-->
|
||||
<n-button
|
||||
v-if="query.today === '1' && query.language !== 'Flowchart'"
|
||||
quaternary
|
||||
type="success"
|
||||
@click="toggleTodayPanel(true)"
|
||||
>
|
||||
统计
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
v-if="query.language === 'Flowchart'"
|
||||
@@ -636,6 +661,15 @@ const flowchartColumns = computed(() => {
|
||||
:username="query.username"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="todayPanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '700px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="今日提交统计"
|
||||
>
|
||||
<TodayStatistics @open-problem="(id: string) => openProblem(id)" />
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
preset="card"
|
||||
|
||||
@@ -80,14 +80,8 @@ async function init() {
|
||||
const metricsRes = await getMetrics(res.user.id)
|
||||
firstSubmissionAt.value = parseTime(metricsRes.first)
|
||||
latestSubmissionAt.value = parseTime(metricsRes.latest)
|
||||
toLatestAt.value = durationToDays(
|
||||
metricsRes.latest,
|
||||
metricsRes.now,
|
||||
)
|
||||
learnDuration.value = durationToDays(
|
||||
metricsRes.first,
|
||||
metricsRes.latest,
|
||||
)
|
||||
toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
|
||||
learnDuration.value = durationToDays(metricsRes.first, metricsRes.latest)
|
||||
}
|
||||
} finally {
|
||||
toggle(false)
|
||||
|
||||
@@ -41,7 +41,5 @@ export function getHitokoto() {
|
||||
}
|
||||
|
||||
export function getClassUsernames(classroom: string) {
|
||||
return api.get<string[]>(
|
||||
`classes/${encodeURIComponent(classroom)}/usernames`,
|
||||
)
|
||||
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import type { FlowchartStatistics } from "@oj2/contract"
|
||||
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 { useHiddenStudents } from "../composables/hiddenStudents"
|
||||
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
||||
@@ -471,7 +474,8 @@ function renderWordCloud() {
|
||||
|
||||
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() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user