update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
1 parent 23844ae301
commit d25b5dbe6d
268 files changed
+3684 -8861

No files matched your search

+47 -103
View File
@@ -47,12 +47,7 @@ import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options"
import { sniffImageExtension } from "../services/image"
import { getUserProfileById } from "../services/profile"
import {
clientIp,
countAttempt,
lockoutRemaining,
type AttemptRule,
} from "../services/throttling"
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
import { localTime, weekStart } from "../time"
import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers"
@@ -65,19 +60,10 @@ export const accountRoutes = new Hono<AppEnv>()
const REGISTER_PER_IP: AttemptRule = { limit: 100, windowSeconds: 60 * 60 }
accountRoutes.post("/users", async (c) => {
const parsed = await parseBody(
c,
registerRequestSchema,
"Invalid registration payload",
)
const parsed = await parseBody(c, registerRequestSchema, "Invalid registration payload")
if (!parsed.success) return parsed.response
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 registerKey = `register:ip:${clientIp(c)}`
@@ -159,47 +145,31 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
)
.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 = await parseBody(
c,
updateProfileRequestSchema,
"Invalid profile payload",
)
const parsed = await parseBody(c, updateProfileRequestSchema, "Invalid profile payload")
if (!parsed.success) return parsed.response
const values = Object.fromEntries(
Object.entries(parsed.data).map(([key, value]) => [
key,
value === "" ? null : value,
]),
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 = await sniffImageExtension(image)
if (!extension) {
return failure(c, 400, "unsupported-file", "Unsupported file format")
@@ -223,14 +193,11 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
.select({
first: min(schema.submission.createTime),
latest: max(schema.submission.createTime),
activeDays: countDistinct(
sql`date(${localTime(schema.submission.createTime)})`,
),
activeDays: countDistinct(sql`date(${localTime(schema.submission.createTime)})`),
})
.from(schema.submission)
.where(eq(schema.submission.userId, userId))
if (!row?.first || !row.latest)
return failure(c, 404, "no-submissions", "暂无提交")
if (!row?.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
return success(c, {
now: new Date().toISOString(),
first: row.first,
@@ -397,10 +364,7 @@ accountRoutes.get("/rankings/activity", async (c) => {
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, start),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
eq(schema.user.isDisabled, false),
ne(schema.user.adminType, "Super Admin"),
),
@@ -447,8 +411,7 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
const user = c.get("user")
const scope = c.req.query("scope") === "class" ? "class" : "global"
const className = scope === "class" ? (user?.className ?? null) : null
if (scope === "class" && !className)
return failure(c, 400, "class-missing", "用户没有班级信息")
if (scope === "class" && !className) return failure(c, 400, "class-missing", "用户没有班级信息")
const start = weekStart()
@@ -503,9 +466,7 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
.groupBy(schema.submission.userId),
])
const submissions = new Map(
submittedRows.map((row) => [row.userId, row.value]),
)
const submissions = new Map(submittedRows.map((row) => [row.userId, row.value]))
/**
* 排序键与全服榜同构:解决多的在前 → 同解决数时提交少的在前 → 再同按 id。
* 第三档同样不是凑数,周榜上「都是 1 题」的学生成片存在,没有稳定兜底键时
@@ -551,8 +512,7 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
),
)
.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")
const accepted = and(
eq(schema.submission.problemId, problem.id),
inArray(schema.submission.result, [0, 10]),
@@ -570,12 +530,7 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
),
),
.where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false))),
),
)
: accepted
@@ -617,44 +572,33 @@ 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 = asRecord(profile?.value)
const problems = asRecord(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 = asRecord(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))
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 = asRecord(profile?.value)
const problems = asRecord(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 = asRecord(value)
const displayId = displayIds.get(id)
if (displayId) item._id = displayId
problems[id] = item
}
return success(c, null)
},
)
status.problems = problems
await db
.update(schema.userProfile)
.set({ acmProblemsStatus: status })
.where(eq(schema.userProfile.userId, user.id))
}
return success(c, null)
})
+9 -37
View File
@@ -27,19 +27,12 @@ async function resolveUser(requested: string | undefined, currentId: number) {
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),
),
)
.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,
@@ -58,19 +51,13 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
.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().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({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)),
])
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
const metrics = asRecord(statRows[0]?.metrics)
@@ -93,10 +80,7 @@ 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, {
@@ -124,10 +108,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, target.id),
eq(schema.achievement.visible, true),
),
and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true)),
)
.orderBy(desc(schema.userAchievement.unlockTime)),
])
@@ -149,9 +130,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
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)
@@ -161,10 +140,7 @@ 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),
)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(
and(
eq(schema.userAchievement.userId, c.get("user")!.id),
@@ -177,11 +153,7 @@ achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
})
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
const parsed = await parseBody(
c,
markAchievementsReadSchema,
"Invalid achievement ids",
)
const parsed = await parseBody(c, markAchievementsReadSchema, "Invalid achievement ids")
if (!parsed.success) return parsed.response
if (parsed.data.ids.length > 0) {
await db
+48 -129
View File
@@ -14,18 +14,7 @@ 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"
@@ -55,10 +44,7 @@ function classNameOf(
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} 位数字`,
@@ -132,8 +118,7 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
eq(schema.user.isDisabled, false),
// 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含
keyword
? (classPrefixCondition(keyword) ??
ilike(schema.user.username, `%${keyword}%`))
? (classPrefixCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`))
: undefined,
)
@@ -185,8 +170,7 @@ 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) {
@@ -211,9 +195,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
orderBy === "-online"
? [
...(online.size
? [
sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`,
]
? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`]
: []),
sql`${schema.user.lastLogin} desc nulls last`,
]
@@ -225,18 +207,12 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
db
.select({ value: count() })
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.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),
)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(...order, asc(schema.user.id))
.limit(limit)
@@ -265,23 +241,19 @@ 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")
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)),
)
.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")
@@ -291,10 +263,7 @@ 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 一致:哈希与明文一起写。明文是有意保留的运营需求,
@@ -317,10 +286,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
*/
if (existing.user.username !== username) {
await tx
.update(schema.submission)
.set({ username })
.where(eq(schema.submission.userId, id))
await tx.update(schema.submission).set({ username }).where(eq(schema.submission.userId, id))
}
await tx
.update(schema.userProfile)
@@ -367,8 +333,7 @@ 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 两条路都查重),唯独导入这条以前
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
@@ -394,27 +359,15 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
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
@@ -437,24 +390,14 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
.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
@@ -464,15 +407,12 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
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)
}
},
),
Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => {
while (cursor < prepared.length) {
const item = prepared[cursor++]!
item.password = await hashPassword(item.raw)
}
}),
)
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
@@ -516,11 +456,7 @@ 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
@@ -534,12 +470,7 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
if (!parsed.success) return parsed.response
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 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
@@ -579,11 +510,7 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
} catch (error) {
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
if (
!(error instanceof UserHasSubmissionsError) &&
!isForeignKeyViolation(error)
)
throw error
if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error
return failure(
c,
409,
@@ -593,32 +520,24 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
}
})
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)
})
+54 -78
View File
@@ -10,11 +10,7 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, parseBody, 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"
@@ -52,30 +48,20 @@ adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
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 = await parseBody(c, createAchievementRequestSchema)
if (!parsed.success) return parsed.response
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)
@@ -97,61 +83,51 @@ adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
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 = await parseBody(c, updateAchievementRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, updateAchievementRequestSchema)
if (!parsed.success) return parsed.response
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)
})
+5 -16
View File
@@ -47,9 +47,7 @@ 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 返回两种形状,调用方没法照着一个类型写。
@@ -103,9 +101,7 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
})
.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 })),
)
.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 一律不下发:里面是喂给模型的原始学情数据与提示词
@@ -123,8 +119,7 @@ adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
.from(schema.aiAnalysis)
.where(eq(schema.aiAnalysis.id, id))
.limit(1)
if (!report)
return failure(c, 404, "report-not-found", "AIAnalysis not found")
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份
const next = !report.isPinned
@@ -134,16 +129,10 @@ adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
.update(schema.aiAnalysis)
.set({ isPinned: false })
.where(
and(
eq(schema.aiAnalysis.userId, report.userId),
eq(schema.aiAnalysis.isPinned, true),
),
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 -75
View File
@@ -58,14 +58,8 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
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),
@@ -94,72 +88,37 @@ adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
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 = await parseBody(c, updateAnnouncementRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, updateAnnouncementRequestSchema)
if (!parsed.success) return parsed.response
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)
})
+23 -64
View File
@@ -14,11 +14,7 @@ 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"
@@ -44,9 +40,7 @@ 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
}
// ---------------------------------------------------------------- 网站配置
@@ -80,9 +74,9 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, updateWebsiteConfigRequestSchema)
if (!parsed.success) return parsed.response
const entries = (
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
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)
@@ -97,8 +91,7 @@ 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)
})
@@ -123,50 +116,30 @@ adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
})
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
const parsed = await parseBody(
c,
updateJudgeServerRequestSchema,
"isDisabled is required",
)
const parsed = await parseBody(c, updateJudgeServerRequestSchema, "isDisabled is required")
if (!parsed.success) return parsed.response
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",
)
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)
})
// ---------------------------------------------------------------- 孤儿测试用例
@@ -179,18 +152,14 @@ 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,
)
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
return {
id,
createTime: info ? info.mtimeMs / 1000 : 0,
@@ -207,12 +176,7 @@ 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), {
@@ -260,10 +224,7 @@ adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(
classPrefixCondition(classroom) ??
ilike(schema.user.username, `${classroom}%`),
)
.where(classPrefixCondition(classroom) ?? ilike(schema.user.username, `${classroom}%`))
.orderBy(sql`random()`)
.limit(10)
return success(
@@ -298,9 +259,7 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
}
// 以文件头为准,扩展名只是第一道快速筛
const claimed = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
const suffix = IMAGE_SUFFIXES.includes(claimed)
? await sniffImageExtension(image)
: null
const suffix = IMAGE_SUFFIXES.includes(claimed) ? await sniffImageExtension(image) : null
if (!suffix) {
return success(c, {
success: false,
+99 -144
View File
@@ -66,8 +66,7 @@ function selectContest(id: number) {
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
}
@@ -78,8 +77,7 @@ 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
@@ -94,10 +92,7 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
})
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.contest.createTime))
.limit(limit)
@@ -110,9 +105,7 @@ 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")
}
@@ -192,12 +185,9 @@ 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)
@@ -227,10 +217,7 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
})
.returning({ id: schema.contest.id })
const problems = await tx
.select()
.from(schema.problem)
.where(eq(schema.problem.contestId, id))
const problems = await tx.select().from(schema.problem).where(eq(schema.problem.contestId, id))
if (problems.length === 0) return contest!.id
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
@@ -251,9 +238,7 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
})),
)
.returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(
copies.map((copy) => [copy.displayId, copy.id]),
)
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx
@@ -269,16 +254,10 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
),
)
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)
}
@@ -291,119 +270,95 @@ 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")
}
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,
})
}
}
// 按 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 = await parseBody(c, updateAcmHelperRequestSchema)
if (!parsed.success) return parsed.response
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()
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)
.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")
.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")
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,
})
}
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)
},
)
}
// 按 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 = await parseBody(c, updateAcmHelperRequestSchema)
if (!parsed.success) return parsed.response
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)
})
+146 -216
View File
@@ -74,9 +74,7 @@ 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)),
)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
const tutorialIds = tutorials.map((row) => row.id)
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
@@ -104,18 +102,11 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(
Number,
),
lastViewedAt: sql<
string | null
>`max(${schema.tutorialProgress.lastViewedAt})`,
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.userProfile, eq(schema.userProfile.userId, schema.user.id))
.leftJoin(schema.tutorialProgress, progressJoin)
.where(studentCondition(className.value))
.groupBy(
@@ -132,27 +123,15 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
.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,
),
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)),
)
.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]))
@@ -179,76 +158,64 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
} 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 回 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,
),
)
// 学生条件写在 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))
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)
})
/**
* 按练习:哪道练一练卡住了全班。
@@ -256,131 +223,94 @@ adminLearnRoutes.get(
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
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(
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,
),
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),
firstTryUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(
Number,
),
)
.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))
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[])
})
+230 -389
View File
@@ -15,18 +15,7 @@ import {
type SqlTestCaseScript,
type UploadTestCaseResponse,
} from "@oj2/contract"
import {
and,
count,
desc,
eq,
ilike,
inArray,
isNull,
ne,
or,
sql,
} from "drizzle-orm"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
@@ -90,10 +79,7 @@ async function tagNamesFor(problemIds: number[]) {
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
for (const row of rows)
result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
@@ -149,16 +135,12 @@ async function resolveTags(tx: DbOrTx, names: string[]) {
.returning({ id: schema.problemTag.id, name: schema.problemTag.name })
for (const row of created) existing.set(row.name.toLowerCase(), row.id)
}
return wanted
.map((name) => existing.get(name.toLowerCase())!)
.filter((id) => id !== undefined)
return wanted.map((name) => existing.get(name.toLowerCase())!).filter((id) => id !== undefined)
}
async function setTags(tx: DbOrTx, problemId: number, names: string[]) {
const ids = await resolveTags(tx, names)
await tx
.delete(schema.problemTags)
.where(eq(schema.problemTags.problemId, problemId))
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemId, problemId))
if (ids.length) {
await tx
.insert(schema.problemTags)
@@ -175,10 +157,7 @@ async function serialize(row: ProblemRow) {
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, row.createdById))
.limit(1),
tagNames(row.id),
@@ -207,10 +186,7 @@ async function serialize(row: ProblemRow) {
acceptedNumber: row.acceptedNumber,
statisticInfo: asRecord(row.statisticInfo),
contestId: row.contestId,
createdBy: sampleUser(
creator ?? { id: row.createdById, username: "" },
creator?.realName,
),
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
isPublic: row.isPublic,
tags,
allowFlowchart: row.allowFlowchart,
@@ -242,13 +218,9 @@ function commonChecks(data: {
return { error: "SQL problem cannot be mixed with other languages" }
if (!data.sqlConfig) return { error: "SQL problem requires sql_config" }
const hasAnswer = data.answers.some(
(item) =>
item.language === "SQL" &&
typeof item.code === "string" &&
item.code.trim(),
(item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim(),
)
if (!hasAnswer)
return { error: "SQL problem requires a SQL reference answer" }
if (!hasAnswer) return { error: "SQL problem requires a SQL reference answer" }
return { sql: true }
}
if (!data.inputDescription || !data.outputDescription) {
@@ -270,28 +242,19 @@ async function generateSqlDisplay(
): Promise<{ error: string } | { display: SqlDisplay }> {
const info = await readInfo(testCaseId)
if (!info) return { error: "测试点信息读取失败,请重新上传测试点" }
if (!info.sql)
return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" }
const keys = Object.keys(info.test_cases ?? {}).sort(
(a, b) => Number(a) - Number(b),
)
if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" }
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
if (keys.length === 0) return { error: "题目没有任何测试点" }
const inputName = info.test_cases![keys[0]!]?.input_name
if (!inputName) return { error: "测试点信息损坏,请重新上传测试点" }
let initSql: string
try {
initSql = await readFile(
resolve(config.testCaseDirectory, testCaseId, inputName),
"utf8",
)
initSql = await readFile(resolve(config.testCaseDirectory, testCaseId, inputName), "utf8")
} catch {
return { error: `测试点脚本 ${inputName} 读取失败` }
}
const refSql = answers.find(
(item) =>
item.language === "SQL" &&
typeof item.code === "string" &&
item.code.trim(),
(item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim(),
)?.code
if (typeof refSql !== "string") return { error: "题目缺少 SQL 标准答案" }
const outcome = await buildSqlDisplay(initSql, refSql, sqlConfig.mode)
@@ -299,10 +262,7 @@ async function generateSqlDisplay(
return { display: outcome.value }
}
function problemValues(
data: ReturnType<typeof createProblemRequestSchema.parse>,
isSql: boolean,
) {
function problemValues(data: ReturnType<typeof createProblemRequestSchema.parse>, isSql: boolean) {
return {
displayId: data._id,
title: data.title,
@@ -342,17 +302,11 @@ type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
*/
async function validateProblem(
data: ProblemInput,
): Promise<
{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }
> {
): Promise<{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }> {
const checked = commonChecks(data)
if ("error" in checked) return checked
if (!checked.sql) return { sql: false, sqlDisplay: null }
const built = await generateSqlDisplay(
data.testCaseId,
data.answers,
data.sqlConfig!,
)
const built = await generateSqlDisplay(data.testCaseId, data.answers, data.sqlConfig!)
if ("error" in built) return built
return { sql: true, sqlDisplay: built.display }
}
@@ -418,9 +372,7 @@ async function copyProblem(
if (tags.length) {
await tx
.insert(schema.problemTags)
.values(
tags.map((tag) => ({ problemId: copy!.id, problemtagId: tag.tagId })),
)
.values(tags.map((tag) => ({ problemId: copy!.id, problemtagId: tag.tagId })))
}
return copy!
}
@@ -471,10 +423,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.problem.createTime))
.limit(limit)
@@ -512,12 +461,9 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
const [row] = await db
.select()
.from(schema.problem)
.where(
eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!row)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, row))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
@@ -528,21 +474,14 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, createProblemRequestSchema)
if (!parsed.success) return parsed.response
const validated = await validateProblem(parsed.data)
if ("error" in validated)
return failure(c, 400, "invalid-problem", validated.error)
if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error)
const [duplicate] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
eq(schema.problem.displayId, parsed.data._id),
isNull(schema.problem.contestId),
),
)
.where(and(eq(schema.problem.displayId, parsed.data._id), isNull(schema.problem.contestId)))
.limit(1)
if (duplicate)
return failure(c, 409, "display-id-exists", "Display ID already exists")
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
const created = await insertProblem(parsed.data, validated, {
contestId: null,
@@ -560,14 +499,12 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
if (!existing)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const validated = await validateProblem(parsed.data)
if ("error" in validated)
return failure(c, 400, "invalid-problem", validated.error)
if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error)
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
const [duplicate] = await db
@@ -583,8 +520,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
),
)
.limit(1)
if (duplicate)
return failure(c, 409, "display-id-exists", "Display ID already exists")
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
const updated = await db.transaction(async (tx) => {
const [row] = await tx
@@ -605,24 +541,19 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
// 公开题与比赛题共用一条删除路由。旧接口分成两个(admin/problem 与
// admin/contest/problem),但两边都只按题目 id 取、比赛是从题目推导出来的,
// 分开没有意义,还逼前端多传一个它未必知道的 contestId。
adminProblemRoutes.delete(
"/problems/:id",
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
if (!existing)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
return deleteProblem(c, id)
},
)
adminProblemRoutes.delete("/problems/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
return deleteProblem(c, id)
})
/**
* 删题的共用实现。
@@ -643,12 +574,7 @@ async function deleteProblem(c: Parameters<typeof success>[0], id: number) {
.from(schema.submission)
.where(eq(schema.submission.problemId, id))
if ((submissions?.value ?? 0) > 0) {
return failure(
c,
409,
"problem-has-submissions",
"该题目已有提交记录,不能删除",
)
return failure(c, 409, "problem-has-submissions", "该题目已有提交记录,不能删除")
}
await db.delete(schema.problem).where(eq(schema.problem.id, id))
return success(c, null)
@@ -656,175 +582,133 @@ async function deleteProblem(c: Parameters<typeof success>[0], id: number) {
// ---------------------------------------------------------------- 比赛题目
adminProblemRoutes.get(
"/contests/:contestId/problems",
requireProblemPermission,
async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (
!contest ||
(user.adminType !== "Super Admin" && contest.createdById !== user.id)
) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
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.problem.contestId, contestId)]
const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.problem.title, `%${keyword}%`))
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problem).where(where),
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(desc(schema.problem.createTime))
.limit(limit)
.offset(offset),
])
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
return success(c, {
results: rows.map(
({ problem, user: creator, realName }) =>
({
id: problem.id,
_id: problem.displayId,
title: problem.title,
createdBy: sampleUser(creator, realName),
visible: problem.visible,
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [],
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
}) satisfies AdminProblemListItem,
),
total: totalRow[0]?.value ?? 0,
} satisfies AdminProblemList)
},
)
adminProblemRoutes.post(
"/contests/:contestId/problems",
requireProblemPermission,
async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (
!contest ||
(user.adminType !== "Super Admin" && contest.createdById !== user.id)
) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const parsed = await parseBody(c, createProblemRequestSchema)
if (!parsed.success) return parsed.response
const validated = await validateProblem(parsed.data)
if ("error" in validated)
return failure(c, 400, "invalid-problem", validated.error)
const [duplicate] = await db
.select({ id: schema.problem.id })
adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission, async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
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.problem.contestId, contestId)]
const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.problem.title, `%${keyword}%`))
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problem).where(where),
db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem)
.where(
and(
eq(schema.problem.displayId, parsed.data._id),
eq(schema.problem.contestId, contestId),
),
)
.limit(1)
if (duplicate)
return failure(c, 409, "display-id-exists", "Duplicate Display id")
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.problem.createTime))
.limit(limit)
.offset(offset),
])
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
return success(c, {
results: rows.map(
({ problem, user: creator, realName }) =>
({
id: problem.id,
_id: problem.displayId,
title: problem.title,
createdBy: sampleUser(creator, realName),
visible: problem.visible,
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [],
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
}) satisfies AdminProblemListItem,
),
total: totalRow[0]?.value ?? 0,
} satisfies AdminProblemList)
})
const created = await insertProblem(parsed.data, validated, {
contestId,
createdById: user.id,
})
return success(c, await serialize(created), 201)
},
)
adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => {
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const user = c.get("user")!
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const parsed = await parseBody(c, createProblemRequestSchema)
if (!parsed.success) return parsed.response
const validated = await validateProblem(parsed.data)
if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error)
const [duplicate] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(eq(schema.problem.displayId, parsed.data._id), eq(schema.problem.contestId, contestId)),
)
.limit(1)
if (duplicate) return failure(c, 409, "display-id-exists", "Duplicate Display id")
const created = await insertProblem(parsed.data, validated, {
contestId,
createdById: user.id,
})
return success(c, await serialize(created), 201)
})
// ---------------------------------------------------------------- 比赛题 ⇄ 公开题
adminProblemRoutes.post(
"/problems/:id/make-public",
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await parseBody(
c,
makeProblemPublicRequestSchema,
"displayId 不能为空",
adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await parseBody(c, makeProblemPublicRequestSchema, "displayId 不能为空")
if (!parsed.success) return parsed.response
const [problem] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传,
// 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。
// 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (!problem.contestId || problem.isPublic) {
return failure(c, 409, "already-public", "Already a public problem")
}
const [duplicate] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(eq(schema.problem.displayId, parsed.data.displayId), isNull(schema.problem.contestId)),
)
if (!parsed.success) return parsed.response
const [problem] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
// 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传,
// 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。
// 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (!problem.contestId || problem.isPublic) {
return failure(c, 409, "already-public", "Already a public problem")
}
const [duplicate] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
eq(schema.problem.displayId, parsed.data.displayId),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (duplicate)
return failure(c, 409, "display-id-exists", "Duplicate display ID")
.limit(1)
if (duplicate) return failure(c, 409, "display-id-exists", "Duplicate display ID")
const created = await db.transaction(async (tx) => {
// 原比赛题标记成「已转公开」,避免同一道题被转两次
await tx
.update(schema.problem)
.set({ isPublic: true })
.where(eq(schema.problem.id, id))
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
return copyProblem(tx, problem, {
contestId: null,
displayId: parsed.data.displayId,
visible: false,
isPublic: true,
})
const created = await db.transaction(async (tx) => {
// 原比赛题标记成「已转公开」,避免同一道题被转两次
await tx.update(schema.problem).set({ isPublic: true }).where(eq(schema.problem.id, id))
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
return copyProblem(tx, problem, {
contestId: null,
displayId: parsed.data.displayId,
visible: false,
isPublic: true,
})
return success(c, await serialize(created), 201)
},
)
})
return success(c, await serialize(created), 201)
})
adminProblemRoutes.post(
"/contests/:contestId/problems/from-public",
@@ -848,12 +732,9 @@ adminProblemRoutes.post(
// problemId 就能靠错误码差异枚举出哪些 contestId 真实存在。全仓其余跨租户路径都是
// 统一码(contest 系列一律 contest-not-found),这里对齐。
const denyContest =
!contest ||
(user.adminType !== "Super Admin" && contest.createdById !== user.id)
if (denyContest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)
if (denyContest) return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
if (problem.contestId !== null) {
@@ -876,12 +757,7 @@ adminProblemRoutes.post(
)
.limit(1)
if (duplicate)
return failure(
c,
409,
"display-id-exists",
"Duplicate display id in this contest",
)
return failure(c, 409, "display-id-exists", "Duplicate display id in this contest")
const created = await db.transaction((tx) =>
copyProblem(tx, problem, {
@@ -900,14 +776,10 @@ adminProblemRoutes.post(
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
const form = await c.req.formData().catch(() => null)
const file = form?.get("file")
if (!(file instanceof File))
return failure(c, 400, "invalid-request", "Upload failed")
if (!(file instanceof File)) return failure(c, 400, "invalid-request", "Upload failed")
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
try {
const result = await processTestCaseZip(
new Uint8Array(await file.arrayBuffer()),
{ sql },
)
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
return success(
c,
{
@@ -917,120 +789,89 @@ adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
201,
)
} catch (error) {
if (error instanceof TestCaseError)
return failure(c, 400, "invalid-test-case", error.message)
if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message)
console.error("Failed to process test case zip", error)
return failure(c, 500, "test-case-error", "测试点处理失败")
}
})
adminProblemRoutes.get(
"/problems/:id/test-cases",
requireProblemPermission,
async (c) => {
const [problem] = await db
.select()
.from(schema.problem)
.where(
eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
try {
const archive = await packTestCaseZip(problem.testCaseId)
return new Response(archive, {
headers: {
"content-type": "application/zip",
"content-disposition": `attachment; filename=problem_${problem.id}_test_cases.zip`,
},
})
} catch (error) {
if (error instanceof TestCaseError)
return failure(c, 404, "test-case-not-found", error.message)
throw error
}
},
)
adminProblemRoutes.get("/problems/:id/test-cases", requireProblemPermission, async (c) => {
const [problem] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
try {
const archive = await packTestCaseZip(problem.testCaseId)
return new Response(archive, {
headers: {
"content-type": "application/zip",
"content-disposition": `attachment; filename=problem_${problem.id}_test_cases.zip`,
},
})
} catch (error) {
if (error instanceof TestCaseError) return failure(c, 404, "test-case-not-found", error.message)
throw error
}
})
/**
* 回显 SQL 题已上传的测试点脚本内容。只读磁盘上的 N.sql,不需要 SQL 引擎 ——
* 同组的 sql-preview / sql-ai-gen 要跑 SQLite 生成展示数据,新后端还没有那条链路,
* 那两个仍在旧后端上。
*/
adminProblemRoutes.get(
"/problems/:id/sql-scripts",
requireProblemPermission,
async (c) => {
const [problem] = await db
.select()
.from(schema.problem)
.where(
eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const info = await readInfo(problem.testCaseId)
if (!info)
return failure(c, 404, "test-case-info-unreadable", "测试点信息读取失败")
if (!info.sql)
return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型")
try {
const scripts = await readSqlScripts(problem.testCaseId)
return success(c, scripts satisfies SqlTestCaseScript[])
} catch (error) {
console.error("Failed to read SQL test case scripts", error)
return failure(c, 500, "test-case-error", "测试点脚本读取失败")
}
},
)
adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, async (c) => {
const [problem] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const info = await readInfo(problem.testCaseId)
if (!info) return failure(c, 404, "test-case-info-unreadable", "测试点信息读取失败")
if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型")
try {
const scripts = await readSqlScripts(problem.testCaseId)
return success(c, scripts satisfies SqlTestCaseScript[])
} catch (error) {
console.error("Failed to read SQL test case scripts", error)
return failure(c, 500, "test-case-error", "测试点脚本读取失败")
}
})
/** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */
adminProblemRoutes.post(
"/sql-test-cases/preview",
requireProblemPermission,
async (c) => {
const parsed = await parseBody(c, sqlPreviewRequestSchema)
if (!parsed.success) return parsed.response
const outcome = await buildSqlDisplay(
parsed.data.initSql,
parsed.data.refSql,
parsed.data.mode,
)
if (!outcome.ok)
return failure(c, 400, "sql-preview-failed", outcome.message)
return success(c, outcome.value)
},
)
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, sqlPreviewRequestSchema)
if (!parsed.success) return parsed.response
const outcome = await buildSqlDisplay(parsed.data.initSql, parsed.data.refSql, parsed.data.mode)
if (!outcome.ok) return failure(c, 400, "sql-preview-failed", outcome.message)
return success(c, outcome.value)
})
/** AI 按标准答案倒推表结构、生成一份自洽的初始化脚本 */
adminProblemRoutes.post(
"/sql-test-cases/generate",
requireProblemPermission,
async (c) => {
const parsed = await parseBody(c, generateSqlTestCaseRequestSchema)
if (!parsed.success) return parsed.response
try {
const sql = await completeChat(
`你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的
adminProblemRoutes.post("/sql-test-cases/generate", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, generateSqlTestCaseRequestSchema)
if (!parsed.success) return parsed.response
try {
const sql = await completeChat(
`你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的
SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
请你推断出该标准答案所需要的表结构,生成一份自洽的 SQLite 兼容初始化脚本,
包含 CREATE TABLE 和若干条 INSERT 语句,插入的数据要足够让标准答案跑出有意义的结果
(比如查询题要有能被筛选出来和被过滤掉的行;增删改题要有能被改动和不受影响的行)。
请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`,
`题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`,
)
return success(c, { sql } satisfies GenerateSqlTestCaseResponse)
} catch (error) {
console.error("SQL test case generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
},
)
`题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`,
)
return success(c, { sql } satisfies GenerateSqlTestCaseResponse)
} catch (error) {
console.error("SQL test case generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
})
+300 -421
View File
@@ -12,18 +12,7 @@ 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"
@@ -46,10 +35,7 @@ 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()
@@ -91,22 +77,11 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
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)),
]),
),
.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)
@@ -118,10 +93,7 @@ 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,
@@ -140,8 +112,7 @@ 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()
@@ -153,8 +124,7 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
)!,
)
}
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
@@ -182,9 +152,7 @@ adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
.insert(schema.problemset)
.values({
...parsed.data,
endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
@@ -208,9 +176,7 @@ adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
.update(schema.problemset)
.set({
...parsed.data,
endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
@@ -218,45 +184,33 @@ adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
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 = await parseBody(
c,
updateProblemSetStatusRequestSchema,
"status 不合法",
)
if (!parsed.success) return parsed.response
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 = await parseBody(c, updateProblemSetStatusRequestSchema, "status 不合法")
if (!parsed.success) return parsed.response
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")!)
@@ -269,168 +223,132 @@ 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,
problemDisplayId: 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,
problemDisplayId: 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 = await parseBody(c, addProblemToSetRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, addProblemToSetRequestSchema)
if (!parsed.success) return parsed.response
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 [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 = await parseBody(
c,
updateProblemInSetRequestSchema,
"参数错误",
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),
),
)
if (!parsed.success) return parsed.response
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)
},
)
.limit(1)
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
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)
},
)
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 = await parseBody(c, updateProblemInSetRequestSchema, "参数错误")
if (!parsed.success) return parsed.response
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)
})
// ---------------------------------------------------------------- 奖章
@@ -467,191 +385,152 @@ async function badgesWithCount(badges: BadgeRow[]) {
)
}
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 = await parseBody(c, createProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, createProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, updateProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, updateProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response
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.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 })
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,
})
if (deleted.length === 0)
return failure(c, 404, "progress-not-found", "用户未加入该题单")
return success(c, null)
},
)
.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)
})
+130 -208
View File
@@ -26,11 +26,7 @@ import {
} 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, parseBody, success } from "../../http"
@@ -43,11 +39,7 @@ 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) {
@@ -65,18 +57,12 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag)
.leftJoin(
schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.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[])
})
@@ -97,18 +83,12 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
.select()
.from(schema.problemTag)
.where(
and(
sql`lower(${schema.problemTag.name}) = lower(${name})`,
ne(schema.problemTag.id, id),
),
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))
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
return success(c, {
merged: false,
id,
@@ -154,142 +134,119 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
} 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 = await parseBody(c, batchProblemTagRequestSchema)
if (!parsed.success) return parsed.response
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 = await parseBody(c, batchProblemTagRequestSchema)
if (!parsed.success) return parsed.response
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, 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, 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)
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),
),
)
const have = new Set(
existing.map((row) => `${row.problemId}:${row.problemtagId}`),
return
}
const existing = await tx
.select()
.from(schema.problemTags)
.where(
and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
),
)
const rows = []
for (const problemId of problemIds) {
for (const tagId of tagIds) {
if (!have.has(`${problemId}:${tagId}`))
rows.push({ problemId, problemtagId: tagId })
}
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)
})
}
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 趋势
@@ -309,16 +266,12 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
Number,
),
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
failedUsers:
sql<number>`count(distinct ${schema.submission.userId}) ${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),
)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
/**
* 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题
* 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题
@@ -333,12 +286,8 @@ 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,
@@ -362,16 +311,14 @@ 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 year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
Number,
)
const rows = await db
.select({
problemId: schema.problem.id,
@@ -385,32 +332,15 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
),
})
.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,
)
.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)
@@ -446,28 +376,20 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
// ---------------------------------------------------------------- Python → 流程图
adminTagRoutes.post(
"/problems/flowchart",
requireProblemPermission,
async (c) => {
const parsed = await parseBody(
c,
generateFlowchartRequestSchema,
"python 代码不能为空",
)
if (!parsed.success) return parsed.response
try {
const flowchart = await completeChat(
`你是一个可以将Python代码转换为mermaid的助手。
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, generateFlowchartRequestSchema, "python 代码不能为空")
if (!parsed.success) return parsed.response
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", "生成失败,请稍后再试")
}
})
+31 -61
View File
@@ -89,11 +89,8 @@ adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
})
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))
})
@@ -106,35 +103,25 @@ adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
.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")
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 = await parseBody(
c,
setTutorialVisibilityRequestSchema,
"isPublic is required",
)
if (!parsed.success) return parsed.response
// 只改可见性,不动 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 = await parseBody(c, setTutorialVisibilityRequestSchema, "isPublic is required")
if (!parsed.success) return parsed.response
// 只改可见性,不动 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 })
@@ -145,8 +132,7 @@ adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
.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)
})
@@ -163,23 +149,14 @@ 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 = await parseBody(c, createExerciseRequestSchema)
@@ -189,8 +166,7 @@ adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
.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")
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
@@ -218,23 +194,17 @@ adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
data: parsed.data.data,
order: parsed.data.order,
})
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.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 })),
)
.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)
})
+69 -174
View File
@@ -11,17 +11,7 @@ import {
type HeatmapItem,
type LoginSummary,
} from "@oj2/contract"
import {
and,
count,
countDistinct,
eq,
gte,
inArray,
isNull,
lte,
sql,
} from "drizzle-orm"
import { and, count, countDistinct, eq, gte, inArray, isNull, lte, sql } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
@@ -32,32 +22,12 @@ import { JudgeStatus } from "../judge/status"
import { failure, parseBody, readJson, success } from "../http"
import { completeChat, streamChat, streamWhole } from "../services/ai"
import { generateFilteredHint } from "../services/hint-filter"
import {
accepted,
buildDetail,
buildDuration,
listSolved,
} from "../services/learning-stats"
import { accepted, buildDetail, buildDuration, listSolved } from "../services/learning-stats"
import { decideHintLevel } from "../services/hint-level"
import {
hintDiagnosis,
hintPrompt,
referenceAnswer,
} from "../services/hint-diagnosis"
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis"
import { consumeToken } from "../services/throttling"
import {
calendarDay,
dayNumber,
dayText,
localTime,
localWeekday,
} from "../time"
import {
countFailedSubmissions,
isTeacherOrAbove,
asRecord,
queryInteger,
} from "./helpers"
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time"
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
@@ -73,12 +43,7 @@ function aiThrottleKey(userId: number) {
async function throttleAi(c: Context<AppEnv>) {
const throttle = await consumeToken("user", aiThrottleKey(c.get("user")!.id))
if (throttle.allowed) return null
return failure(
c,
429,
"too-many-requests",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
return failure(c, 429, "too-many-requests", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
async function targetUser(c: Context<AppEnv>, override?: string) {
@@ -104,18 +69,8 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (
!start ||
!end ||
Number.isNaN(Date.parse(start)) ||
Number.isNaN(Date.parse(end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
@@ -125,18 +80,8 @@ aiRoutes.get("/ai/detail", requireAuth, async (c) => {
aiRoutes.get("/ai/solved", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (
!start ||
!end ||
Number.isNaN(Date.parse(start)) ||
Number.isNaN(Date.parse(end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
@@ -151,10 +96,7 @@ aiRoutes.get("/ai/duration", requireAuth, async (c) => {
return failure(c, 400, "invalid-end", "end must be an ISO timestamp")
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
return success(
c,
await buildDuration(user, endText, c.req.query("duration") ?? "months:1"),
)
return success(c, await buildDuration(user, endText, c.req.query("duration") ?? "months:1"))
})
aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
@@ -174,14 +116,8 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
.where(
and(
eq(schema.submission.userId, user.id),
gte(
schema.submission.createTime,
new Date((firstMonday - 1) * 864e5).toISOString(),
),
lte(
schema.submission.createTime,
new Date(end.getTime() + 864e5).toISOString(),
),
gte(schema.submission.createTime, new Date((firstMonday - 1) * 864e5).toISOString()),
lte(schema.submission.createTime, new Date(end.getTime() + 864e5).toISOString()),
),
)
.groupBy(date)
@@ -192,8 +128,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
Array.from({ length: 53 }, (_, week) => {
const monday = firstMonday + week * 7
let value = 0
for (let offset = 0; offset < 7; offset++)
value += counts.get(dayText(monday + offset)) ?? 0
for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0
// timestamp 是该周周一的 UTC 零点,前端按东八区只取年月日部件
return { timestamp: monday * 864e5, value } satisfies HeatmapItem
}),
@@ -213,64 +148,60 @@ aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
.limit(1)
const previous = await getPreviousLogin(c)
let start = new Date(
previous ??
userRow?.lastLogin ??
userRow?.createTime ??
end.getTime() - 7 * 864e5,
previous ?? userRow?.lastLogin ?? userRow?.createTime ?? end.getTime() - 7 * 864e5,
)
if (start >= end) start = new Date(end.getTime() - 864e5)
const range = and(
gte(schema.submission.createTime, start.toISOString()),
lte(schema.submission.createTime, end.toISOString()),
)
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] =
await Promise.all([
db
.select({ value: count() })
.from(schema.problem)
.where(
and(
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
gte(schema.problem.createTime, start.toISOString()),
lte(schema.problem.createTime, end.toISOString()),
),
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] = await Promise.all([
db
.select({ value: count() })
.from(schema.problem)
.where(
and(
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
gte(schema.problem.createTime, start.toISOString()),
lte(schema.problem.createTime, end.toISOString()),
),
db
.select({ value: count() })
.from(schema.submission)
.where(and(eq(schema.submission.userId, user.id), range)),
db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
),
db
.select({ value: count() })
.from(schema.submission)
.where(and(eq(schema.submission.userId, user.id), range)),
db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
gte(schema.flowchartSubmission.createTime, start.toISOString()),
lte(schema.flowchartSubmission.createTime, end.toISOString()),
),
),
db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
gte(schema.flowchartSubmission.createTime, start.toISOString()),
lte(schema.flowchartSubmission.createTime, end.toISOString()),
),
])
),
])
const summary = {
start: start.toISOString(),
end: end.toISOString(),
@@ -306,10 +237,7 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(
and(
eq(schema.aiAnalysis.userId, c.get("user")!.id),
eq(schema.aiAnalysis.isPinned, true),
),
and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true)),
)
.limit(1)
if (!row) return success(c, null)
@@ -326,22 +254,10 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
})
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = await parseBody(
c,
aiAnalysisRequestSchema,
"start, end and duration are required",
)
const parsed = await parseBody(c, aiAnalysisRequestSchema, "start, end and duration are required")
if (!parsed.success) return parsed.response
if (
Number.isNaN(Date.parse(parsed.data.start)) ||
Number.isNaN(Date.parse(parsed.data.end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
if (Number.isNaN(Date.parse(parsed.data.start)) || Number.isNaN(Date.parse(parsed.data.end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
// 传 username 的鉴权走 targetUser:非教师传了也只会拿到自己
const user = await targetUser(c, parsed.data.username)
@@ -423,19 +339,12 @@ async function recordHint(
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = await parseBody(
c,
aiHintRequestSchema,
"submissionId is required",
)
const parsed = await parseBody(c, aiHintRequestSchema, "submissionId is required")
if (!parsed.success) return parsed.response
const [row] = await db
.select({ submission: schema.submission, 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, parsed.data.submissionId),
@@ -443,17 +352,11 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
),
)
.limit(1)
if (!row)
return failure(c, 404, "submission-not-found", "Submission not found")
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
// 比赛里不给 AI 提示,和「求助」按钮同一个口径。前端在比赛路由下压根不显示按钮,
// 这里是防直接 POST 的那一道 —— 比赛只有 ACM 模式,提示等于变相放水。
if (row.submission.contestId !== null)
return failure(
c,
403,
"contest-hint-disabled",
"Hint is disabled in contests",
)
return failure(c, 403, "contest-hint-disabled", "Hint is disabled in contests")
// 失败次数在端点这边也要卡一道:直接 POST 完全绕开前端的显示条件 ——
// 不然这就是个不限次数的免费 LLM 接口。数法(判题中的不算、判题机自己崩的不算)
// 由 countFailedSubmissions 统一,题目详情的 myFailedCount 走的是同一个函数,
@@ -461,10 +364,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
// 编译失败不数次数(理由见 HINT_MIN_FAILURES 的注释)。放开的只是这一次提交本身,
// 下面的 throttleAi 照样卡着,不会因此变成不限次数的接口。
if (row.submission.result !== JudgeStatus.COMPILE_ERROR) {
const failed = await countFailedSubmissions(
c.get("user")!.id,
row.submission.problemId,
)
const failed = await countFailedSubmissions(c.get("user")!.id, row.submission.problemId)
if (failed < HINT_MIN_FAILURES)
return failure(
c,
@@ -523,8 +423,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await readJson(c, aiHintFeedbackRequestSchema)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
if (!id || !parsed.success) return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
// 不透露那个 id 上有没有东西
const [updated] = await db
@@ -553,11 +452,7 @@ aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user")))
return failure(c, 403, "permission-denied", "Permission denied")
const parsed = await parseBody(
c,
classAnalysisRequestSchema,
"Class data is required",
)
const parsed = await parseBody(c, classAnalysisRequestSchema, "Class data is required")
if (!parsed.success) return parsed.response
const limited = await throttleAi(c)
if (limited) return limited
+3 -13
View File
@@ -32,11 +32,7 @@ const LOGIN_PER_USERNAME: AttemptRule = { limit: 10, windowSeconds: 15 * 60 }
const LOGIN_PER_IP: AttemptRule = { limit: 100, windowSeconds: 15 * 60 }
authRoutes.post("/auth/login", async (c) => {
const parsed = await parseBody(
c,
loginRequestSchema,
"Username and password are required",
)
const parsed = await parseBody(c, loginRequestSchema, "Username and password are required")
if (!parsed.success) return parsed.response
const usernameKey = `login:user:${parsed.data.username.toLowerCase()}`
@@ -57,12 +53,7 @@ authRoutes.post("/auth/login", async (c) => {
countAttempt(usernameKey, LOGIN_PER_USERNAME),
countAttempt(ipKey, LOGIN_PER_IP),
])
return failure(
c,
401,
"invalid-credentials",
"Invalid username or password",
)
return failure(c, 401, "invalid-credentials", "Invalid username or password")
}
const [user] = await db
@@ -107,7 +98,6 @@ authRoutes.get("/me", optionalAuth, async (c) => {
if (!authUser) return success(c, null)
const data = await getUserProfileById(authUser.id, true)
if (!data)
return failure(c, 404, "profile-not-found", "User profile does not exist")
if (!data) return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, data)
})
+24 -74
View File
@@ -47,27 +47,20 @@ async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
submissionNumber: schema.userProfile.submissionNumber,
})
.from(schema.user)
.innerJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.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) {
@@ -85,58 +78,42 @@ 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),
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,
)
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,
acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
}
})
.sort(
(a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission,
)
.sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
return success(
c,
result.map(
(item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem,
),
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,
@@ -165,11 +142,7 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
})
classroomRoutes.post("/classes/comparison", async (c) => {
const parsed = await parseBody(
c,
classComparisonRequestSchema,
"At least one class is required",
)
const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required")
if (!parsed.success) return parsed.response
const users = await loadClassUsers(parsed.data.classNames)
const allAc = users.map((user) => user.acceptedNumber)
@@ -200,21 +173,12 @@ classroomRoutes.post("/classes/comparison", async (c) => {
lte(schema.submission.createTime, parsed.data.endTime!),
),
)
const userClass = new Map(
users.map((user) => [user.userId, user.className]),
)
const userClass = new Map(users.map((user) => [user.userId, user.className]))
for (const row of rows) {
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)
@@ -223,17 +187,12 @@ 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 = {
@@ -250,17 +209,10 @@ 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) {
@@ -290,9 +242,7 @@ classroomRoutes.post("/classes/comparison", async (c) => {
1,
)
}
comparisons.sort(
(a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc,
)
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
return success(c, {
comparisons,
hasTimeRange,
+34 -129
View File
@@ -43,19 +43,10 @@ contentRoutes.get("/announcements", async (c) => {
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))
.where(eq(schema.announcement.visible, true))
.orderBy(
desc(schema.announcement.top),
desc(schema.announcement.createTime),
)
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime))
.limit(limit)
.offset(offset),
])
@@ -87,20 +78,9 @@ contentRoutes.get("/announcements/:id", async (c) => {
.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),
),
)
.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",
)
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, {
id: row.announcement.id,
title: row.announcement.title,
@@ -132,18 +112,9 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
})
.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),
)
.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)
@@ -186,29 +157,15 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
*/
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
const user = c.get("user")!
const parsed = await parseBody(
c,
createMessageRequestSchema,
"Invalid message payload",
)
const parsed = await parseBody(c, createMessageRequestSchema, "Invalid message payload")
if (!parsed.success) return parsed.response
if (parsed.data.recipientId === user.id)
return failure(
c,
400,
"invalid-recipient",
"Can not send a message to yourself",
)
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),
),
)
.where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false)))
.limit(1),
db
.select({ id: schema.submission.id })
@@ -216,10 +173,8 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
.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(),
@@ -234,12 +189,7 @@ 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),
),
)
.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
@@ -263,20 +213,14 @@ 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 = await parseBody(
c,
setReactionRequestSchema,
"Invalid reaction",
)
const parsed = await parseBody(c, setReactionRequestSchema, "Invalid reaction")
if (!parsed.success) return parsed.response
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)),
)
.where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true)))
.limit(1),
db
.select({ id: schema.submission.id })
@@ -285,23 +229,14 @@ contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, problemId),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
),
)
.limit(1),
])
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem) 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",
)
return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
await db
.insert(schema.reaction)
.values({
@@ -321,9 +256,7 @@ contentRoutes.get("/tutorials", async (c) => {
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)),
)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.orderBy(asc(schema.tutorial.order))
return success(c, rows satisfies TutorialSummary[])
})
@@ -341,8 +274,7 @@ contentRoutes.get("/tutorials/:id", async (c) => {
.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")
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, {
id: row.tutorial.id,
title: row.tutorial.title,
@@ -369,10 +301,7 @@ 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」和「这课没有练习」在前端分不出来
@@ -399,16 +328,12 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
.select({
tutorialId: schema.exercise.tutorialId,
total: count(),
solved:
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
})
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(
schema.exerciseAttempt,
and(
@@ -448,19 +373,14 @@ 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 = await parseBody(
c,
tutorialProgressPingSchema,
"Invalid progress payload",
)
const parsed = await parseBody(c, tutorialProgressPingSchema, "Invalid progress payload")
if (!parsed.success) return parsed.response
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")
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const now = new Date().toISOString()
const { seconds, opened } = parsed.data
@@ -475,10 +395,7 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
lastViewedAt: now,
})
.onConflictDoUpdate({
target: [
schema.tutorialProgress.userId,
schema.tutorialProgress.tutorialId,
],
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
set: {
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
@@ -503,24 +420,16 @@ 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 = await parseBody(
c,
exerciseAttemptRequestSchema,
"Invalid attempt payload",
)
const parsed = await parseBody(c, exerciseAttemptRequestSchema, "Invalid attempt payload")
if (!parsed.success) return parsed.response
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
const [exercise] = await db
.select({ id: schema.exercise.id })
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.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")
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
const now = new Date().toISOString()
const { correct } = parsed.data
@@ -540,10 +449,7 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
solvedAt: correct ? now : null,
})
.onConflictDoUpdate({
target: [
schema.exerciseAttempt.userId,
schema.exerciseAttempt.exerciseId,
],
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
set: {
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
//
@@ -576,8 +482,7 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => {
.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")
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const rows = await db
.select()
.from(schema.exercise)
+70 -134
View File
@@ -9,18 +9,7 @@ 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"
@@ -38,11 +27,7 @@ import {
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import {
countAttempt,
lockoutRemaining,
type AttemptRule,
} from "../services/throttling"
import { countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
import { asRecord, publicTemplates, queryInteger, sampleUser } from "./helpers"
export const contestRoutes = new Hono<ContestEnv>()
@@ -98,12 +83,7 @@ contestRoutes.get("/contests", async (c) => {
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),
)!,
)
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),
@@ -120,8 +100,7 @@ contestRoutes.get("/contests", async (c) => {
results: rows.map((row) =>
serializeContest(
row,
byId.get(row.createdById) ??
sampleUser({ id: row.createdById, username: "" }, null),
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
),
),
total: totalRow[0]?.value ?? 0,
@@ -135,15 +114,13 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
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),
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null),
true,
),
)
@@ -158,11 +135,7 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = await parseBody(
c,
contestPasswordRequestSchema,
"Password is required",
)
const parsed = await parseBody(c, contestPasswordRequestSchema, "Password is required")
if (!parsed.success) return parsed.response
// 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。
// 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。
@@ -178,12 +151,7 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
}
if (!checkContestPassword(parsed.data.password, contest.password)) {
await countAttempt(attemptKey, CONTEST_PASSWORD_RULE)
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)
@@ -234,14 +202,10 @@ async function contestProblemTags(problemIds: number[]) {
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
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
}
@@ -259,16 +223,8 @@ contestRoutes.get(
})
.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),
),
)
.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)
@@ -311,10 +267,7 @@ contestRoutes.get(
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.problem.contestId, contest.id),
@@ -323,8 +276,7 @@ contestRoutes.get(
),
)
.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 tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
@@ -358,9 +310,7 @@ contestRoutes.get(
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart
? null
: asRecord(row.problem.flowchartData),
flowchartData: row.problem.allowFlowchart ? null : asRecord(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay,
@@ -370,72 +320,58 @@ contestRoutes.get(
},
)
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)
})
+132 -223
View File
@@ -10,17 +10,7 @@ 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"
@@ -53,11 +43,7 @@ function canView(
row: { userId: number },
problem: { createdById: number },
) {
return (
row.userId === user.id ||
isAdminRole(user) ||
problem.createdById === user.id
)
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
}
function flowchartData(
@@ -86,10 +72,7 @@ function flowchartData(
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
const parsed = await readJson(c, createFlowchartRequestSchema)
if (
!parsed.success ||
JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024
) {
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) {
return failure(
c,
400,
@@ -102,8 +85,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
.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) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.allow)
return failure(
c,
@@ -114,10 +96,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
// 限流:每次提交都会触发一次外部 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,
@@ -152,18 +131,9 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
.update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, id))
return failure(
c,
502,
"queue-unavailable",
"Evaluation queue is unavailable",
)
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)
})
/**
@@ -202,9 +172,7 @@ async function flowchartProblemFilter(displayId: string) {
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`
}
/**
@@ -250,16 +218,12 @@ 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")
c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")
const filters: Array<SQL | undefined> = []
filters.push(
...(await Promise.all([
@@ -279,14 +243,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
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)
@@ -346,8 +304,7 @@ 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("problemDisplayId")?.trim()
if (displayId) {
@@ -362,8 +319,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
),
)
.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))
}
@@ -374,17 +330,13 @@ 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")
: []
/**
@@ -396,56 +348,49 @@ 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(
@@ -456,25 +401,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, {
@@ -525,8 +470,7 @@ 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)
@@ -543,9 +487,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
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,
@@ -568,14 +510,8 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
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),
)
.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))
@@ -588,21 +524,13 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
const [row] = await db
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
.from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.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(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",
)
return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
if (!isAdminRole(user)) {
@@ -646,12 +574,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
.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 failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
}
return success(c, {
submissionId: row.flowchart.id,
@@ -659,65 +582,51 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
} 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)
})
+3 -11
View File
@@ -35,10 +35,7 @@ export function sampleUser(
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
*/
export function stripClassPrefix(
username: string,
className: string | null | undefined,
) {
export function stripClassPrefix(username: string, className: string | null | undefined) {
if (!className) return username
const prefix = `ks${className}`
return username.startsWith(prefix) ? username.slice(prefix.length) : username
@@ -67,9 +64,7 @@ export function classPrefixCondition(input: string) {
* 和列没收窄之前的行为完全一致 —— 所以这里只做类型上的交接,**不加校验**:
* 在这儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」,那是另一种行为。
*/
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
}
@@ -133,10 +128,7 @@ 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)
+4 -13
View File
@@ -22,14 +22,11 @@ export const judgeServerRoutes = new Hono()
function tokenMatches(value: string | undefined) {
if (!value) return false
const expected = createHash("sha256")
.update(config.judgeServerToken)
.digest("hex")
const expected = createHash("sha256").update(config.judgeServerToken).digest("hex")
const actualBuffer = Buffer.from(value)
const expectedBuffer = Buffer.from(expected)
return (
actualBuffer.length === expectedBuffer.length &&
timingSafeEqual(actualBuffer, expectedBuffer)
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
)
}
@@ -51,10 +48,7 @@ async function heartbeat(c: Context) {
.limit(1)
const common = {
ip:
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
c.req.header("x-real-ip") ||
null,
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || null,
judgerVersion: parsed.data.judger_version,
cpuCore: parsed.data.cpu_core,
memoryUsage: parsed.data.memory,
@@ -64,10 +58,7 @@ async function heartbeat(c: Context) {
}
if (existing) {
await db
.update(schema.judgeServer)
.set(common)
.where(eq(schema.judgeServer.id, existing.id))
await db.update(schema.judgeServer).set(common).where(eq(schema.judgeServer.id, existing.id))
} else {
await db.insert(schema.judgeServer).values({
...common,
+16 -57
View File
@@ -68,10 +68,7 @@ async function getProblemTags(problemIds: number[]) {
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
const result = new Map<number, string[]>()
for (const row of rows)
@@ -109,10 +106,7 @@ function listItem(
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 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()
@@ -125,8 +119,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
ilike(schema.problem.displayId, `%${keyword}%`),
)!,
)
if (difficulty)
filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (tag) {
filters.push(
inArray(
@@ -134,10 +127,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTag.name, tag)),
),
)
@@ -153,10 +143,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
desc(schema.problem.createTime),
]
: sort === "ast"
? [
desc(sql`(${schema.problem.astRules} is not null)`),
desc(schema.problem.createTime),
]
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
: sort === "-accepted_number"
? [desc(schema.problem.acceptedNumber)]
: sort === "accepted_number"
@@ -209,10 +196,7 @@ problemRoutes.get("/problem-tags", async (c) => {
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag)
.innerJoin(
schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.innerJoin(
schema.problem,
and(
@@ -232,9 +216,7 @@ 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)),
)
.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")
@@ -273,10 +255,7 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
),
)
if (!mine?.value) return success(c, "0")
@@ -286,12 +265,7 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
db
.select({ value: count() })
.from(schema.user)
.where(
and(
eq(schema.user.isDisabled, false),
gte(schema.user.lastLogin, since),
),
),
.where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))),
db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
@@ -307,9 +281,7 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
const solved = accepted[0]?.value ?? 0
return success(
c,
total > 0 && solved < total
? (((total - solved) / total) * 100).toFixed(2)
: "0",
total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0",
)
})
@@ -399,8 +371,7 @@ problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
),
)
.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")
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
const rows = await db
.select({
@@ -424,10 +395,7 @@ problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
(row) =>
({
...row,
acRate:
row.total > 0
? Math.round((row.accepted / row.total) * 10_000) / 100
: 0,
acRate: row.total > 0 ? Math.round((row.accepted / row.total) * 10_000) / 100 : 0,
}) satisfies YearlyAc,
),
)
@@ -451,16 +419,12 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
)
.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),
)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTags.problemId, row.problem.id))
const user = c.get("user")
@@ -505,18 +469,13 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
statisticInfo: asRecord(row.problem.statisticInfo),
contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name),
createdBy: sampleUser(
{ id: row.creatorId, username: row.creatorUsername },
null,
),
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
myStatus,
myFailedCount,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart
? null
: asRecord(row.problem.flowchartData),
flowchartData: row.problem.allowFlowchart ? null : asRecord(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay,
+175 -278
View File
@@ -8,46 +8,20 @@ import {
type ProblemSetProgressList,
type UserBadge,
} from "@oj2/contract"
import {
and,
asc,
avg,
count,
desc,
eq,
gt,
ilike,
inArray,
ne,
or,
sql,
} from "drizzle-orm"
import { and, asc, avg, count, desc, eq, gt, ilike, inArray, ne, or, sql } 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, parseBody, success } from "../http"
import { computeProgress } from "../services/problemset"
import {
asFilterValue,
asRecord,
classPrefixCondition,
queryInteger,
sampleUser,
} from "./helpers"
import { asFilterValue, asRecord, classPrefixCondition, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>()
type ProblemSetRow = typeof schema.problemset.$inferSelect
function progressSummary(
progress: typeof schema.problemsetProgress.$inferSelect | undefined,
) {
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
return progress
? {
isJoined: true,
@@ -81,10 +55,7 @@ async function problemSetCreators(ids: number[]) {
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,
@@ -103,75 +74,58 @@ function badgeData(
* 以前是每行 5 条查询(题目数 / 我的进度 / 奖章 / 已获奖章 / 创建者),
* limit 最大 250 就是 1250 次往返。这里固定 5 条,与行数无关。
*/
async function serializeProblemSets(
rows: ProblemSetRow[],
userId?: number,
includeBadges = false,
) {
async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, includeBadges = false) {
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)[]
>()
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,
])
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)
@@ -180,8 +134,7 @@ async function serializeProblemSets(
title: row.title,
description: row.description,
createdBy:
creators.get(row.createdById) ??
sampleUser({ id: row.createdById, username: "" }, null),
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
difficulty: row.difficulty,
@@ -192,9 +145,7 @@ async function serializeProblemSets(
completedCount: progress?.completedProblemsCount ?? 0,
userProgress: progressSummary(progress),
badges: includeBadges
? (badgesBySet.get(row.id) ?? []).map((badge) =>
badgeData(badge, earned.has(badge.id)),
)
? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id)))
: undefined,
} satisfies ProblemSet
})
@@ -203,10 +154,7 @@ 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()
@@ -217,8 +165,7 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
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 = and(...filters)
const [totalRows, rows] = await Promise.all([
@@ -284,15 +231,9 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
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 })
@@ -348,11 +289,7 @@ async function recomputeProgress(
}
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
const parsed = await parseBody(
c,
joinProblemSetRequestSchema,
"Invalid problem set",
)
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
if (!parsed.success) return parsed.response
const user = c.get("user")!
const [problemSet] = await db
@@ -402,17 +339,11 @@ 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")
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),
),
)
.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
@@ -422,14 +353,8 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
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),
)
.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(
@@ -471,145 +396,117 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
)
})
problemsetRoutes.get(
"/problem-sets/:id/user-progress",
requireTeacher,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db
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)]
// 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配,
// 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含
if (className) {
const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className
filters.push(classPrefixCondition(classPrefix) ?? 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({
id: schema.problemset.id,
createdById: schema.problemset.createdById,
total: count(),
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
avgProgress: avg(schema.problemsetProgress.progressPercentage),
})
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
.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(asRecord(progress.progressDetail)).flatMap(
(key) => problemMap.get(key) ?? [],
),
)
.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)]
// 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配,
// 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含
if (className) {
const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className
filters.push(
classPrefixCondition(classPrefix) ??
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(
asRecord(progress.progressDetail),
).flatMap((key) => problemMap.get(key) ?? []),
}) satisfies ProblemSetProgress,
)
const stats = statsRows[0]
return success(c, {
results,
}) satisfies ProblemSetProgress,
)
const stats = statsRows[0]
return success(c, {
results,
total: stats?.total ?? 0,
statistics: {
total: stats?.total ?? 0,
statistics: {
total: stats?.total ?? 0,
completed: stats?.completed ?? 0,
avgProgress: Number(stats?.avgProgress ?? 0),
},
problems: problemRows,
} satisfies ProblemSetProgressList)
},
)
completed: stats?.completed ?? 0,
avgProgress: Number(stats?.avgProgress ?? 0),
},
problems: problemRows,
} satisfies ProblemSetProgressList)
})
+6 -11
View File
@@ -53,9 +53,10 @@ 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) => ({
@@ -87,8 +88,7 @@ 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)
}
})
@@ -96,12 +96,7 @@ 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 })
+295 -361
View File
@@ -11,28 +11,13 @@ import {
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import {
and,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
type SQL,
} from "drizzle-orm"
import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import {
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { localTime, dayStart } from "../time"
@@ -54,118 +39,96 @@ function judgedRate(accepted: number, judged: number) {
* 按钟点切用 `localTime()`,不能写 `extract(hour from create_time)` ——
* 后者按数据库会话时区算,容器是 UTC,整张分布图会整体左移 8 小时。
*/
submissionStatisticsRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/**
* 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${dayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => {
/**
* 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${dayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db
const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number),
})
.from(schema.submission)
.where(where),
db.select({ hour, value: count() }).from(schema.submission).where(where).groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
total: count(),
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
})
.from(schema.submission)
.where(where),
db
.select({ hour, value: count() })
.from(schema.submission)
.where(where)
.groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count()))
.limit(10)
: [],
])
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
.orderBy(desc(count()))
.limit(10)
: [],
])
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problemDisplayId: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
},
)
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problemDisplayId: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
})
/**
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
*/
function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
function statisticsRange(c: { req: { query(name: string): string | undefined } }) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
@@ -360,10 +323,7 @@ export async function usernameFilter(username: string) {
* 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
*/
export async function problemFilter(
displayId: string,
contestId: number | null,
) {
export async function problemFilter(displayId: string, contestId: number | null) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
@@ -409,8 +369,7 @@ async function statisticsScope(c: {
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
@@ -437,207 +396,190 @@ async function statisticsScope(c: {
return { ok: true, filters, problemCount: displayIds.length }
}
submissionStatisticsRoutes.get(
"/submissions/statistics",
requireTeacher,
async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(
matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **解决的题数**,不是通过的提交条数。同一道题重复 AC(改完再交一次仍然对)
* 在这里只算一道 —— 表格那一列叫「已解决」,数条数就名不副实了。
* 指定了题号时它最多是 1,不指定时才看得出差别(老师查「这节课全班」就是这种)。
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* 显示的是**当前**用户名,从 user 表 join 出来 —— 按 submission.username
* 分组的话,改过名的学生会裂成新旧两行,两边各算各的,谁都够不到「全做完」。
*
* 已删号的学生 user 表里没有行,退回提交里冻结的那份名字(下面的
* personCount 兜底就是给这种情况的)。
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
.orderBy(desc(count())),
])
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **解决的题数**,不是通过的提交条数。同一道题重复 AC(改完再交一次仍然对)
* 在这里只算一道 —— 表格那一列叫「已解决」,数条数就名不副实了。
* 指定了题号时它最多是 1,不指定时才看得出差别(老师查「这节课全班」就是这种)。
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* 显示的是**当前**用户名,从 user 表 join 出来 —— 按 submission.username
* 分组的话,改过名的学生会裂成新旧两行,两边各算各的,谁都够不到「全做完」。
*
* 已删号的学生 user 表里没有行,退回提交里冻结的那份名字(下面的
* personCount 兜底就是给这种情况的)。
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
.orderBy(desc(count())),
])
/**
* 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径:
* 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏,
* 那里带着 `solvedCount`,老师看得出他差几道。
*
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
* 不填题号时无所谓「全部」,退回「至少做出一道」。
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 ——
* 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在,
* 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部
* 提交(GET /submissions/statistics/items 不按结果过滤),对错都在里面。
*
* 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
/**
* 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径:
* 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏,
* 那里带着 `solvedCount`,老师看得出他差几道。
*
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
* 不填题号时无所谓「全部」,退回「至少做出一道」。
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
const submittedUserIds = new Set(perUser.map((row) => row.userId))
/**
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 ——
* 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在,
* 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部
* 提交(GET /submissions/statistics/items 不按结果过滤),对错都在里面。
*
* 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
done: isDone(row),
}))
const submittedUserIds = new Set(perUser.map((row) => row.userId))
const data = perUser.map((row) => ({
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row),
realName: stripClassPrefix(row.username, row.className),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
/**
* 交了但没做完的:包括一道都没对的,也包括三道里做出两道的。
*
* **传了用户名时按花名册取**,和 dataUnaccepted 同一个范围,查一个班不会冒出
* 一堆别的班的人。
*
* 不传用户名时没有花名册,这一栏原先跟着空掉 —— 于是只交了错误答案的学生
* 「已完成」那张表进不去(没做完)、「未完成」那一栏也没有,整个人从屏幕上
* 消失,看起来就像统计只认成功的提交。这种情况退回「有提交但没做完的全部人」,
* 教师和禁用账号照样排除(否则老师自己试题留下的错误提交会混进点名名单)。
*
* 「还没交」那一栏没有花名册是真的算不出来(不知道该有谁),仍然为空。
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* 交了但没做完的:包括一道都没对的,也包括三道里做出两道的。
*
* **传了用户名时按花名册取**,和 dataUnaccepted 同一个范围,查一个班不会冒出
* 一堆别的班的人。
*
* 不传用户名时没有花名册,这一栏原先跟着空掉 —— 于是只交了错误答案的学生
* 「已完成」那张表进不去(没做完)、「未完成」那一栏也没有,整个人从屏幕上
* 消失,看起来就像统计只认成功的提交。这种情况退回「有提交但没做完的全部人」,
* 教师和禁用账号照样排除(否则老师自己试题留下的错误提交会混进点名名单)。
*
* 「还没交」那一栏没有花名册是真的算不出来(不知道该有谁),仍然为空。
* 剥前缀只在**查了某个班**的时候做:那时满屏都是同一个班,留着 `ks251` 是噪音。
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* 剥前缀只在**查了某个班**的时候做:那时满屏都是同一个班,留着 `ks251` 是噪音。
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
*/
realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
realName: username ? stripClassPrefix(row.username, row.className) : row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
},
)
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
})
/**
* 统计面板展开一行时拉这个人的提交明细。
@@ -646,58 +588,50 @@ submissionStatisticsRoutes.get(
* 这边是「点开的这一行是谁」。时间窗和题号沿用同一个 scope,不然展开行看到的
* 会是另一个范围的数据。
*/
submissionStatisticsRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim()
if (!username)
return failure(c, 400, "invalid-request", "username is required")
submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim()
if (!username) return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* 展开的那一行给的是**当前**用户名,先换成 user_id 再查 —— 直接按
* `submission.username` 精确匹配的话,改过名的学生展开来是空的(他的提交
* 全挂在旧名字下)。
*
* 查不到账号才退回按提交里冻结的用户名匹配:已删号的学生仍然会出现在统计
* 表格里(那一行的名字取自提交),展开行不能因此空着。
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
/**
* 展开的那一行给的是**当前**用户名,先换成 user_id 再查 —— 直接按
* `submission.username` 精确匹配的话,改过名的学生展开来是空的(他的提交
* 全挂在旧名字下)。
*
* 查不到账号才退回按提交里冻结的用户名匹配:已删号的学生仍然会出现在统计
* 表格里(那一行的名字取自提交),展开行不能因此空着。
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION,
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problemDisplayId: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION,
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problemDisplayId: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
},
)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
})
+53 -171
View File
@@ -10,25 +10,10 @@ import {
type SubmissionListItem,
type SubmissionTrace,
} from "@oj2/contract"
import {
and,
count,
desc,
eq,
gt,
inArray,
isNull,
or,
sql,
type SQL,
} from "drizzle-orm"
import { and, count, desc, eq, gt, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
import {
optionalAuth,
requireAuth,
requireSuperAdmin,
} from "../auth/middleware"
import { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
@@ -46,11 +31,7 @@ import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
import {
problemFilter,
submissionStatisticsRoutes,
usernameFilter,
} from "./submission-statistics"
import { problemFilter, submissionStatisticsRoutes, usernameFilter } from "./submission-statistics"
export const submissionRoutes = new Hono<ContestEnv>()
@@ -87,30 +68,17 @@ async function saveTrace(
}
submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = await parseBody(
c,
createSubmissionRequestSchema,
"Invalid submission payload",
)
const parsed = await parseBody(c, createSubmissionRequestSchema, "Invalid submission payload")
if (!parsed.success) return parsed.response
let contestId: number | null = null
if (parsed.data.contestId) {
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
const contest = await findAccessibleContest(
c.get("user"),
parsed.data.contestId,
)
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const contest = await findAccessibleContest(c.get("user"), parsed.data.contestId)
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "problems")
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)
if (contestStatus(contest) === "-1")
return failure(c, 403, "contest-ended", "The contest has ended")
contestId = contest.id
@@ -145,8 +113,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.languages.includes(parsed.data.language)) {
return failure(
c,
@@ -196,20 +163,10 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
})
if (parsed.data.trace)
await saveTrace(
submissionId,
user.id,
problem.id,
createTime,
parsed.data.trace,
)
await saveTrace(submissionId, user.id, problem.id, createTime, parsed.data.trace)
try {
await judgeQueue.add(
"judge",
{ submissionId, problemId: problem.id },
{ jobId: submissionId },
)
await judgeQueue.add("judge", { submissionId, problemId: problem.id }, { jobId: submissionId })
} catch (error) {
await db
.update(schema.submission)
@@ -245,53 +202,34 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
submissionRoutes.route("/", submissionStatisticsRoutes)
submissionRoutes.post(
"/submissions/:id/rejudge",
requireSuperAdmin,
async (c) => {
const [row] = await db
.select({
id: schema.submission.id,
problemId: schema.submission.problemId,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.id, c.req.param("id")),
isNull(schema.submission.contestId),
),
)
.limit(1)
if (!row)
return failure(
c,
404,
"submission-not-found",
"Submission does not exist",
)
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => {
const [row] = await db
.select({
id: schema.submission.id,
problemId: schema.submission.problemId,
})
.from(schema.submission)
.where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId)))
.limit(1)
if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist")
await db
.update(schema.submission)
.set({ statisticInfo: {}, result: JudgeStatus.PENDING })
.where(eq(schema.submission.id, row.id))
await db
.update(schema.submission)
.set({ statisticInfo: {}, result: JudgeStatus.PENDING })
.where(eq(schema.submission.id, row.id))
// jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话
// BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。
await judgeQueue.add(
"judge",
{ submissionId: row.id, problemId: row.problemId },
{ jobId: `${row.id}:rejudge:${Date.now()}` },
)
return success(c, null)
},
)
// jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话
// BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。
await judgeQueue.add(
"judge",
{ submissionId: row.id, problemId: row.problemId },
{ jobId: `${row.id}:rejudge:${Date.now()}` },
)
return success(c, null)
})
submissionRoutes.post("/code/format", requireAuth, async (c) => {
const parsed = await parseBody(
c,
formatCodeRequestSchema,
"Invalid format payload",
)
const parsed = await parseBody(c, formatCodeRequestSchema, "Invalid format payload")
if (!parsed.success) return parsed.response
try {
const code = await formatCode(parsed.data.code, parsed.data.language)
@@ -337,10 +275,7 @@ async function problemSetJoinTimes(userId: number, problemIds: number[]) {
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
})
.from(schema.problemsetProgress)
.innerJoin(
schema.problemset,
eq(schema.problemset.id, schema.problemsetProgress.problemsetId),
)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
.innerJoin(
schema.problemsetProblem,
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
@@ -350,10 +285,7 @@ async function problemSetJoinTimes(userId: number, problemIds: number[]) {
eq(schema.problemsetProgress.userId, userId),
inArray(schema.problemsetProblem.problemId, problemIds),
eq(schema.problemset.status, "active"),
or(
isNull(schema.problemset.endTime),
gt(schema.problemset.endTime, sql`now()`),
),
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
),
)
@@ -376,11 +308,7 @@ function canViewSubmission(
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。
if (row.userId === user.id && !isAdminRole(user)) {
const joinTime = problemSetJoinTime?.get(row.problemId)
if (
joinTime !== undefined &&
Date.parse(row.createTime) < Date.parse(joinTime)
)
return false
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
}
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里
// (contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。
@@ -391,11 +319,7 @@ function canViewSubmission(
// 他早就知道答案了,挡他没有意义。
const elevated =
isAdminRole(user) &&
!(
contest &&
contestStatus(contest) !== "-1" &&
user.adminType === "Student Admin"
)
!(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
// 这三条就是全部:别人的代码谁都看不到,比赛内外一样。
// 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉,
// 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条
@@ -444,13 +368,10 @@ const submissionListColumns = {
* 这里只信「有没有 data 数组」,数组项的形状信判题机,和前端 submissionCaseResults 同口径。
*/
function caseSummary(submission: typeof schema.submission.$inferSelect) {
if (submission.contestId !== null || submission.language === "SQL")
return null
if (submission.contestId !== null || submission.language === "SQL") return null
const data = asRecord(submission.info).data
if (!Array.isArray(data) || data.length === 0) return null
const passed = data.filter(
(item) => asRecord(item).result === JudgeStatus.ACCEPTED,
).length
const passed = data.filter((item) => asRecord(item).result === JudgeStatus.ACCEPTED).length
return { passed, total: data.length }
}
@@ -462,14 +383,8 @@ async function submissionDetail(id: string, user: AuthUser) {
contest: schema.contest,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.leftJoin(
schema.contest,
eq(schema.submission.contestId, schema.contest.id),
)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, id))
.limit(1)
if (!row) return null
@@ -479,16 +394,7 @@ async function submissionDetail(id: string, user: AuthUser) {
isAdminRole(user) || row.submission.userId !== user.id
? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId])
if (
!canViewSubmission(
user,
row.submission,
row.problem,
row.contest,
joinTimes,
)
)
return null
if (!canViewSubmission(user, row.submission, row.problem, row.contest, joinTimes)) return null
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
@@ -546,18 +452,12 @@ async function paginateSubmissionRows(
offset: number,
byUsername: boolean,
) {
const order = [
desc(schema.submission.createTime),
desc(schema.submission.id),
] as const
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const
const page = (condition: SQL | undefined) =>
db
.select(submissionListColumns)
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
// 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行,
// inner join 会把他们的提交整条从列表里抹掉
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
@@ -621,10 +521,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const user = c.get("user")
// 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。
if (
!(await getBooleanOption("submission_list_show_all", true)) &&
!isAdminRole(user)
) {
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, { results: [], total: 0 } satisfies SubmissionList)
}
const displayId = c.req.query("problemDisplayId")?.trim()
@@ -643,8 +540,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
if (myself) filters.push(eq(schema.submission.userId, myself.id))
if (result !== undefined && result !== "" && Number.isInteger(Number(result)))
filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (language)
filters.push(eq(schema.submission.language, asFilterValue(language)))
if (language) filters.push(eq(schema.submission.language, asFilterValue(language)))
if (c.req.query("today") === "1")
filters.push(sql`${schema.submission.createTime} >= ${dayStart()}`)
const where = and(...filters)
@@ -675,9 +571,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
id: submission.id,
problemDisplayId: problem.displayId,
problemTitle: problem.title,
showLink: user
? canViewSubmission(user, submission, problem, null, joinTimes)
: false,
showLink: user ? canViewSubmission(user, submission, problem, null, joinTimes) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
@@ -686,8 +580,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
statisticInfo: asRecord(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet:
submission.problemsetId !== null &&
problemsetTitles.has(submission.problemsetId)
submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? {
id: submission.problemsetId,
title: problemsetTitles.get(submission.problemsetId)!,
@@ -712,9 +605,7 @@ submissionRoutes.get(
const myself = c.req.query("myself") === "1" ? user : null
const username = myself ? undefined : c.req.query("username")?.trim()
const result = c.req.query("result")
const filters: Array<SQL | undefined> = [
eq(schema.submission.contestId, contest.id),
]
const filters: Array<SQL | undefined> = [eq(schema.submission.contestId, contest.id)]
filters.push(
...(await Promise.all([
displayId ? problemFilter(displayId, contest.id) : undefined,
@@ -722,11 +613,7 @@ submissionRoutes.get(
])),
)
if (myself) filters.push(eq(schema.submission.userId, myself.id))
if (
result !== undefined &&
result !== "" &&
Number.isInteger(Number(result))
)
if (result !== undefined && result !== "" && Number.isInteger(Number(result)))
filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (contestStatus(contest) !== "1")
filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
@@ -738,10 +625,7 @@ submissionRoutes.get(
db
.select(submissionListColumns)
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
.orderBy(desc(schema.submission.createTime))
@@ -759,9 +643,7 @@ submissionRoutes.get(
id: submission.id,
problemDisplayId: problem.displayId,
problemTitle: problem.title,
showLink: user
? canViewSubmission(user, submission, problem, contest)
: false,
showLink: user ? canViewSubmission(user, submission, problem, contest) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,