1 parent
23844ae301
commit
d25b5dbe6d
268 files changed
+3684
-8861
No files matched your search
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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[])
|
||||
})
|
||||
@@ -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", "生成失败,请稍后再试")
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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", "生成失败,请稍后再试")
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user