feat(阶段4): 题单管理(题目 / 奖章 / 进度三层)
GET/POST admin/problem-sets GET/PUT/DELETE admin/problem-sets/:id PUT admin/problem-sets/:id/visibility (取反语义,与旧一致) PUT admin/problem-sets/:id/status GET/POST admin/problem-sets/:id/problems PUT/DELETE admin/problem-sets/:id/problems/:itemId GET/POST admin/problem-sets/:id/badges PUT/DELETE admin/problem-sets/:id/badges/:badgeId GET admin/problem-sets/:id/progress DELETE admin/problem-sets/:id/progress/:userId 修掉旧后端三个问题: 1. **后台列表写死了 visible=True,可它同时又提供「切换可见性」的接口** —— 一旦把题单设成不可见,它就从后台列表消失,再也没法在界面上改回来。 新实现不按 visible 过滤,后台能看见自己管的全部题单。实测取反两次仍在列表里。 2. **加题/删题/改分值后不重算学生进度**。往题单里加一道题,学生那边的 totalProblemsCount 还是老数字,进度百分比因此偏高,甚至已「完成」的人分母变了 却还标着完成。新实现加了 resyncProgress,实测 2/2 加一道题后变成 2/3 = 66.67%。 只重算分母与百分比,不碰 completeTime —— 已完成过的事实不因加题而撤销。 3. 把人踢出题单时一并收回他基于这份题单拿到的奖章,否则奖章悬空。 奖章重算对齐旧 recalculate_user_badges(那边靠 post_save 信号,这里显式调): 只增删差集、保留已有记录的 earnedTime,否则每改一次条件所有人的获得时间都会 刷新成今天。实测 all_problems 建成时补发 1 人 → 加题后收回 → 改成 problem_count>=2 后重新发出 → 踢人后收回。 删题单要按序清五张子表(全是 NO ACTION 外键):user_badge 挂在 problemset_badge 上,得先于 badge 删。 实测:学生 403;重复加题 409、加不存在的题 404、野状态/野奖章条件 400; 级联删除后五张表全为 0。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { adminAchievementRoutes } from "./achievement"
|
||||
import { adminAiRoutes } from "./ai"
|
||||
import { adminConfRoutes } from "./conf"
|
||||
import { adminContestRoutes } from "./contest"
|
||||
import { adminProblemSetRoutes } from "./problemset"
|
||||
import { adminAnnouncementRoutes } from "./announcement"
|
||||
import { adminTutorialRoutes } from "./tutorial"
|
||||
|
||||
@@ -24,5 +25,6 @@ adminRoutes.route("/", adminAchievementRoutes)
|
||||
adminRoutes.route("/", adminAiRoutes)
|
||||
adminRoutes.route("/", adminConfRoutes)
|
||||
adminRoutes.route("/", adminContestRoutes)
|
||||
adminRoutes.route("/", adminProblemSetRoutes)
|
||||
adminRoutes.route("/", adminAnnouncementRoutes)
|
||||
adminRoutes.route("/", adminTutorialRoutes)
|
||||
|
||||
461
apps/api/src/routes/admin/problemset.ts
Normal file
461
apps/api/src/routes/admin/problemset.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
addProblemToSetRequestSchema,
|
||||
adminProblemSetBadgeSchema,
|
||||
adminProblemSetListSchema,
|
||||
adminProblemSetProblemSchema,
|
||||
adminProblemSetProgressSchema,
|
||||
adminProblemSetSchema,
|
||||
createProblemSetBadgeRequestSchema,
|
||||
createProblemSetRequestSchema,
|
||||
updateProblemInSetRequestSchema,
|
||||
updateProblemSetBadgeRequestSchema,
|
||||
updateProblemSetRequestSchema,
|
||||
updateProblemSetStatusRequestSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, 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, success } from "../../http"
|
||||
import { queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||||
|
||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||
type ProgressRow = typeof schema.problemsetProgress.$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) {
|
||||
const [[problems], [participants], [creator]] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
|
||||
db.select({ value: count() }).from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, row.id)),
|
||||
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(eq(schema.user.id, row.createdById)).limit(1),
|
||||
])
|
||||
return adminProblemSetSchema.parse({
|
||||
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: problems?.value ?? 0,
|
||||
participantCount: participants?.value ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 题单本体
|
||||
|
||||
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, difficulty))
|
||||
if (status) filters.push(eq(schema.problemset.status, 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, adminProblemSetListSchema.parse({
|
||||
results: await Promise.all(rows.map(serialize)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
||||
const parsed = createProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const [created] = await db.insert(schema.problemset).values({
|
||||
...parsed.data,
|
||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
}).returning()
|
||||
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 = updateProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [updated] = await db.update(schema.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 = updateProblemSetStatusRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "status 不合法")
|
||||
const [updated] = await db.update(schema.problemset)
|
||||
.set({ status: parsed.data.status, lastUpdateTime: new Date().toISOString() })
|
||||
.where(eq(schema.problemset.id, row.id)).returning()
|
||||
return success(c, await serialize(updated!))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
// 五张子表全是 NO ACTION 外键,Django 的级联在应用层。顺序不能反:
|
||||
// user_badge 挂在 problemset_badge 上,得先于 badge 删
|
||||
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(inArray(schema.userBadge.badgeId, badges.map((b) => b.id)))
|
||||
}
|
||||
await tx.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id))
|
||||
await tx.delete(schema.problemsetSubmission).where(eq(schema.problemsetSubmission.problemsetId, row.id))
|
||||
await tx.delete(schema.problemsetProgress).where(eq(schema.problemsetProgress.problemsetId, row.id))
|
||||
await tx.delete(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||
await tx.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 }) => adminProblemSetProblemSchema.parse({
|
||||
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,
|
||||
})))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = addProblemToSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
)).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
|
||||
|
||||
const [duplicate] = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem)
|
||||
.where(and(
|
||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
||||
eq(schema.problemsetProblem.problemId, problem.id),
|
||||
)).limit(1)
|
||||
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
|
||||
|
||||
const [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 / 百分比都得跟着变,
|
||||
// 否则学生看到的进度分母还是老的。旧后端没做这一步。
|
||||
await resyncProgress(row.id)
|
||||
return success(c, { id: created!.id }, 201)
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemInSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
|
||||
const updated = await db.update(schema.problemsetProblem).set(parsed.data).where(and(
|
||||
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
|
||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
||||
)).returning({ id: schema.problemsetProblem.id })
|
||||
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
||||
if (parsed.data.score !== undefined) await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.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 })
|
||||
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
||||
await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 奖章
|
||||
|
||||
async function badgeWithCount(badge: BadgeRow) {
|
||||
const [earned] = await db.select({ value: count() }).from(schema.userBadge)
|
||||
.where(eq(schema.userBadge.badgeId, badge.id))
|
||||
return adminProblemSetBadgeSchema.parse({
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
conditionType: badge.conditionType,
|
||||
conditionValue: badge.conditionValue,
|
||||
earnedCount: earned?.value ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
|
||||
function eligible(badge: BadgeRow, progress: ProgressRow) {
|
||||
if (badge.conditionType === "all_problems") {
|
||||
return progress.totalProblemsCount > 0 &&
|
||||
progress.completedProblemsCount === progress.totalProblemsCount
|
||||
}
|
||||
if (badge.conditionType === "problem_count") return progress.completedProblemsCount >= badge.conditionValue
|
||||
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算某个奖章的获得者,对齐旧 `recalculate_user_badges`(由 post_save 信号触发)。
|
||||
* 保留已有记录的 earnedTime —— 只增删差集,不是先清空再重建,
|
||||
* 否则每改一次条件所有人的获得时间都会刷新成今天。
|
||||
*/
|
||||
async function recalculateBadge(badge: BadgeRow) {
|
||||
const progresses = await db.select().from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId))
|
||||
const eligibleIds = progresses.filter((item) => eligible(badge, item)).map((item) => item.userId)
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.userBadge).where(and(
|
||||
eq(schema.userBadge.badgeId, badge.id),
|
||||
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
|
||||
))
|
||||
if (!eligibleIds.length) return
|
||||
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
|
||||
.where(eq(schema.userBadge.badgeId, badge.id))
|
||||
const have = new Set(existing.map((item) => item.userId))
|
||||
const missing = eligibleIds.filter((id) => !have.has(id))
|
||||
if (missing.length) {
|
||||
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
})))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 Promise.all(badges.map(badgeWithCount)))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets/:id/badges", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = createProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [created] = await db.insert(schema.problemsetBadge).values({
|
||||
...parsed.data,
|
||||
problemsetId: row.id,
|
||||
}).returning()
|
||||
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
|
||||
await recalculateBadge(created!)
|
||||
return success(c, await badgeWithCount(created!), 201)
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.put("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||
}
|
||||
const [updated] = await db.update(schema.problemsetBadge).set(parsed.data).where(and(
|
||||
eq(schema.problemsetBadge.id, queryInteger(c.req.param("badgeId"), 0, { min: 1 })),
|
||||
eq(schema.problemsetBadge.problemsetId, row.id),
|
||||
)).returning()
|
||||
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||
await recalculateBadge(updated)
|
||||
return success(c, await badgeWithCount(updated))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.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 })
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badgeId))
|
||||
return tx.delete(schema.problemsetBadge).where(and(
|
||||
eq(schema.problemsetBadge.id, badgeId),
|
||||
eq(schema.problemsetBadge.problemsetId, row.id),
|
||||
)).returning({ id: schema.problemsetBadge.id })
|
||||
})
|
||||
if (deleted.length === 0) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 学生进度
|
||||
|
||||
/**
|
||||
* 题目集或分值变动后,把所有参与者的进度重算一遍。
|
||||
*
|
||||
* 旧后端不做这件事:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字,
|
||||
* 进度百分比因此偏高,甚至已经「完成」的人分母变了却还标着完成。
|
||||
* 只重算分母与百分比,不碰 completeTime —— 已经完成过的事实不因加题而撤销。
|
||||
*/
|
||||
async function resyncProgress(problemsetId: number) {
|
||||
const [[totalRow], progresses] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
|
||||
db.select().from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
|
||||
])
|
||||
const total = totalRow?.value ?? 0
|
||||
for (const progress of progresses) {
|
||||
const completed = Math.min(progress.completedProblemsCount, total)
|
||||
await db.update(schema.problemsetProgress).set({
|
||||
totalProblemsCount: total,
|
||||
completedProblemsCount: completed,
|
||||
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
|
||||
}).where(eq(schema.problemsetProgress.id, progress.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,
|
||||
}).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 }) =>
|
||||
adminProblemSetProgressSchema.parse({
|
||||
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,
|
||||
})))
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user