54 处 handler 都手写同一段: const parsed = X.safeParse(await c.req.json().catch(() => null)) if (!parsed.success) return failure(c, 400, "invalid-request", ...) 漏写 .catch 就是一个畸形请求打出 500。 - http.ts 加 readJson(读 + 校验)和 parseBody(再加上 400)。parseBody 不给 message 时回 zod 的第一条 issue —— 后台表单原来就是这么写的,fallback 文案 「Invalid payload」/「参数错误」只在 issues 为空时才用得上,实际走不到 - 51 处换成 parseBody;另 3 处失败时带别的错误码或和路径参数一起判,换成 readJson - 错误码、状态码、文案、判断顺序都不变 实跑核过:非 JSON 请求体回原来的固定文案,公告标题为空回 zod issue。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
658 lines
22 KiB
TypeScript
658 lines
22 KiB
TypeScript
import {
|
||
addProblemToSetRequestSchema,
|
||
createProblemSetBadgeRequestSchema,
|
||
createProblemSetRequestSchema,
|
||
updateProblemInSetRequestSchema,
|
||
updateProblemSetBadgeRequestSchema,
|
||
updateProblemSetRequestSchema,
|
||
updateProblemSetStatusRequestSchema,
|
||
type AdminProblemSet,
|
||
type AdminProblemSetBadge,
|
||
type AdminProblemSetList,
|
||
type AdminProblemSetProblem,
|
||
type AdminProblemSetProgress,
|
||
} from "@oj2/contract"
|
||
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"
|
||
import type { AuthUser } from "../../auth/session"
|
||
import { db, schema } from "../../db"
|
||
import { failure, parseBody, success } from "../../http"
|
||
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
||
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
|
||
|
||
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||
|
||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||
|
||
/** 对齐旧 ensure_created_by:超管放行,其余人只能碰自己建的。越权报「不存在」 */
|
||
function ownedBy(user: AuthUser, row: { createdById: number }) {
|
||
return user.adminType === "Super Admin" || row.createdById === user.id
|
||
}
|
||
|
||
/**
|
||
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
||
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
|
||
*/
|
||
async function loadOwned(
|
||
c: { req: { param(name: string): string } },
|
||
user: AuthUser,
|
||
) {
|
||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const [row] = await db
|
||
.select()
|
||
.from(schema.problemset)
|
||
.where(eq(schema.problemset.id, id))
|
||
.limit(1)
|
||
return row && ownedBy(user, row) ? row : null
|
||
}
|
||
|
||
async function serialize(row: typeof schema.problemset.$inferSelect) {
|
||
return (await serializeMany([row]))[0]!
|
||
}
|
||
|
||
/** 批量版:列表接口走这个,固定 3 条查询,与行数无关(按行 serialize 就是 N+1) */
|
||
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||
if (rows.length === 0) return []
|
||
const ids = rows.map((row) => row.id)
|
||
const [problems, participants, creators] = await Promise.all([
|
||
db
|
||
.select({
|
||
problemsetId: schema.problemsetProblem.problemsetId,
|
||
value: count(),
|
||
})
|
||
.from(schema.problemsetProblem)
|
||
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||
.groupBy(schema.problemsetProblem.problemsetId),
|
||
db
|
||
.select({
|
||
problemsetId: schema.problemsetProgress.problemsetId,
|
||
value: count(),
|
||
})
|
||
.from(schema.problemsetProgress)
|
||
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||
.groupBy(schema.problemsetProgress.problemsetId),
|
||
db
|
||
.select({
|
||
id: schema.user.id,
|
||
username: schema.user.username,
|
||
realName: schema.userProfile.realName,
|
||
})
|
||
.from(schema.user)
|
||
.leftJoin(
|
||
schema.userProfile,
|
||
eq(schema.userProfile.userId, schema.user.id),
|
||
)
|
||
.where(
|
||
inArray(schema.user.id, [
|
||
...new Set(rows.map((row) => row.createdById)),
|
||
]),
|
||
),
|
||
])
|
||
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)
|
||
return {
|
||
id: row.id,
|
||
title: row.title,
|
||
description: row.description,
|
||
difficulty: row.difficulty,
|
||
status: row.status,
|
||
endTime: row.endTime,
|
||
visible: row.visible,
|
||
createdBy: sampleUser(
|
||
creator ?? { id: row.createdById, username: "" },
|
||
creator?.realName,
|
||
),
|
||
createTime: row.createTime,
|
||
lastUpdateTime: row.lastUpdateTime,
|
||
problemsCount: problemsBySet.get(row.id) ?? 0,
|
||
participantCount: participantsBySet.get(row.id) ?? 0,
|
||
} satisfies AdminProblemSet
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 题单本体
|
||
|
||
adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||
const user = c.get("user")!
|
||
const filters = []
|
||
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
|
||
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失,
|
||
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
||
if (user.adminType !== "Super Admin")
|
||
filters.push(eq(schema.problemset.createdById, user.id))
|
||
const keyword = c.req.query("keyword")?.trim()
|
||
const difficulty = c.req.query("difficulty")?.trim()
|
||
const status = c.req.query("status")?.trim()
|
||
if (keyword) {
|
||
filters.push(
|
||
or(
|
||
ilike(schema.problemset.title, `%${keyword}%`),
|
||
ilike(schema.problemset.description, `%${keyword}%`),
|
||
)!,
|
||
)
|
||
}
|
||
if (difficulty)
|
||
filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
|
||
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
|
||
const where = filters.length ? and(...filters) : undefined
|
||
|
||
const [totalRows, rows] = await Promise.all([
|
||
db.select({ value: count() }).from(schema.problemset).where(where),
|
||
db
|
||
.select()
|
||
.from(schema.problemset)
|
||
.where(where)
|
||
.orderBy(desc(schema.problemset.createTime))
|
||
.limit(limit)
|
||
.offset(offset),
|
||
])
|
||
return success(c, {
|
||
results: await serializeMany(rows),
|
||
total: totalRows[0]?.value ?? 0,
|
||
} satisfies AdminProblemSetList)
|
||
})
|
||
|
||
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
||
const parsed = await parseBody(c, createProblemSetRequestSchema)
|
||
if (!parsed.success) return parsed.response
|
||
const now = new Date().toISOString()
|
||
const [created] = await db
|
||
.insert(schema.problemset)
|
||
.values({
|
||
...parsed.data,
|
||
endTime: parsed.data.endTime
|
||
? new Date(parsed.data.endTime).toISOString()
|
||
: null,
|
||
createdById: c.get("user")!.id,
|
||
createTime: now,
|
||
lastUpdateTime: now,
|
||
})
|
||
.returning()
|
||
return success(c, await serialize(created!), 201)
|
||
})
|
||
|
||
adminProblemSetRoutes.get("/problem-sets/:id", requireTeacher, async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
return success(c, await serialize(row))
|
||
})
|
||
|
||
adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
||
if (!parsed.success) return parsed.response
|
||
const [updated] = await db
|
||
.update(schema.problemset)
|
||
.set({
|
||
...parsed.data,
|
||
endTime: parsed.data.endTime
|
||
? new Date(parsed.data.endTime).toISOString()
|
||
: null,
|
||
lastUpdateTime: new Date().toISOString(),
|
||
})
|
||
.where(eq(schema.problemset.id, row.id))
|
||
.returning()
|
||
return success(c, await serialize(updated!))
|
||
})
|
||
|
||
adminProblemSetRoutes.put(
|
||
"/problem-sets/:id/visibility",
|
||
requireTeacher,
|
||
async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
|
||
const [updated] = await db
|
||
.update(schema.problemset)
|
||
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
|
||
.where(eq(schema.problemset.id, row.id))
|
||
.returning()
|
||
return success(c, await serialize(updated!))
|
||
},
|
||
)
|
||
|
||
adminProblemSetRoutes.put(
|
||
"/problem-sets/:id/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")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
// 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐,
|
||
// user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
|
||
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
|
||
return success(c, null)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 题单里的题目
|
||
|
||
adminProblemSetRoutes.get(
|
||
"/problem-sets/:id/problems",
|
||
requireTeacher,
|
||
async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
const rows = await db
|
||
.select({ item: schema.problemsetProblem, problem: schema.problem })
|
||
.from(schema.problemsetProblem)
|
||
.innerJoin(
|
||
schema.problem,
|
||
eq(schema.problemsetProblem.problemId, schema.problem.id),
|
||
)
|
||
.where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||
.orderBy(
|
||
asc(schema.problemsetProblem.order),
|
||
asc(schema.problemsetProblem.id),
|
||
)
|
||
return success(
|
||
c,
|
||
rows.map(
|
||
({ item, problem }) =>
|
||
({
|
||
id: item.id,
|
||
problemsetId: item.problemsetId,
|
||
problemId: item.problemId,
|
||
displayId: problem.displayId,
|
||
title: problem.title,
|
||
difficulty: problem.difficulty,
|
||
order: item.order,
|
||
isRequired: item.isRequired,
|
||
score: item.score,
|
||
hint: item.hint,
|
||
}) satisfies AdminProblemSetProblem,
|
||
),
|
||
)
|
||
},
|
||
)
|
||
|
||
adminProblemSetRoutes.post(
|
||
"/problem-sets/:id/problems",
|
||
requireTeacher,
|
||
async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
const parsed = 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,
|
||
"参数错误",
|
||
)
|
||
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)
|
||
},
|
||
)
|
||
|
||
// ---------------------------------------------------------------- 奖章
|
||
|
||
async function badgeWithCount(badge: BadgeRow) {
|
||
return (await badgesWithCount([badge]))[0]!
|
||
}
|
||
|
||
/** 批量版:一条 group by 数完整批奖章的获得人数 */
|
||
async function badgesWithCount(badges: BadgeRow[]) {
|
||
if (badges.length === 0) return []
|
||
const earned = await db
|
||
.select({ badgeId: schema.userBadge.badgeId, value: count() })
|
||
.from(schema.userBadge)
|
||
.where(
|
||
inArray(
|
||
schema.userBadge.badgeId,
|
||
badges.map((badge) => badge.id),
|
||
),
|
||
)
|
||
.groupBy(schema.userBadge.badgeId)
|
||
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
|
||
return badges.map(
|
||
(badge) =>
|
||
({
|
||
id: badge.id,
|
||
problemsetId: badge.problemsetId,
|
||
name: badge.name,
|
||
description: badge.description,
|
||
icon: badge.icon,
|
||
conditionType: badge.conditionType,
|
||
conditionValue: badge.conditionValue,
|
||
earnedCount: countByBadge.get(badge.id) ?? 0,
|
||
}) satisfies AdminProblemSetBadge,
|
||
)
|
||
}
|
||
|
||
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.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.get(
|
||
"/problem-sets/:id/progress",
|
||
requireTeacher,
|
||
async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
const rows = await db
|
||
.select({
|
||
progress: schema.problemsetProgress,
|
||
username: schema.user.username,
|
||
realName: schema.userProfile.realName,
|
||
})
|
||
.from(schema.problemsetProgress)
|
||
.innerJoin(
|
||
schema.user,
|
||
eq(schema.problemsetProgress.userId, schema.user.id),
|
||
)
|
||
.leftJoin(
|
||
schema.userProfile,
|
||
eq(schema.userProfile.userId, schema.user.id),
|
||
)
|
||
.where(eq(schema.problemsetProgress.problemsetId, row.id))
|
||
.orderBy(desc(schema.problemsetProgress.joinTime))
|
||
return success(
|
||
c,
|
||
rows.map(
|
||
({ progress, username, realName }) =>
|
||
({
|
||
id: progress.id,
|
||
userId: progress.userId,
|
||
username,
|
||
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
|
||
realName,
|
||
joinTime: progress.joinTime,
|
||
completeTime: progress.completeTime,
|
||
isCompleted: progress.isCompleted,
|
||
progressPercentage: progress.progressPercentage,
|
||
completedProblemsCount: progress.completedProblemsCount,
|
||
totalProblemsCount: progress.totalProblemsCount,
|
||
totalScore: progress.totalScore,
|
||
}) satisfies AdminProblemSetProgress,
|
||
),
|
||
)
|
||
},
|
||
)
|
||
|
||
adminProblemSetRoutes.delete(
|
||
"/problem-sets/:id/progress/:userId",
|
||
requireTeacher,
|
||
async (c) => {
|
||
const row = await loadOwned(c, c.get("user")!)
|
||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
|
||
const deleted = await db.transaction(async (tx) => {
|
||
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
|
||
const badges = await tx
|
||
.select({ id: schema.problemsetBadge.id })
|
||
.from(schema.problemsetBadge)
|
||
.where(eq(schema.problemsetBadge.problemsetId, row.id))
|
||
if (badges.length) {
|
||
await tx.delete(schema.userBadge).where(
|
||
and(
|
||
eq(schema.userBadge.userId, userId),
|
||
inArray(
|
||
schema.userBadge.badgeId,
|
||
badges.map((badge) => badge.id),
|
||
),
|
||
),
|
||
)
|
||
}
|
||
await tx
|
||
.delete(schema.problemsetSubmission)
|
||
.where(
|
||
and(
|
||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||
eq(schema.problemsetSubmission.userId, userId),
|
||
),
|
||
)
|
||
return tx
|
||
.delete(schema.problemsetProgress)
|
||
.where(
|
||
and(
|
||
eq(schema.problemsetProgress.problemsetId, row.id),
|
||
eq(schema.problemsetProgress.userId, userId),
|
||
),
|
||
)
|
||
.returning({ id: schema.problemsetProgress.id })
|
||
})
|
||
if (deleted.length === 0)
|
||
return failure(c, 404, "progress-not-found", "用户未加入该题单")
|
||
return success(c, null)
|
||
},
|
||
)
|