From 5e0229db123795584c583b5a8b02212066c952ef Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 7 Aug 2026 16:25:14 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=B6=E6=AE=B54):=20=E9=A2=98=E5=8D=95?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=88=E9=A2=98=E7=9B=AE=20/=20=E5=A5=96?= =?UTF-8?q?=E7=AB=A0=20/=20=E8=BF=9B=E5=BA=A6=E4=B8=89=E5=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/routes/admin/index.ts | 2 + apps/api/src/routes/admin/problemset.ts | 461 ++++++++++++++++++++++++ apps/web/src/admin/api.ts | 107 ++++-- packages/contract/src/admin.ts | 101 ++++++ 4 files changed, 643 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/routes/admin/problemset.ts diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 232a90b..ae793bd 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -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) diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts new file mode 100644 index 0000000..188ff19 --- /dev/null +++ b/apps/api/src/routes/admin/problemset.ts @@ -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() + +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) +}) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 2728e9d..1745df4 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -478,19 +478,34 @@ export function getProblemSetList( difficulty = "", status = "", ) { - return http.get("admin/problemset", { - params: { - offset, - limit, - keyword, - difficulty, - status, - }, - }) + return legacyResponse( + api2.get("admin/problem-sets", { + params: { offset, limit, keyword, difficulty, status }, + }), + ) } export function getProblemSetDetail(id: number) { - return http.get(`admin/problemset/${id}`) + return legacyResponse(api2.get(`admin/problem-sets/${id}`)) +} + +/** 组件传的是 snake_case,出站转成新后端要的 camelCase */ +function toProblemSetBody(data: { + title?: string + description?: string + difficulty?: string + status?: string + end_time?: Date | null + visible?: boolean +}) { + return { + title: data.title, + description: data.description ?? "", + difficulty: data.difficulty ?? "Easy", + status: data.status ?? "active", + endTime: data.end_time ? new Date(data.end_time).toISOString() : null, + visible: data.visible ?? true, + } } export function createProblemSet(data: { @@ -500,7 +515,7 @@ export function createProblemSet(data: { status: string end_time?: Date | null }) { - return http.post("admin/problemset", data) + return legacyResponse(api2.post("admin/problem-sets", toProblemSetBody(data))) } export function editProblemSet(data: { @@ -512,24 +527,26 @@ export function editProblemSet(data: { end_time?: Date | null visible?: boolean }) { - return http.put("admin/problemset", data) + return legacyResponse( + api2.put(`admin/problem-sets/${data.id}`, toProblemSetBody(data)), + ) } export function deleteProblemSet(id: number) { - return http.delete("admin/problemset", { params: { id } }) + return api2.delete(`admin/problem-sets/${id}`) } export function toggleProblemSetVisible(id: number) { - return http.put("admin/problemset/visible", { id }) + return legacyResponse(api2.put(`admin/problem-sets/${id}/visibility`)) } export function updateProblemSetStatus(id: number, status: string) { - return http.put("admin/problemset/status", { id, status }) + return legacyResponse(api2.put(`admin/problem-sets/${id}/status`, { status })) } // 题单题目管理 API export function getProblemSetProblems(problemSetId: number) { - return http.get(`admin/problemset/${problemSetId}/problems`) + return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/problems`)) } export function addProblemToSet( @@ -542,7 +559,13 @@ export function addProblemToSet( hint?: string }, ) { - return http.post(`admin/problemset/${problemSetId}/problems`, data) + return api2.post(`admin/problem-sets/${problemSetId}/problems`, { + problemId: data.problem_id, + order: data.order ?? 0, + isRequired: data.is_required ?? true, + score: data.score ?? 0, + hint: data.hint ?? "", + }) } export function editProblemInSet( @@ -555,9 +578,14 @@ export function editProblemInSet( hint?: string }, ) { - return http.put( - `admin/problemset/${problemSetId}/problems/${problemSetProblemId}`, - data, + return api2.put( + `admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, + { + order: data.order, + isRequired: data.is_required, + score: data.score, + hint: data.hint, + }, ) } @@ -565,14 +593,30 @@ export function removeProblemFromSet( problemSetId: number, problemSetProblemId: number, ) { - return http.delete( - `admin/problemset/${problemSetId}/problems/${problemSetProblemId}`, + return api2.delete( + `admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, ) } // 题单奖章管理 API export function getProblemSetBadges(problemSetId: number) { - return http.get(`admin/problemset/${problemSetId}/badges`) + return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/badges`)) +} + +function toBadgeBody(data: { + name?: string + description?: string + icon?: string + condition_type?: string + condition_value?: number +}) { + return { + name: data.name, + description: data.description ?? "", + icon: data.icon ?? "", + conditionType: data.condition_type, + conditionValue: data.condition_value ?? 0, + } } export function createProblemSetBadge( @@ -586,7 +630,9 @@ export function createProblemSetBadge( level?: number }, ) { - return http.post(`admin/problemset/${problemSetId}/badges`, data) + return legacyResponse( + api2.post(`admin/problem-sets/${problemSetId}/badges`, toBadgeBody(data)), + ) } export function editProblemSetBadge( @@ -601,20 +647,25 @@ export function editProblemSetBadge( level?: number }, ) { - return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data) + return legacyResponse( + api2.put( + `admin/problem-sets/${problemSetId}/badges/${badgeId}`, + toBadgeBody(data), + ), + ) } export function deleteProblemSetBadge(problemSetId: number, badgeId: number) { - return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`) + return api2.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`) } // 题单进度管理 API export function getProblemSetProgress(problemSetId: number) { - return http.get(`admin/problemset/${problemSetId}/progress`) + return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/progress`)) } export function removeUserFromProblemSet(problemSetId: number, userId: number) { - return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`) + return api2.delete(`admin/problem-sets/${problemSetId}/progress/${userId}`) } // 学生卡点分析 diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 64de024..dc6a446 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -332,3 +332,104 @@ export const updateAcmHelperRequestSchema = z.object({ problemId: z.string().min(1), checked: z.boolean(), }) + +// ---------------------------------------------------------------- 题单管理 + +export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"]) +export const problemSetStatusSchema = z.enum(["draft", "active", "archived"]) +export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"]) + +export const adminProblemSetSchema = z.object({ + id: z.number().int(), + title: z.string(), + description: z.string(), + difficulty: problemSetDifficultySchema, + status: problemSetStatusSchema, + endTime: z.string().nullable(), + visible: z.boolean(), + createdBy: sampleUserSchema, + createTime: z.string(), + lastUpdateTime: z.string(), + problemsCount: z.number().int(), + /** 加入这份题单的人数,后台用来判断改动会影响多少人 */ + participantCount: z.number().int(), +}) + +export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema) + +export const createProblemSetRequestSchema = z.object({ + title: z.string().trim().min(1).max(200), + description: z.string(), + difficulty: problemSetDifficultySchema.default("Easy"), + status: problemSetStatusSchema.default("active"), + endTime: z.string().nullable().default(null), + visible: z.boolean().default(true), +}) + +export const updateProblemSetRequestSchema = createProblemSetRequestSchema +export const updateProblemSetStatusRequestSchema = z.object({ status: problemSetStatusSchema }) + +export const adminProblemSetProblemSchema = z.object({ + id: z.number().int(), + problemsetId: z.number().int(), + problemId: z.number().int(), + displayId: z.string(), + title: z.string(), + difficulty: z.string(), + order: z.number().int(), + isRequired: z.boolean(), + score: z.number().int(), + hint: z.string().nullable(), +}) + +export const addProblemToSetRequestSchema = z.object({ + /** 展示用题号(_id),不是自增主键 —— 老师手里只有题号 */ + problemId: z.string().trim().min(1), + order: z.number().int().default(0), + isRequired: z.boolean().default(true), + score: z.number().int().default(0), + hint: z.string().default(""), +}) + +export const updateProblemInSetRequestSchema = z.object({ + order: z.number().int().optional(), + isRequired: z.boolean().optional(), + score: z.number().int().optional(), + hint: z.string().optional(), +}) + +export const adminProblemSetBadgeSchema = z.object({ + id: z.number().int(), + problemsetId: z.number().int(), + name: z.string(), + description: z.string(), + icon: z.string(), + conditionType: badgeConditionTypeSchema, + conditionValue: z.number().int(), + /** 已获得该奖章的人数,后台改条件前要能看到影响面 */ + earnedCount: z.number().int(), +}) + +export const createProblemSetBadgeRequestSchema = z.object({ + name: z.string().trim().min(1).max(100), + description: z.string(), + icon: z.string(), + conditionType: badgeConditionTypeSchema, + conditionValue: z.number().int().default(0), +}) + +export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema + +export const adminProblemSetProgressSchema = z.object({ + id: z.number().int(), + userId: z.number().int(), + username: z.string(), + realName: z.string().nullable(), + joinTime: z.string(), + completeTime: z.string().nullable(), + isCompleted: z.boolean(), + progressPercentage: z.number(), + completedProblemsCount: z.number().int(), + totalProblemsCount: z.number().int(), + totalScore: z.number().int(), +})