feat(题单): 「截止时间」改成「布置期」,布置期内没加入的人也藏旧代码;老师能看全班情况,后台一次加好几道题
- 布置期(列名仍是 end_time,机房共库不改名)+ 新列 assigned_at:布置期内题单里这几道题, 学生在布置开始之前交的代码先藏起来,不管加没加入,堵住「先抄再加入」;做对或到期放出来,不拦做题 - 「总分达到 N」奖章换成「做对 N 道」,草稿并进「不公开」(0026) - /problem-sets 带布置期、必做题数、奖章获得时间;题目带做对时刻、错几次、旧代码藏着没有 - 新增 /problem-sets/:id/class-view(老师看一个班:真名 × 每题,加入前就做对过)、/problem-sets/locks - 后台:批量加题、拖动排序、切必做;去掉状态、难度、分数、提示和单独的进度页 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2224478294
commit
8c53e28dcb
11 files changed
+4958
-617
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
-- 题单的「截止时间」改成「布置到」:它从来不截止任何东西,只管布置期内藏学生以前的代码(防抄),
|
||||||
|
-- 见 services/problemset.ts 的 problemSetLockCutoffs。列名 end_time 不改(机房和服务器共库,
|
||||||
|
-- 改名会让没升级的那边直接报错);旧值是按旧语义填的(3 个题单,都已过期),清掉。
|
||||||
|
ALTER TABLE "problemset" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
UPDATE "problemset" SET "end_time" = NULL;--> statement-breakpoint
|
||||||
|
-- 「状态」和「可见」两道闸合成一个「公开」:草稿等于不公开,归档等于公开(一直都还能进)
|
||||||
|
UPDATE "problemset" SET "visible" = false WHERE "status" = 'draft';--> statement-breakpoint
|
||||||
|
UPDATE "problemset" SET "status" = 'active' WHERE "status" <> 'active';--> statement-breakpoint
|
||||||
|
-- 分数拿掉了,「总分达到 N」的奖章换成「做对 N 道」(线上那几套题每题 10 分,门槛不变)
|
||||||
|
UPDATE "problemset_badge" SET "condition_type" = 'problem_count', "condition_value" = GREATEST(1, CEIL("condition_value" / 10.0)) WHERE "condition_type" = 'score';
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1791521256834,
|
"when": 1791521256834,
|
||||||
"tag": "0025_submission_problem_user_idx",
|
"tag": "0025_submission_problem_user_idx",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791553481647,
|
||||||
|
"tag": "0026_problemset_assignment",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,17 @@ export const problemset = pgTable(
|
|||||||
difficulty: text().notNull().$type<ProblemSetDifficulty>(),
|
difficulty: text().notNull().$type<ProblemSetDifficulty>(),
|
||||||
status: text().notNull().$type<ProblemSetStatus>(),
|
status: text().notNull().$type<ProblemSetStatus>(),
|
||||||
createdById: integer("created_by_id").notNull(),
|
createdById: integer("created_by_id").notNull(),
|
||||||
endTime: timestamp("end_time", { withTimezone: true, mode: "string" }),
|
/**
|
||||||
|
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
|
||||||
|
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
||||||
|
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
|
||||||
|
* 两个都为空 = 没布置。
|
||||||
|
*
|
||||||
|
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
|
||||||
|
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
|
||||||
|
*/
|
||||||
|
assignedUntil: timestamp("end_time", { withTimezone: true, mode: "string" }),
|
||||||
|
assignedAt: timestamp("assigned_at", { withTimezone: true, mode: "string" }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("problemset_created_by_id_01b5197f").using(
|
index("problemset_created_by_id_01b5197f").using(
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
addProblemToSetRequestSchema,
|
addProblemsToSetRequestSchema,
|
||||||
createProblemSetBadgeRequestSchema,
|
createProblemSetBadgeRequestSchema,
|
||||||
createProblemSetRequestSchema,
|
createProblemSetRequestSchema,
|
||||||
|
reorderProblemSetProblemsRequestSchema,
|
||||||
updateProblemInSetRequestSchema,
|
updateProblemInSetRequestSchema,
|
||||||
updateProblemSetBadgeRequestSchema,
|
updateProblemSetBadgeRequestSchema,
|
||||||
updateProblemSetRequestSchema,
|
updateProblemSetRequestSchema,
|
||||||
updateProblemSetStatusRequestSchema,
|
type AddProblemsToSetResult,
|
||||||
type AdminProblemSet,
|
type AdminProblemSet,
|
||||||
type AdminProblemSetBadge,
|
type AdminProblemSetBadge,
|
||||||
type AdminProblemSetList,
|
type AdminProblemSetList,
|
||||||
type AdminProblemSetProblem,
|
type AdminProblemSetProblem,
|
||||||
type AdminProblemSetProgress,
|
|
||||||
} from "@oj2/contract"
|
} 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, max, or, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||||
@@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session"
|
|||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, parseBody, success } from "../../http"
|
import { failure, parseBody, success } from "../../http"
|
||||||
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
||||||
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
|
import { queryInteger, sampleUser } from "../helpers"
|
||||||
|
|
||||||
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -49,11 +49,11 @@ async function serialize(row: typeof schema.problemset.$inferSelect) {
|
|||||||
return (await serializeMany([row]))[0]!
|
return (await serializeMany([row]))[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量版:列表接口走这个,固定 3 条查询,与行数无关(按行 serialize 就是 N+1) */
|
/** 批量版:列表接口走这个,固定 4 条查询,与行数无关(按行 serialize 就是 N+1) */
|
||||||
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||||||
if (rows.length === 0) return []
|
if (rows.length === 0) return []
|
||||||
const ids = rows.map((row) => row.id)
|
const ids = rows.map((row) => row.id)
|
||||||
const [problems, participants, creators] = await Promise.all([
|
const [problems, participants, badges, creators] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
problemsetId: schema.problemsetProblem.problemsetId,
|
problemsetId: schema.problemsetProblem.problemsetId,
|
||||||
@@ -66,10 +66,19 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
|||||||
.select({
|
.select({
|
||||||
problemsetId: schema.problemsetProgress.problemsetId,
|
problemsetId: schema.problemsetProgress.problemsetId,
|
||||||
value: count(),
|
value: count(),
|
||||||
|
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProgress)
|
||||||
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||||||
.groupBy(schema.problemsetProgress.problemsetId),
|
.groupBy(schema.problemsetProgress.problemsetId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
problemsetId: schema.problemsetBadge.problemsetId,
|
||||||
|
value: count(),
|
||||||
|
})
|
||||||
|
.from(schema.problemsetBadge)
|
||||||
|
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
||||||
|
.groupBy(schema.problemsetBadge.problemsetId),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: schema.user.id,
|
id: schema.user.id,
|
||||||
@@ -81,27 +90,57 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
|||||||
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
|
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
|
||||||
])
|
])
|
||||||
const problemsBySet = new Map(problems.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 participantsBySet = new Map(participants.map((item) => [item.problemsetId, item]))
|
||||||
|
const badgesBySet = new Map(badges.map((item) => [item.problemsetId, item.value]))
|
||||||
const creatorById = new Map(creators.map((item) => [item.id, item]))
|
const creatorById = new Map(creators.map((item) => [item.id, item]))
|
||||||
|
const now = Date.now()
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const creator = creatorById.get(row.createdById)
|
const creator = creatorById.get(row.createdById)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
difficulty: row.difficulty,
|
assignedUntil: row.assignedUntil,
|
||||||
status: row.status,
|
assignedAt: row.assignedAt,
|
||||||
endTime: row.endTime,
|
assigning:
|
||||||
|
row.assignedAt !== null &&
|
||||||
|
row.assignedUntil !== null &&
|
||||||
|
Date.parse(row.assignedUntil) > now,
|
||||||
visible: row.visible,
|
visible: row.visible,
|
||||||
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
|
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
|
||||||
createTime: row.createTime,
|
createTime: row.createTime,
|
||||||
lastUpdateTime: row.lastUpdateTime,
|
lastUpdateTime: row.lastUpdateTime,
|
||||||
problemsCount: problemsBySet.get(row.id) ?? 0,
|
problemsCount: problemsBySet.get(row.id) ?? 0,
|
||||||
participantCount: participantsBySet.get(row.id) ?? 0,
|
participantCount: participantsBySet.get(row.id)?.value ?? 0,
|
||||||
|
completedCount: participantsBySet.get(row.id)?.completed ?? 0,
|
||||||
|
badgeCount: badgesBySet.get(row.id) ?? 0,
|
||||||
} satisfies AdminProblemSet
|
} satisfies AdminProblemSet
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布置期怎么落库。老师给的是「布置到哪天」(东八区当天结束的时刻),开始时刻由服务端定:
|
||||||
|
* - 不布置 → 两个都清掉;
|
||||||
|
* - 现在正在布置 → 只改结束,开始不动(延长、缩短都不该把「以前的代码」的分界挪到现在);
|
||||||
|
* - 没在布置(从没布置过,或者上一轮已经过期)→ 开始记成现在,这就是「再布置一次」。
|
||||||
|
*/
|
||||||
|
function assignment(
|
||||||
|
requested: string | null,
|
||||||
|
current: { assignedAt: string | null; assignedUntil: string | null } | null,
|
||||||
|
) {
|
||||||
|
if (requested === null) return { assignedAt: null, assignedUntil: null }
|
||||||
|
const until = Date.parse(requested)
|
||||||
|
if (!Number.isFinite(until) || until <= Date.now()) return "past" as const
|
||||||
|
const running =
|
||||||
|
current?.assignedAt != null &&
|
||||||
|
current.assignedUntil != null &&
|
||||||
|
Date.parse(current.assignedUntil) > Date.now()
|
||||||
|
return {
|
||||||
|
assignedAt: running ? current.assignedAt : new Date().toISOString(),
|
||||||
|
assignedUntil: new Date(until).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 题单本体
|
// ---------------------------------------------------------------- 题单本体
|
||||||
|
|
||||||
adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||||||
@@ -114,8 +153,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
|||||||
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
||||||
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 keyword = c.req.query("keyword")?.trim()
|
||||||
const difficulty = c.req.query("difficulty")?.trim()
|
|
||||||
const status = c.req.query("status")?.trim()
|
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
filters.push(
|
filters.push(
|
||||||
or(
|
or(
|
||||||
@@ -124,8 +161,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
|||||||
)!,
|
)!,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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 where = filters.length ? and(...filters) : undefined
|
||||||
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
@@ -147,12 +182,19 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
|||||||
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
||||||
const parsed = await parseBody(c, createProblemSetRequestSchema)
|
const parsed = await parseBody(c, createProblemSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
|
const period = assignment(parsed.data.assignedUntil, null)
|
||||||
|
if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了")
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(schema.problemset)
|
.insert(schema.problemset)
|
||||||
.values({
|
.values({
|
||||||
...parsed.data,
|
title: parsed.data.title,
|
||||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
description: parsed.data.description,
|
||||||
|
visible: parsed.data.visible,
|
||||||
|
...period,
|
||||||
|
// 难度、状态两列还在表里、非空,但已经不用了(见契约 admin.ts 的说明)
|
||||||
|
difficulty: "Easy",
|
||||||
|
status: "active",
|
||||||
createdById: c.get("user")!.id,
|
createdById: c.get("user")!.id,
|
||||||
createTime: now,
|
createTime: now,
|
||||||
lastUpdateTime: now,
|
lastUpdateTime: now,
|
||||||
@@ -172,11 +214,15 @@ adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
|
|||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
|
const period = assignment(parsed.data.assignedUntil, row)
|
||||||
|
if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了")
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(schema.problemset)
|
.update(schema.problemset)
|
||||||
.set({
|
.set({
|
||||||
...parsed.data,
|
title: parsed.data.title,
|
||||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
description: parsed.data.description,
|
||||||
|
visible: parsed.data.visible,
|
||||||
|
...period,
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.problemset.id, row.id))
|
.where(eq(schema.problemset.id, row.id))
|
||||||
@@ -196,22 +242,6 @@ adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async
|
|||||||
return success(c, await serialize(updated!))
|
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) => {
|
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
const row = await loadOwned(c, c.get("user")!)
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
@@ -245,59 +275,97 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
|
|||||||
difficulty: problem.difficulty,
|
difficulty: problem.difficulty,
|
||||||
order: item.order,
|
order: item.order,
|
||||||
isRequired: item.isRequired,
|
isRequired: item.isRequired,
|
||||||
score: item.score,
|
|
||||||
hint: item.hint,
|
|
||||||
}) satisfies AdminProblemSetProblem,
|
}) satisfies AdminProblemSetProblem,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加题:一次可以给好几个题号(后台搜索框里粘一串「3065 3066 3067」),按给的顺序接在最后。
|
||||||
|
* 找不到的、本来就在里面的不报错,分开报回去,前端告诉老师哪几个没加上。
|
||||||
|
*/
|
||||||
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
const row = await loadOwned(c, c.get("user")!)
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const parsed = await parseBody(c, addProblemToSetRequestSchema)
|
const parsed = await parseBody(c, addProblemsToSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const [problem] = await db
|
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
|
||||||
.select({ id: schema.problem.id })
|
const [problems, existing, last] = await Promise.all([
|
||||||
.from(schema.problem)
|
db
|
||||||
.where(
|
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||||
and(
|
.from(schema.problem)
|
||||||
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
|
.where(
|
||||||
eq(schema.problem.visible, true),
|
and(
|
||||||
isNull(schema.problem.contestId),
|
inArray(sql<string>`lower(${schema.problem.displayId})`, wanted),
|
||||||
|
eq(schema.problem.visible, true),
|
||||||
|
isNull(schema.problem.contestId),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
db
|
||||||
.limit(1)
|
.select({ problemId: schema.problemsetProblem.problemId })
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
|
.from(schema.problemsetProblem)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
|
||||||
|
db
|
||||||
|
.select({ value: max(schema.problemsetProblem.order) })
|
||||||
|
.from(schema.problemsetProblem)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
|
||||||
|
])
|
||||||
|
const byDisplayId = new Map(problems.map((problem) => [problem.displayId.toLowerCase(), problem]))
|
||||||
|
const inSet = new Set(existing.map((item) => item.problemId))
|
||||||
|
const result: AddProblemsToSetResult = { added: [], missing: [], duplicate: [] }
|
||||||
|
const values: (typeof schema.problemsetProblem.$inferInsert)[] = []
|
||||||
|
let order = last[0]?.value ?? 0
|
||||||
|
for (const id of wanted) {
|
||||||
|
const problem = byDisplayId.get(id)
|
||||||
|
if (!problem) result.missing.push(id)
|
||||||
|
else if (inSet.has(problem.id)) result.duplicate.push(problem.displayId)
|
||||||
|
else {
|
||||||
|
inSet.add(problem.id)
|
||||||
|
order += 1
|
||||||
|
// 分数、提示两列还在表里但已经不用了:score 非空给 0
|
||||||
|
values.push({
|
||||||
|
problemsetId: row.id,
|
||||||
|
problemId: problem.id,
|
||||||
|
order,
|
||||||
|
isRequired: true,
|
||||||
|
score: 0,
|
||||||
|
})
|
||||||
|
result.added.push(problem.displayId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (values.length) {
|
||||||
|
await db.insert(schema.problemsetProblem).values(values)
|
||||||
|
// 题目集变了,已加入的人的分母、完成状态、奖章都得跟着变(旧栈靠 post_save 信号,
|
||||||
|
// 不在 views 里,别因为翻不到显式调用就以为它没做,见 services/problemset.ts)
|
||||||
|
await resyncProgress(row.id)
|
||||||
|
}
|
||||||
|
return success(c, result, values.length ? 201 : 200)
|
||||||
|
})
|
||||||
|
|
||||||
const [duplicate] = await db
|
// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
|
||||||
|
adminProblemSetRoutes.put("/problem-sets/:id/problems/order", 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, reorderProblemSetProblemsRequestSchema)
|
||||||
|
if (!parsed.success) return parsed.response
|
||||||
|
const links = await db
|
||||||
.select({ id: schema.problemsetProblem.id })
|
.select({ id: schema.problemsetProblem.id })
|
||||||
.from(schema.problemsetProblem)
|
.from(schema.problemsetProblem)
|
||||||
.where(
|
.where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||||
and(
|
const have = new Set(links.map((link) => link.id))
|
||||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
const given = new Set(parsed.data.ids)
|
||||||
eq(schema.problemsetProblem.problemId, problem.id),
|
if (given.size !== have.size || [...given].some((id) => !have.has(id))) {
|
||||||
),
|
return failure(c, 409, "problems-changed", "题目列表变了,刷新一下再排")
|
||||||
)
|
}
|
||||||
.limit(1)
|
await db.transaction(async (tx) => {
|
||||||
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
|
for (const [index, id] of parsed.data.ids.entries()) {
|
||||||
|
await tx
|
||||||
const [created] = await db
|
.update(schema.problemsetProblem)
|
||||||
.insert(schema.problemsetProblem)
|
.set({ order: index + 1 })
|
||||||
.values({
|
.where(eq(schema.problemsetProblem.id, id))
|
||||||
problemsetId: row.id,
|
}
|
||||||
problemId: problem.id,
|
})
|
||||||
order: parsed.data.order,
|
return success(c, null)
|
||||||
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) => {
|
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
|
||||||
@@ -316,7 +384,8 @@ adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher,
|
|||||||
)
|
)
|
||||||
.returning({ id: schema.problemsetProblem.id })
|
.returning({ id: schema.problemsetProblem.id })
|
||||||
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
||||||
if (parsed.data.score !== undefined) await resyncProgress(row.id)
|
// 必做 / 选做变了,分母和「做完」跟着变
|
||||||
|
await resyncProgress(row.id)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -453,84 +522,3 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
|
|||||||
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
|
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
|
||||||
return success(c, null)
|
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)
|
|
||||||
})
|
|
||||||
@@ -23,7 +23,7 @@ import { db, schema } from "../db"
|
|||||||
import { failure, readJson, success } from "../http"
|
import { failure, readJson, success } from "../http"
|
||||||
import { flowchartQueue } from "../queue"
|
import { flowchartQueue } from "../queue"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { problemSetJoinTimes } from "../services/problemset"
|
import { problemSetLockCutoffs } from "../services/problemset"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { buildWordFrequencies } from "../services/word-frequency"
|
import { buildWordFrequencies } from "../services/word-frequency"
|
||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
@@ -49,36 +49,36 @@ function flowchartThrottleKey(userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸
|
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
||||||
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图
|
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
|
||||||
* 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
||||||
*/
|
*/
|
||||||
function hiddenByProblemSet(
|
function hiddenByProblemSet(
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
joinTimes: Map<number, string>,
|
cutoffs: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (row.userId !== user.id || isTeacherOrAbove(user)) return false
|
if (row.userId !== user.id || isTeacherOrAbove(user)) return false
|
||||||
const joinTime = joinTimes.get(row.problemId)
|
const cutoff = cutoffs.get(row.problemId)
|
||||||
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
|
return cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canView(
|
function canView(
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
problem: { createdById: number },
|
problem: { createdById: number },
|
||||||
joinTimes: Map<number, string>,
|
cutoffs: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (hiddenByProblemSet(user, row, joinTimes)) return false
|
if (hiddenByProblemSet(user, row, cutoffs)) return false
|
||||||
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
|
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
|
||||||
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
||||||
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||||||
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
||||||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||||||
return problemSetJoinTimes(user.id, [...new Set(own)])
|
return problemSetLockCutoffs(user.id, [...new Set(own)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function flowchartData(
|
function flowchartData(
|
||||||
@@ -321,7 +321,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
|||||||
.offset(offset),
|
.offset(offset),
|
||||||
displayId ? unknownDisplayIds(displayId, null) : [],
|
displayId ? unknownDisplayIds(displayId, null) : [],
|
||||||
])
|
])
|
||||||
const joinTimes = await joinTimesFor(
|
const cutoffs = await cutoffsFor(
|
||||||
user,
|
user,
|
||||||
rows.map((row) => row.flowchart),
|
rows.map((row) => row.flowchart),
|
||||||
)
|
)
|
||||||
@@ -341,7 +341,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
|||||||
aiModel: flowchart.aiModel,
|
aiModel: flowchart.aiModel,
|
||||||
processingTime: flowchart.processingTime,
|
processingTime: flowchart.processingTime,
|
||||||
evaluationTime: flowchart.evaluationTime,
|
evaluationTime: flowchart.evaluationTime,
|
||||||
showLink: canView(user, flowchart, problem, joinTimes),
|
showLink: canView(user, flowchart, problem, cutoffs),
|
||||||
}) satisfies FlowchartListItem,
|
}) satisfies FlowchartListItem,
|
||||||
),
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
@@ -656,7 +656,7 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
|||||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||||
return success(c, flowchartData(row.flowchart, row.username))
|
return success(c, flowchartData(row.flowchart, row.username))
|
||||||
})
|
})
|
||||||
@@ -669,7 +669,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
|||||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||||
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
|
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
|
||||||
// 评分再抽一次,别人的提交只有老师能动
|
// 评分再抽一次,别人的提交只有老师能动
|
||||||
@@ -737,7 +737,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
|||||||
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
||||||
*/
|
*/
|
||||||
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||||
const [rows, joinTimes] = await Promise.all([
|
const [rows, cutoffs] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: schema.flowchartSubmission.id,
|
id: schema.flowchartSubmission.id,
|
||||||
@@ -756,9 +756,9 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
||||||
joinTimesFor(user, [{ userId: user.id, problemId }]),
|
cutoffsFor(user, [{ userId: user.id, problemId }]),
|
||||||
])
|
])
|
||||||
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes))
|
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, cutoffs))
|
||||||
return { visible, hidden: rows.length - visible.length }
|
return { visible, hidden: rows.length - visible.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+417
-308
@@ -1,44 +1,83 @@
|
|||||||
import {
|
import {
|
||||||
joinProblemSetRequestSchema,
|
joinProblemSetRequestSchema,
|
||||||
|
STUDENT_ROLES,
|
||||||
type ProblemSet,
|
type ProblemSet,
|
||||||
type ProblemSetBadge,
|
type ProblemSetBadge,
|
||||||
|
type ProblemSetClassView,
|
||||||
type ProblemSetList,
|
type ProblemSetList,
|
||||||
|
type ProblemSetLock,
|
||||||
type ProblemSetProblem,
|
type ProblemSetProblem,
|
||||||
type ProblemSetProgress,
|
|
||||||
type ProblemSetProgressList,
|
|
||||||
type UserBadge,
|
type UserBadge,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, avg, count, desc, eq, gt, ilike, inArray, ne, or, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
|
import type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
import { computeProgress } from "../services/problemset"
|
import { JudgeStatus, NON_FAILURE_RESULTS } from "../judge/status"
|
||||||
import { asFilterValue, asRecord, classPrefixCondition, queryInteger, sampleUser } from "./helpers"
|
import {
|
||||||
|
assigningSql,
|
||||||
|
computeProgress,
|
||||||
|
problemSetLockCutoffs,
|
||||||
|
problemSetLocksFor,
|
||||||
|
} from "../services/problemset"
|
||||||
|
import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const problemsetRoutes = new Hono<AppEnv>()
|
export const problemsetRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
||||||
|
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
||||||
|
|
||||||
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
|
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
|
||||||
|
/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */
|
||||||
|
function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
|
||||||
|
const required = links.filter((link) => link.isRequired)
|
||||||
|
return new Set((required.length ? required : links).map((link) => link.problemId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressSummary(progress: ProgressRow | undefined) {
|
||||||
return progress
|
return progress
|
||||||
? {
|
? {
|
||||||
isJoined: true,
|
isJoined: true,
|
||||||
progressPercentage: progress.progressPercentage,
|
joinTime: progress.joinTime,
|
||||||
completedCount: progress.completedProblemsCount,
|
completedCount: progress.completedProblemsCount,
|
||||||
totalCount: progress.totalProblemsCount,
|
totalCount: progress.totalProblemsCount,
|
||||||
|
solvedCount: Object.keys(asRecord(progress.progressDetail)).length,
|
||||||
isCompleted: progress.isCompleted,
|
isCompleted: progress.isCompleted,
|
||||||
|
completeTime: progress.completeTime,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
isJoined: false,
|
isJoined: false,
|
||||||
progressPercentage: 0,
|
joinTime: null,
|
||||||
completedCount: 0,
|
completedCount: 0,
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
|
solvedCount: 0,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
|
completeTime: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
||||||
|
* 看到的样子、看全班情况,不该被 404 挡在外面。
|
||||||
|
*/
|
||||||
|
async function loadVisible(id: number, user: AuthUser | null | undefined) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ set: schema.problemset, assigning: assigningSql })
|
||||||
|
.from(schema.problemset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.problemset.id, id),
|
||||||
|
isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
async function problemSetCreators(ids: number[]) {
|
async function problemSetCreators(ids: number[]) {
|
||||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||||
if (ids.length === 0) return map
|
if (ids.length === 0) return map
|
||||||
@@ -55,7 +94,7 @@ async function problemSetCreators(ids: number[]) {
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
|
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime?: string | null) {
|
||||||
return {
|
return {
|
||||||
id: badge.id,
|
id: badge.id,
|
||||||
problemSetId: badge.problemsetId,
|
problemSetId: badge.problemsetId,
|
||||||
@@ -64,70 +103,79 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b
|
|||||||
icon: badge.icon,
|
icon: badge.icon,
|
||||||
conditionType: badge.conditionType,
|
conditionType: badge.conditionType,
|
||||||
conditionValue: badge.conditionValue,
|
conditionValue: badge.conditionValue,
|
||||||
isEarned: earned,
|
isEarned: earnedTime === undefined ? undefined : earnedTime !== null,
|
||||||
|
earnedTime,
|
||||||
} satisfies ProblemSetBadge
|
} satisfies ProblemSetBadge
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
|
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
|
||||||
*
|
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
|
||||||
* 以前是每行 5 条查询(题目数 / 我的进度 / 奖章 / 已获奖章 / 创建者),
|
|
||||||
* limit 最大 250 就是 1250 次往返。这里固定 5 条,与行数无关。
|
|
||||||
*/
|
*/
|
||||||
async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, includeBadges = false) {
|
async function serializeProblemSets(
|
||||||
|
rows: { set: ProblemSetRow; assigning: boolean }[],
|
||||||
|
userId?: number,
|
||||||
|
) {
|
||||||
if (rows.length === 0) return []
|
if (rows.length === 0) return []
|
||||||
const ids = rows.map((row) => row.id)
|
const ids = rows.map((row) => row.set.id)
|
||||||
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([
|
const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all(
|
||||||
db
|
[
|
||||||
.select({
|
db
|
||||||
problemsetId: schema.problemsetProblem.problemsetId,
|
.select({
|
||||||
value: count(),
|
problemsetId: schema.problemsetProblem.problemsetId,
|
||||||
})
|
value: count(),
|
||||||
.from(schema.problemsetProblem)
|
required: sql<number>`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`,
|
||||||
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
})
|
||||||
.groupBy(schema.problemsetProblem.problemsetId),
|
.from(schema.problemsetProblem)
|
||||||
userId
|
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||||
? db
|
.groupBy(schema.problemsetProblem.problemsetId),
|
||||||
.select()
|
db
|
||||||
.from(schema.problemsetProgress)
|
.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
|
||||||
.where(
|
.from(schema.problemsetProgress)
|
||||||
and(
|
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||||||
inArray(schema.problemsetProgress.problemsetId, ids),
|
.groupBy(schema.problemsetProgress.problemsetId),
|
||||||
eq(schema.problemsetProgress.userId, userId),
|
userId
|
||||||
),
|
? db
|
||||||
)
|
.select()
|
||||||
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]),
|
.from(schema.problemsetProgress)
|
||||||
includeBadges
|
.where(
|
||||||
? db
|
and(
|
||||||
.select()
|
inArray(schema.problemsetProgress.problemsetId, ids),
|
||||||
.from(schema.problemsetBadge)
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
),
|
||||||
.orderBy(asc(schema.problemsetBadge.id))
|
)
|
||||||
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
|
: Promise.resolve([] as ProgressRow[]),
|
||||||
includeBadges && userId
|
db
|
||||||
? db
|
.select()
|
||||||
.select({ id: schema.userBadge.badgeId })
|
.from(schema.problemsetBadge)
|
||||||
.from(schema.userBadge)
|
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
||||||
.innerJoin(
|
.orderBy(asc(schema.problemsetBadge.id)),
|
||||||
schema.problemsetBadge,
|
userId
|
||||||
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
|
? db
|
||||||
)
|
.select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime })
|
||||||
.where(
|
.from(schema.userBadge)
|
||||||
and(
|
.innerJoin(
|
||||||
eq(schema.userBadge.userId, userId),
|
schema.problemsetBadge,
|
||||||
inArray(schema.problemsetBadge.problemsetId, ids),
|
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
|
||||||
),
|
)
|
||||||
)
|
.where(
|
||||||
: Promise.resolve([] as { id: number }[]),
|
and(
|
||||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
eq(schema.userBadge.userId, userId),
|
||||||
])
|
inArray(schema.problemsetBadge.problemsetId, ids),
|
||||||
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value]))
|
),
|
||||||
|
)
|
||||||
|
: Promise.resolve([] as { id: number; earnedTime: string }[]),
|
||||||
|
problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item]))
|
||||||
|
const joinedBySet = new Map(joinedCounts.map((item) => [item.problemsetId, item.value]))
|
||||||
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
|
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
|
||||||
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
|
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
|
||||||
for (const badge of badges)
|
for (const badge of badges)
|
||||||
badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
|
badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
|
||||||
const earned = new Set(earnedRows.map((item) => item.id))
|
const earned = new Map(earnedRows.map((item) => [item.id, item.earnedTime]))
|
||||||
return rows.map((row) => {
|
return rows.map(({ set: row, assigning }) => {
|
||||||
const progress = progressBySet.get(row.id)
|
const progress = progressBySet.get(row.id)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -136,17 +184,16 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
|
|||||||
createdBy:
|
createdBy:
|
||||||
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
||||||
createTime: row.createTime,
|
createTime: row.createTime,
|
||||||
lastUpdateTime: row.lastUpdateTime,
|
assignedUntil: row.assignedUntil,
|
||||||
difficulty: row.difficulty,
|
assigning,
|
||||||
status: row.status,
|
problemsCount: countBySet.get(row.id)?.value ?? 0,
|
||||||
endTime: row.endTime,
|
// 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径
|
||||||
visible: row.visible,
|
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
|
||||||
problemsCount: countBySet.get(row.id) ?? 0,
|
joinedCount: joinedBySet.get(row.id) ?? 0,
|
||||||
completedCount: progress?.completedProblemsCount ?? 0,
|
|
||||||
userProgress: progressSummary(progress),
|
userProgress: progressSummary(progress),
|
||||||
badges: includeBadges
|
badges: (badgesBySet.get(row.id) ?? []).map((badge) =>
|
||||||
? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id)))
|
badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined),
|
||||||
: undefined,
|
),
|
||||||
} satisfies ProblemSet
|
} satisfies ProblemSet
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,10 +201,8 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
|
|||||||
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")]
|
const filters = [eq(schema.problemset.visible, true)]
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
const difficulty = c.req.query("difficulty")?.trim()
|
|
||||||
const status = c.req.query("status")?.trim()
|
|
||||||
if (keyword)
|
if (keyword)
|
||||||
filters.push(
|
filters.push(
|
||||||
or(
|
or(
|
||||||
@@ -165,13 +210,11 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
|||||||
ilike(schema.problemset.description, `%${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 = and(...filters)
|
const where = and(...filters)
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.problemset).where(where),
|
db.select({ value: count() }).from(schema.problemset).where(where),
|
||||||
db
|
db
|
||||||
.select()
|
.select({ set: schema.problemset, assigning: assigningSql })
|
||||||
.from(schema.problemset)
|
.from(schema.problemset)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(desc(schema.problemset.createTime))
|
.orderBy(desc(schema.problemset.createTime))
|
||||||
@@ -179,49 +222,45 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
|||||||
.offset(offset),
|
.offset(offset),
|
||||||
])
|
])
|
||||||
return success(c, {
|
return success(c, {
|
||||||
results: await serializeProblemSets(rows, c.get("user")?.id, true),
|
results: await serializeProblemSets(rows, c.get("user")?.id),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
} satisfies ProblemSetList)
|
} satisfies ProblemSetList)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 字面量路由要排在 /problem-sets/:id 前面:Hono 按注册顺序匹配,不是静态优先
|
||||||
|
// 题号(problemDisplayId)和主键(problemId)都认:提交列表那边手里只有题号
|
||||||
|
problemsetRoutes.get("/problem-sets/locks", requireAuth, async (c) => {
|
||||||
|
const user = c.get("user")!
|
||||||
|
if (isTeacherOrAbove(user)) return success(c, [] as ProblemSetLock[])
|
||||||
|
let problemId = queryInteger(c.req.query("problemId"), 0, { min: 1 })
|
||||||
|
const displayId = c.req.query("problemDisplayId")?.trim()
|
||||||
|
if (!problemId && displayId) {
|
||||||
|
const [problem] = await db
|
||||||
|
.select({ id: schema.problem.id })
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(and(eq(schema.problem.displayId, displayId), isNull(schema.problem.contestId)))
|
||||||
|
.limit(1)
|
||||||
|
problemId = problem?.id ?? 0
|
||||||
|
}
|
||||||
|
if (!problemId) return success(c, [] as ProblemSetLock[])
|
||||||
|
const rows = await problemSetLocksFor(user.id, problemId)
|
||||||
|
return success(c, rows satisfies ProblemSetLock[])
|
||||||
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const row = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user"))
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.problemset)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemset.id, id),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
||||||
return success(c, data)
|
return success(c, data)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const user = c.get("user")
|
||||||
const [problemSet] = await db
|
const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), user)
|
||||||
.select({ id: schema.problemset.id })
|
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
.from(schema.problemset)
|
const id = found.set.id
|
||||||
.where(
|
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
|
||||||
and(
|
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
|
||||||
eq(schema.problemset.id, id),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
|
|
||||||
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
|
|
||||||
//
|
|
||||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
|
|
||||||
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
|
|
||||||
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
link: schema.problemsetProblem,
|
link: schema.problemsetProblem,
|
||||||
@@ -234,76 +273,86 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
|||||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||||
const progressRows = c.get("user")
|
const problemIds = rows.map((row) => row.problemId)
|
||||||
? await db
|
const [progressRows, wrongRows, cutoffs] = user
|
||||||
.select({ detail: schema.problemsetProgress.progressDetail })
|
? await Promise.all([
|
||||||
.from(schema.problemsetProgress)
|
db
|
||||||
.where(
|
.select({ detail: schema.problemsetProgress.progressDetail })
|
||||||
and(
|
.from(schema.problemsetProgress)
|
||||||
eq(schema.problemsetProgress.problemsetId, id),
|
.where(
|
||||||
eq(schema.problemsetProgress.userId, c.get("user")!.id),
|
and(
|
||||||
),
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
)
|
eq(schema.problemsetProgress.userId, user.id),
|
||||||
.limit(1)
|
),
|
||||||
: []
|
)
|
||||||
const completed = asRecord(progressRows[0]?.detail)
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({ problemId: schema.submission.problemId, value: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
eq(schema.submission.problemsetId, id),
|
||||||
|
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.problemId),
|
||||||
|
isTeacherOrAbove(user)
|
||||||
|
? new Map<number, string>()
|
||||||
|
: problemSetLockCutoffs(user.id, problemIds),
|
||||||
|
])
|
||||||
|
: [[], [], new Map<number, string>()]
|
||||||
|
// 「以前做对过 · 旧代码先藏着」:这道题正被闸门挡着,而且分界之前确实做对过
|
||||||
|
const hiddenSolved =
|
||||||
|
user && cutoffs.size
|
||||||
|
? await db
|
||||||
|
.select({ problemId: schema.submission.problemId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
inArray(schema.submission.problemId, [...cutoffs.keys()]),
|
||||||
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||||
|
or(
|
||||||
|
...[...cutoffs].map(
|
||||||
|
([problemId, cutoff]) =>
|
||||||
|
sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.problemId)
|
||||||
|
: []
|
||||||
|
const hidden = new Set(hiddenSolved.map((row) => row.problemId))
|
||||||
|
const solved = asRecord(progressRows[0]?.detail)
|
||||||
|
const wrong = new Map(wrongRows.map((row) => [row.problemId, row.value]))
|
||||||
return success(
|
return success(
|
||||||
c,
|
c,
|
||||||
rows.map(
|
rows.map(({ link, problemId, displayId, title, difficulty }) => {
|
||||||
({ link, problemId, displayId, title, difficulty }) =>
|
const entry = solved[String(problemId)]
|
||||||
({
|
const solvedTime = entry === undefined ? null : asRecord(entry).submit_time
|
||||||
id: link.id,
|
return {
|
||||||
problemSetId: link.problemsetId,
|
id: link.id,
|
||||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
problemSetId: link.problemsetId,
|
||||||
order: link.order,
|
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||||
isRequired: link.isRequired,
|
order: link.order,
|
||||||
score: link.score,
|
isRequired: link.isRequired,
|
||||||
hint: link.hint,
|
isCompleted: entry !== undefined,
|
||||||
isCompleted: String(problemId) in completed,
|
solvedTime: typeof solvedTime === "string" ? solvedTime : null,
|
||||||
}) satisfies ProblemSetProblem,
|
wrongCount: wrong.get(problemId) ?? 0,
|
||||||
),
|
oldCodeHidden: hidden.has(problemId),
|
||||||
|
} satisfies ProblemSetProblem
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function recomputeProgress(
|
|
||||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
|
||||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
|
||||||
detail: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
const links = await tx
|
|
||||||
.select({
|
|
||||||
problemId: schema.problemsetProblem.problemId,
|
|
||||||
score: schema.problemsetProblem.score,
|
|
||||||
isRequired: schema.problemsetProblem.isRequired,
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProblem)
|
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
|
||||||
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
|
||||||
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
|
||||||
const update = computeProgress(detail, links, progress.completeTime)
|
|
||||||
await tx
|
|
||||||
.update(schema.problemsetProgress)
|
|
||||||
.set(update)
|
|
||||||
.where(eq(schema.problemsetProgress.id, progress.id))
|
|
||||||
return { ...progress, ...update }
|
|
||||||
}
|
|
||||||
|
|
||||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||||
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
|
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [problemSet] = await db
|
const found = await loadVisible(parsed.data.problemSetId, user)
|
||||||
.select({ id: schema.problemset.id })
|
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
.from(schema.problemset)
|
const problemSet = found.set
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemset.id, parsed.data.problemSetId),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: schema.problemsetProgress.id })
|
.select({ id: schema.problemsetProgress.id })
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProgress)
|
||||||
@@ -316,22 +365,21 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const [created] = await tx
|
const links = await tx
|
||||||
.insert(schema.problemsetProgress)
|
.select({
|
||||||
.values({
|
problemId: schema.problemsetProblem.problemId,
|
||||||
problemsetId: problemSet.id,
|
score: schema.problemsetProblem.score,
|
||||||
userId: user.id,
|
isRequired: schema.problemsetProblem.isRequired,
|
||||||
joinTime: new Date().toISOString(),
|
|
||||||
completeTime: null,
|
|
||||||
isCompleted: false,
|
|
||||||
progressPercentage: 0,
|
|
||||||
completedProblemsCount: 0,
|
|
||||||
totalProblemsCount: 0,
|
|
||||||
totalScore: 0,
|
|
||||||
progressDetail: {},
|
|
||||||
})
|
})
|
||||||
.returning()
|
.from(schema.problemsetProblem)
|
||||||
if (created) await recomputeProgress(tx, created, {})
|
.where(eq(schema.problemsetProblem.problemsetId, problemSet.id))
|
||||||
|
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份
|
||||||
|
await tx.insert(schema.problemsetProgress).values({
|
||||||
|
problemsetId: problemSet.id,
|
||||||
|
userId: user.id,
|
||||||
|
joinTime: new Date().toISOString(),
|
||||||
|
...computeProgress({}, links, null),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return success(c, null, 201)
|
return success(c, null, 201)
|
||||||
})
|
})
|
||||||
@@ -372,137 +420,198 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
/**
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
||||||
const [problemSet] = await db
|
*
|
||||||
.select({ id: schema.problemset.id })
|
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
||||||
.from(schema.problemset)
|
* 也是任何老师看任何班,这里和它们一致。
|
||||||
.where(
|
*
|
||||||
and(
|
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
||||||
eq(schema.problemset.id, id),
|
* 想看别的班再换(和比赛「全班情况」按参赛者推断班级是一个思路)。名单是那个班的全部学生,
|
||||||
eq(schema.problemset.visible, true),
|
* 没加入的也列出来,好点名。
|
||||||
ne(schema.problemset.status, "draft"),
|
*/
|
||||||
),
|
problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) => {
|
||||||
)
|
const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user"))
|
||||||
.limit(1)
|
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
const id = found.set.id
|
||||||
const badges = await db
|
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
|
||||||
.select()
|
const [links, joinedClasses] = await Promise.all([
|
||||||
.from(schema.problemsetBadge)
|
|
||||||
.where(eq(schema.problemsetBadge.problemsetId, id))
|
|
||||||
return success(
|
|
||||||
c,
|
|
||||||
badges.map((badge) => badgeData(badge)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
|
||||||
const [problemSet] = await db
|
|
||||||
.select({ id: schema.problemset.id })
|
|
||||||
.from(schema.problemset)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemset.id, id),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
// 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
|
||||||
// 也是任何老师看任何班,这里和它们一致。后台那条(admin/problemset.ts 的 loadOwned)
|
|
||||||
// 能改能删,仍然只给创建者
|
|
||||||
if (!problemSet) {
|
|
||||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
}
|
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
|
||||||
const className = c.req.query("className")?.trim()
|
|
||||||
const completion = c.req.query("completionStatus")?.trim()
|
|
||||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
|
||||||
// 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配,
|
|
||||||
// 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含
|
|
||||||
if (className) {
|
|
||||||
const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className
|
|
||||||
filters.push(classPrefixCondition(classPrefix) ?? ilike(schema.user.username, `%${className}%`))
|
|
||||||
}
|
|
||||||
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
|
||||||
else if (completion === "in_progress")
|
|
||||||
filters.push(
|
|
||||||
and(
|
|
||||||
eq(schema.problemsetProgress.isCompleted, false),
|
|
||||||
gt(schema.problemsetProgress.completedProblemsCount, 0),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
else if (completion === "not_started")
|
|
||||||
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
|
||||||
const where = and(...filters)
|
|
||||||
const [statsRows, rows, problemRows] = await Promise.all([
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
total: count(),
|
problemId: schema.problem.id,
|
||||||
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
|
|
||||||
avgProgress: avg(schema.problemsetProgress.progressPercentage),
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProgress)
|
|
||||||
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
|
||||||
.where(where),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
progress: schema.problemsetProgress,
|
|
||||||
user: schema.user,
|
|
||||||
realName: schema.userProfile.realName,
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProgress)
|
|
||||||
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
||||||
.where(where)
|
|
||||||
.orderBy(
|
|
||||||
desc(schema.problemsetProgress.isCompleted),
|
|
||||||
desc(schema.problemsetProgress.progressPercentage),
|
|
||||||
asc(schema.problemsetProgress.joinTime),
|
|
||||||
)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: schema.problem.id,
|
|
||||||
_id: schema.problem.displayId,
|
_id: schema.problem.displayId,
|
||||||
title: schema.problem.title,
|
title: schema.problem.title,
|
||||||
|
isRequired: schema.problemsetProblem.isRequired,
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProblem)
|
.from(schema.problemsetProblem)
|
||||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
||||||
])
|
db
|
||||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
.select({
|
||||||
const results = rows.map(
|
className: sql<string>`${schema.user.className}`,
|
||||||
({ progress, user: progressUser, realName }) =>
|
joined: count(),
|
||||||
({
|
last: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
||||||
id: progress.id,
|
})
|
||||||
problemSetId: progress.problemsetId,
|
.from(schema.problemsetProgress)
|
||||||
user: sampleUser(progressUser, realName),
|
.innerJoin(schema.user, eq(schema.user.id, schema.problemsetProgress.userId))
|
||||||
joinTime: progress.joinTime,
|
.where(
|
||||||
completeTime: progress.completeTime,
|
and(
|
||||||
isCompleted: progress.isCompleted,
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
progressPercentage: progress.progressPercentage,
|
studentRole,
|
||||||
completedProblemsCount: progress.completedProblemsCount,
|
sql`coalesce(${schema.user.className}, '') <> ''`,
|
||||||
totalProblemsCount: progress.totalProblemsCount,
|
|
||||||
totalScore: progress.totalScore,
|
|
||||||
completedProblems: Object.keys(asRecord(progress.progressDetail)).flatMap(
|
|
||||||
(key) => problemMap.get(key) ?? [],
|
|
||||||
),
|
),
|
||||||
}) satisfies ProblemSetProgress,
|
)
|
||||||
)
|
.groupBy(schema.user.className)
|
||||||
const stats = statsRows[0]
|
.orderBy(sql`max(${schema.problemsetProgress.joinTime}) desc`),
|
||||||
|
])
|
||||||
|
const sizes = joinedClasses.length
|
||||||
|
? await db
|
||||||
|
.select({ className: sql<string>`${schema.user.className}`, value: count() })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
schema.user.className,
|
||||||
|
joinedClasses.map((row) => row.className),
|
||||||
|
),
|
||||||
|
studentRole,
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.user.className)
|
||||||
|
: []
|
||||||
|
const sizeOf = new Map(sizes.map((row) => [row.className, row.value]))
|
||||||
|
// 只有零星几个人加入的班(别的班的学生自己点进来的)排到后面去:至少 5 个、三分之一的人加入了
|
||||||
|
// 才算「这个班在做」,这样的班按最近一次有人加入排,默认看第一个
|
||||||
|
const classes = joinedClasses
|
||||||
|
.map((row) => ({
|
||||||
|
className: row.className,
|
||||||
|
joined: row.joined,
|
||||||
|
size: sizeOf.get(row.className) ?? row.joined,
|
||||||
|
}))
|
||||||
|
.map((row, recency) => ({ row, recency, doing: row.joined >= 5 && row.joined * 3 >= row.size }))
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.doing !== b.doing
|
||||||
|
? Number(b.doing) - Number(a.doing)
|
||||||
|
: a.doing
|
||||||
|
? a.recency - b.recency
|
||||||
|
: b.row.joined - a.row.joined,
|
||||||
|
)
|
||||||
|
.map((item) => item.row)
|
||||||
|
const wanted = c.req.query("className")?.trim()
|
||||||
|
const className = wanted || classes[0]?.className || null
|
||||||
|
const graded = gradedIds(links)
|
||||||
|
const empty: ProblemSetClassView = {
|
||||||
|
classes,
|
||||||
|
className,
|
||||||
|
problems: links.map((link) => ({
|
||||||
|
id: link.problemId,
|
||||||
|
_id: link._id,
|
||||||
|
title: link.title,
|
||||||
|
isRequired: link.isRequired,
|
||||||
|
solved: 0,
|
||||||
|
})),
|
||||||
|
totalCount: graded.size,
|
||||||
|
students: [],
|
||||||
|
}
|
||||||
|
if (!className || links.length === 0) return success(c, empty)
|
||||||
|
|
||||||
|
const roster = await db
|
||||||
|
.select({
|
||||||
|
userId: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
realName: schema.userProfile.realName,
|
||||||
|
progress: schema.problemsetProgress,
|
||||||
|
})
|
||||||
|
.from(schema.user)
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.leftJoin(
|
||||||
|
schema.problemsetProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProgress.userId, schema.user.id),
|
||||||
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.user.username))
|
||||||
|
if (roster.length === 0) return success(c, empty)
|
||||||
|
const userIds = roster.map((row) => row.userId)
|
||||||
|
const problemIds = links.map((link) => link.problemId)
|
||||||
|
const [wrongRows, beforeRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
userId: schema.submission.userId,
|
||||||
|
problemId: schema.submission.problemId,
|
||||||
|
value: count(),
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.problemsetId, id),
|
||||||
|
inArray(schema.submission.userId, userIds),
|
||||||
|
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||||
|
// 加入之前就做对过(哪个入口都算):老师据此知道谁可能是凭记忆默写的
|
||||||
|
db
|
||||||
|
.select({ userId: schema.submission.userId, problemId: schema.submission.problemId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(
|
||||||
|
schema.problemsetProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProgress.userId, schema.submission.userId),
|
||||||
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.submission.userId, userIds),
|
||||||
|
inArray(schema.submission.problemId, problemIds),
|
||||||
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||||
|
sql`${schema.submission.createTime} < ${schema.problemsetProgress.joinTime}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||||
|
])
|
||||||
|
const key = (userId: number, problemId: number) => `${userId}:${problemId}`
|
||||||
|
const wrong = new Map(wrongRows.map((row) => [key(row.userId, row.problemId), row.value]))
|
||||||
|
const before = new Set(beforeRows.map((row) => key(row.userId, row.problemId)))
|
||||||
|
const solvedCount = new Map<number, number>()
|
||||||
|
const students = roster.map((row) => {
|
||||||
|
const detail = asRecord(row.progress?.progressDetail)
|
||||||
|
let completed = 0
|
||||||
|
const cells = links.map((link) => {
|
||||||
|
const entry = detail[String(link.problemId)]
|
||||||
|
const time = entry === undefined ? null : asRecord(entry).submit_time
|
||||||
|
if (entry !== undefined) {
|
||||||
|
solvedCount.set(link.problemId, (solvedCount.get(link.problemId) ?? 0) + 1)
|
||||||
|
if (graded.has(link.problemId)) completed += 1
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
solvedTime: typeof time === "string" ? time : null,
|
||||||
|
wrongCount: wrong.get(key(row.userId, link.problemId)) ?? 0,
|
||||||
|
solvedBefore: before.has(key(row.userId, link.problemId)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
realName: row.realName,
|
||||||
|
joinTime: row.progress?.joinTime ?? null,
|
||||||
|
completedCount: completed,
|
||||||
|
cells,
|
||||||
|
}
|
||||||
|
})
|
||||||
return success(c, {
|
return success(c, {
|
||||||
results,
|
...empty,
|
||||||
total: stats?.total ?? 0,
|
problems: empty.problems.map((problem) => ({
|
||||||
statistics: {
|
...problem,
|
||||||
total: stats?.total ?? 0,
|
solved: solvedCount.get(problem.id) ?? 0,
|
||||||
completed: stats?.completed ?? 0,
|
})),
|
||||||
avgProgress: Number(stats?.avgProgress ?? 0),
|
students,
|
||||||
},
|
} satisfies ProblemSetClassView)
|
||||||
problems: problemRows,
|
|
||||||
} satisfies ProblemSetProgressList)
|
|
||||||
})
|
})
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
import { CodeFormatError, formatCode } from "../services/format-code"
|
import { CodeFormatError, formatCode } from "../services/format-code"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { problemSetJoinTimes } from "../services/problemset"
|
import { problemSetLockCutoffs } from "../services/problemset"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
||||||
@@ -289,14 +289,14 @@ function canViewSubmission(
|
|||||||
user: AuthUser | null,
|
user: AuthUser | null,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
problem: { createdById: number },
|
problem: { createdById: number },
|
||||||
problemSetJoinTime?: Map<number, string>,
|
problemSetCutoffs?: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,老师不受限。
|
// 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
|
||||||
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
||||||
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
||||||
const joinTime = problemSetJoinTime?.get(row.problemId)
|
const cutoff = problemSetCutoffs?.get(row.problemId)
|
||||||
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
if (cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)) return false
|
||||||
}
|
}
|
||||||
// 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
|
// 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
|
||||||
// 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
|
// 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
|
||||||
@@ -399,11 +399,11 @@ async function submissionDetail(id: string, user: AuthUser) {
|
|||||||
if (!row) return null
|
if (!row) return null
|
||||||
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
||||||
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
||||||
const joinTimes =
|
const cutoffs =
|
||||||
isTeacherOrAbove(user) || row.submission.userId !== user.id
|
isTeacherOrAbove(user) || row.submission.userId !== user.id
|
||||||
? undefined
|
? undefined
|
||||||
: await problemSetJoinTimes(user.id, [row.submission.problemId])
|
: await problemSetLockCutoffs(user.id, [row.submission.problemId])
|
||||||
if (!canViewSubmission(user, row.submission, row.problem, joinTimes)) return null
|
if (!canViewSubmission(user, row.submission, row.problem, cutoffs)) return null
|
||||||
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
||||||
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
||||||
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
|
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
|
||||||
@@ -569,9 +569,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
displayId ? unknownDisplayIds(displayId, null) : [],
|
displayId ? unknownDisplayIds(displayId, null) : [],
|
||||||
])
|
])
|
||||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||||
const [joinTimes, problemsetTitles] = await Promise.all([
|
const [cutoffs, problemsetTitles] = await Promise.all([
|
||||||
user && !isTeacherOrAbove(user)
|
user && !isTeacherOrAbove(user)
|
||||||
? problemSetJoinTimes(user.id, [
|
? problemSetLockCutoffs(user.id, [
|
||||||
...new Set(
|
...new Set(
|
||||||
rows
|
rows
|
||||||
.filter((row) => row.submission.userId === user.id)
|
.filter((row) => row.submission.userId === user.id)
|
||||||
@@ -589,7 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
id: submission.id,
|
id: submission.id,
|
||||||
problemDisplayId: problem.displayId,
|
problemDisplayId: problem.displayId,
|
||||||
problemTitle: problem.title,
|
problemTitle: problem.title,
|
||||||
showLink: user ? canViewSubmission(user, submission, problem, joinTimes) : false,
|
showLink: user ? canViewSubmission(user, submission, problem, cutoffs) : false,
|
||||||
createTime: submission.createTime,
|
createTime: submission.createTime,
|
||||||
userId: submission.userId,
|
userId: submission.userId,
|
||||||
username: submission.username,
|
username: submission.username,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
|
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { publishAchievementNotification } from "../events"
|
import { publishAchievementNotification } from "../events"
|
||||||
@@ -10,7 +10,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
|||||||
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
|
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
|
||||||
type BadgeCheck = Pick<
|
type BadgeCheck = Pick<
|
||||||
ProgressRow,
|
ProgressRow,
|
||||||
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail"
|
"completedProblemsCount" | "totalProblemsCount" | "progressDetail"
|
||||||
>
|
>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +132,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
|
|||||||
if (badge.conditionType === "problem_count") {
|
if (badge.conditionType === "problem_count") {
|
||||||
return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue
|
return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue
|
||||||
}
|
}
|
||||||
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
|
// 「总分达到 N」随分数一起拿掉了,0026 把它们换成了「做对 N 道」
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +242,8 @@ export async function recordSolvedProblem(
|
|||||||
problemId: number,
|
problemId: number,
|
||||||
/**
|
/**
|
||||||
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
||||||
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
|
* 外键指向代码提交表,流程图提交的 id 进不去。不影响防抄闸门的解锁 ——
|
||||||
* 那道闸看的是 progress_detail(本文件下面的 problemSetJoinTimes)
|
* 那道闸看的是 progress_detail(本文件下面的 problemSetLockCutoffs)
|
||||||
*/
|
*/
|
||||||
submissionId: string | null,
|
submissionId: string | null,
|
||||||
solvedAt: string,
|
solvedAt: string,
|
||||||
@@ -390,50 +390,85 @@ export async function recordSolvedAndNotify(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
|
||||||
|
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
|
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
|
||||||
* 代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)共用这一道闸。
|
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
|
||||||
|
* 共用这一道闸。
|
||||||
*
|
*
|
||||||
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
|
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前**
|
||||||
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
|
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的
|
||||||
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
|
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
|
||||||
*
|
*
|
||||||
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
|
||||||
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
|
* 现在分界是布置开始,没加入的人也藏,这个口子就没了。代价是别的班以前做过这几道题的人,
|
||||||
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
|
* 在这一两周里也看不到自己的旧代码,用户认了。
|
||||||
* - 题单被归档(status 不是 active)
|
|
||||||
*
|
*
|
||||||
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
|
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||||
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
|
* - 题单不在布置期(没布置,或者布置期过了)
|
||||||
* 行为不确定,换成聚合顺手定死。
|
* - 题单没公开
|
||||||
|
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key)
|
||||||
|
*
|
||||||
|
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。
|
||||||
*/
|
*/
|
||||||
export async function problemSetJoinTimes(userId: number, problemIds: number[]) {
|
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
|
||||||
const joinTimes = new Map<number, string>()
|
const cutoffs = new Map<number, string>()
|
||||||
if (problemIds.length === 0) return joinTimes
|
if (problemIds.length === 0) return cutoffs
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
problemId: schema.problemsetProblem.problemId,
|
problemId: schema.problemsetProblem.problemId,
|
||||||
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
|
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
|
||||||
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
|
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状
|
||||||
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
|
cutoff: sql<string>`max(${schema.problemset.assignedAt})`,
|
||||||
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProblem)
|
||||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
|
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
|
||||||
.innerJoin(
|
.leftJoin(
|
||||||
schema.problemsetProblem,
|
schema.problemsetProgress,
|
||||||
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
|
and(
|
||||||
|
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
|
||||||
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.problemsetProgress.userId, userId),
|
|
||||||
inArray(schema.problemsetProblem.problemId, problemIds),
|
inArray(schema.problemsetProblem.problemId, problemIds),
|
||||||
eq(schema.problemset.status, "active"),
|
eq(schema.problemset.visible, true),
|
||||||
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
|
assigningSql,
|
||||||
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
|
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(schema.problemsetProblem.problemId)
|
.groupBy(schema.problemsetProblem.problemId)
|
||||||
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
|
for (const row of rows) cutoffs.set(row.problemId, row.cutoff)
|
||||||
return joinTimes
|
return cutoffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */
|
||||||
|
export async function problemSetLocksFor(userId: number, problemId: number) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
problemSetId: schema.problemset.id,
|
||||||
|
title: schema.problemset.title,
|
||||||
|
assignedUntil: sql<string>`${schema.problemset.assignedUntil}`,
|
||||||
|
})
|
||||||
|
.from(schema.problemsetProblem)
|
||||||
|
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
|
||||||
|
.leftJoin(
|
||||||
|
schema.problemsetProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
|
||||||
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProblem.problemId, problemId),
|
||||||
|
eq(schema.problemset.visible, true),
|
||||||
|
assigningSql,
|
||||||
|
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.problemset.assignedUntil))
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement"
|
|||||||
import { rankProfileSchema } from "./account"
|
import { rankProfileSchema } from "./account"
|
||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
import { reactionKeySchema } from "./content"
|
import { reactionKeySchema } from "./content"
|
||||||
|
import { badgeConditionTypeSchema } from "./problemset"
|
||||||
import { problemLanguageSchema, runnableLanguageSchema } from "./language"
|
import { problemLanguageSchema, runnableLanguageSchema } from "./language"
|
||||||
import {
|
import {
|
||||||
astRuleSchema,
|
astRuleSchema,
|
||||||
@@ -358,17 +359,22 @@ export const updateAcmHelperRequestSchema = z.object({
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 题单管理
|
// ---------------------------------------------------------------- 题单管理
|
||||||
|
|
||||||
|
// 难度、状态两列还留在表里(0026 没删列,删列要手工放行破坏性迁移),
|
||||||
|
// 但界面和接口都不再用:线上 16 个题单全是 Easy / active,「状态 + 可见」两道闸合成了「公开」
|
||||||
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
|
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
|
||||||
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
|
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
|
||||||
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"])
|
|
||||||
|
|
||||||
export const adminProblemSetSchema = z.object({
|
export const adminProblemSetSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
difficulty: problemSetDifficultySchema,
|
/** 布置到哪天(东八区当天结束);null = 没布置 */
|
||||||
status: problemSetStatusSchema,
|
assignedUntil: z.string().nullable(),
|
||||||
endTime: z.string().nullable(),
|
/** 这一轮布置开始的时刻 */
|
||||||
|
assignedAt: z.string().nullable(),
|
||||||
|
/** 现在在不在布置期内(服务端的钟) */
|
||||||
|
assigning: z.boolean(),
|
||||||
|
/** 公开:学生能在题单列表里看到、能进 */
|
||||||
visible: z.boolean(),
|
visible: z.boolean(),
|
||||||
createdBy: sampleUserSchema,
|
createdBy: sampleUserSchema,
|
||||||
createTime: z.string(),
|
createTime: z.string(),
|
||||||
@@ -376,23 +382,22 @@ export const adminProblemSetSchema = z.object({
|
|||||||
problemsCount: z.number().int(),
|
problemsCount: z.number().int(),
|
||||||
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */
|
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */
|
||||||
participantCount: z.number().int(),
|
participantCount: z.number().int(),
|
||||||
|
/** 做完(必做题全对)的人数 */
|
||||||
|
completedCount: z.number().int(),
|
||||||
|
badgeCount: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
|
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
|
||||||
|
|
||||||
export const createProblemSetRequestSchema = z.object({
|
export const createProblemSetRequestSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
description: z.string(),
|
description: z.string().default(""),
|
||||||
difficulty: problemSetDifficultySchema.default("Easy"),
|
/** 布置到哪天;null = 不布置。从「没在布置」变成「布置到将来」时,服务端把布置开始记成现在 */
|
||||||
status: problemSetStatusSchema.default("active"),
|
assignedUntil: z.string().nullable().default(null),
|
||||||
endTime: z.string().nullable().default(null),
|
|
||||||
visible: z.boolean().default(true),
|
visible: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProblemSetRequestSchema = createProblemSetRequestSchema
|
export const updateProblemSetRequestSchema = createProblemSetRequestSchema
|
||||||
export const updateProblemSetStatusRequestSchema = z.object({
|
|
||||||
status: problemSetStatusSchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const adminProblemSetProblemSchema = z.object({
|
export const adminProblemSetProblemSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -403,24 +408,28 @@ export const adminProblemSetProblemSchema = z.object({
|
|||||||
difficulty: z.string(),
|
difficulty: z.string(),
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
isRequired: z.boolean(),
|
isRequired: z.boolean(),
|
||||||
score: z.number().int(),
|
|
||||||
hint: z.string().nullable(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const addProblemToSetRequestSchema = z.object({
|
export const addProblemsToSetRequestSchema = z.object({
|
||||||
/** 展示用题号(_id),不是自增主键 —— 老师手里只有题号 */
|
/** 展示用题号(_id),不是自增主键 —— 老师手里只有题号。按给的顺序接在最后 */
|
||||||
problemId: z.string().trim().min(1),
|
problemIds: z.array(z.string().trim().min(1)).min(1).max(100),
|
||||||
order: z.number().int().default(0),
|
})
|
||||||
isRequired: z.boolean().default(true),
|
|
||||||
score: z.number().int().default(0),
|
export const addProblemsToSetResultSchema = z.object({
|
||||||
hint: z.string().default(""),
|
added: z.array(z.string()),
|
||||||
|
/** 题号不存在、不可见或是比赛题 */
|
||||||
|
missing: z.array(z.string()),
|
||||||
|
/** 本来就在题单里 */
|
||||||
|
duplicate: z.array(z.string()),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProblemInSetRequestSchema = z.object({
|
export const updateProblemInSetRequestSchema = z.object({
|
||||||
order: z.number().int().optional(),
|
isRequired: z.boolean(),
|
||||||
isRequired: z.boolean().optional(),
|
})
|
||||||
score: z.number().int().optional(),
|
|
||||||
hint: z.string().optional(),
|
/** 拖动排序:题单里题目的链接 id,按新顺序给全 */
|
||||||
|
export const reorderProblemSetProblemsRequestSchema = z.object({
|
||||||
|
ids: z.array(z.number().int()).min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminProblemSetBadgeSchema = z.object({
|
export const adminProblemSetBadgeSchema = z.object({
|
||||||
@@ -438,27 +447,13 @@ export const adminProblemSetBadgeSchema = z.object({
|
|||||||
export const createProblemSetBadgeRequestSchema = z.object({
|
export const createProblemSetBadgeRequestSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string().min(1),
|
||||||
conditionType: badgeConditionTypeSchema,
|
conditionType: badgeConditionTypeSchema,
|
||||||
conditionValue: z.number().int().default(0),
|
conditionValue: z.number().int().default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema
|
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 标签与题目分析
|
// ---------------------------------------------------------------- 标签与题目分析
|
||||||
|
|
||||||
export const adminTagSchema = z.object({
|
export const adminTagSchema = z.object({
|
||||||
@@ -927,7 +922,6 @@ export type AstCheckRequest = z.infer<typeof astCheckRequestSchema>
|
|||||||
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
|
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
|
||||||
export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema>
|
export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema>
|
||||||
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
|
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
|
||||||
export type AdminProblemSetProgress = z.infer<typeof adminProblemSetProgressSchema>
|
|
||||||
|
|
||||||
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>
|
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>
|
||||||
export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema>
|
export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema>
|
||||||
@@ -959,14 +953,16 @@ export type UpdateContestRequest = z.infer<typeof updateContestRequestSchema>
|
|||||||
export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema>
|
export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema>
|
||||||
export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema>
|
export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema>
|
||||||
export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema>
|
export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema>
|
||||||
export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
|
|
||||||
export type AdminProblemSet = z.infer<typeof adminProblemSetSchema>
|
export type AdminProblemSet = z.infer<typeof adminProblemSetSchema>
|
||||||
export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema>
|
export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema>
|
||||||
export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema>
|
export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema>
|
||||||
export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema>
|
export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema>
|
||||||
export type UpdateProblemSetStatusRequest = z.infer<typeof updateProblemSetStatusRequestSchema>
|
|
||||||
export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema>
|
export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema>
|
||||||
export type AddProblemToSetRequest = z.infer<typeof addProblemToSetRequestSchema>
|
export type AddProblemsToSetRequest = z.infer<typeof addProblemsToSetRequestSchema>
|
||||||
|
export type AddProblemsToSetResult = z.infer<typeof addProblemsToSetResultSchema>
|
||||||
|
export type ReorderProblemSetProblemsRequest = z.infer<
|
||||||
|
typeof reorderProblemSetProblemsRequestSchema
|
||||||
|
>
|
||||||
export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema>
|
export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema>
|
||||||
export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema>
|
export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema>
|
||||||
export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema>
|
export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema>
|
||||||
|
|||||||
@@ -3,23 +3,38 @@ import { z } from "zod"
|
|||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
import { maskedProblemDifficultySchema } from "./problem"
|
import { maskedProblemDifficultySchema } from "./problem"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我在这份题单里的进度。
|
||||||
|
*
|
||||||
|
* 两个计数口径不同,别混用:
|
||||||
|
* - completedCount / totalCount 只数**必做题**,进度条和「做完了」看它;
|
||||||
|
* - solvedCount 数做对的**全部**题(含选做),「做对 N 道」奖章看它 ——
|
||||||
|
* 和后端 eligibleForBadge 的 problem_count 同一个口径。
|
||||||
|
*/
|
||||||
export const problemSetUserProgressSummarySchema = z.object({
|
export const problemSetUserProgressSummarySchema = z.object({
|
||||||
isJoined: z.boolean(),
|
isJoined: z.boolean(),
|
||||||
progressPercentage: z.number(),
|
joinTime: z.string().nullable(),
|
||||||
completedCount: z.number().int(),
|
completedCount: z.number().int(),
|
||||||
totalCount: z.number().int(),
|
totalCount: z.number().int(),
|
||||||
|
solvedCount: z.number().int(),
|
||||||
isCompleted: z.boolean(),
|
isCompleted: z.boolean(),
|
||||||
|
completeTime: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 奖章只有两种条件:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了,0026 换算过) */
|
||||||
|
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count"])
|
||||||
|
|
||||||
export const problemSetBadgeSchema = z.object({
|
export const problemSetBadgeSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
problemSetId: z.number().int(),
|
problemSetId: z.number().int(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string(),
|
||||||
conditionType: z.enum(["all_problems", "problem_count", "score"]),
|
conditionType: badgeConditionTypeSchema,
|
||||||
conditionValue: z.number().int(),
|
conditionValue: z.number().int(),
|
||||||
isEarned: z.boolean().optional(),
|
isEarned: z.boolean().optional(),
|
||||||
|
/** 我拿到它的时刻;没拿到、没登录为 null */
|
||||||
|
earnedTime: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const problemSetSchema = z.object({
|
export const problemSetSchema = z.object({
|
||||||
@@ -28,26 +43,26 @@ export const problemSetSchema = z.object({
|
|||||||
description: z.string(),
|
description: z.string(),
|
||||||
createdBy: sampleUserSchema,
|
createdBy: sampleUserSchema,
|
||||||
createTime: z.string(),
|
createTime: z.string(),
|
||||||
lastUpdateTime: z.string().optional(),
|
/** 布置到哪天(东八区当天结束的时刻);null = 没布置 */
|
||||||
difficulty: z.enum(["Easy", "Medium", "Hard"]),
|
assignedUntil: z.string().nullable(),
|
||||||
status: z.enum(["active", "archived", "draft"]),
|
/** 现在是不是在布置期内。服务端按自己的钟算好,前端不用拿本机时间去比 */
|
||||||
endTime: z.string().nullable(),
|
assigning: z.boolean(),
|
||||||
visible: z.boolean(),
|
/** 全部题数(含选做) */
|
||||||
problemsCount: z.number().int(),
|
problemsCount: z.number().int(),
|
||||||
completedCount: z.number().int().optional(),
|
/** 必做题数(一道必做都没标时等于全部题数)——「做完」的分母,没加入时也要用 */
|
||||||
|
requiredCount: z.number().int(),
|
||||||
|
/** 加入过的人数 */
|
||||||
|
joinedCount: z.number().int(),
|
||||||
userProgress: problemSetUserProgressSummarySchema,
|
userProgress: problemSetUserProgressSummarySchema,
|
||||||
badges: z.array(problemSetBadgeSchema).optional(),
|
badges: z.array(problemSetBadgeSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const problemSetListSchema = paginatedSchema(problemSetSchema)
|
export const problemSetListSchema = paginatedSchema(problemSetSchema)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单页的题目卡片只渲染题号、标题、难度,所以只下发这三样(外加题目主键)。
|
* 题单页的题目行只渲染题号、标题,所以只下发这几样(外加题目主键)。
|
||||||
*
|
* 以前复用 problemListItemSchema,服务端每次要多 join 一次 user / 标签表,
|
||||||
* 以前这里复用 problemListItemSchema,代价是服务端每次都要多 join 一次 user +
|
* 而那些字段题单页一个都不渲染。
|
||||||
* user_profile 凑 createdBy、再多查一次标签表凑 tags,而 tags / submissionNumber /
|
|
||||||
* acceptedNumber / createdBy / flowchart / hasAstRules / myStatus 在题单页一个都不渲染
|
|
||||||
* —— myStatus 甚至是写死的 null。
|
|
||||||
*/
|
*/
|
||||||
export const problemSetProblemItemSchema = z.object({
|
export const problemSetProblemItemSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -62,42 +77,72 @@ export const problemSetProblemSchema = z.object({
|
|||||||
problem: problemSetProblemItemSchema,
|
problem: problemSetProblemItemSchema,
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
isRequired: z.boolean(),
|
isRequired: z.boolean(),
|
||||||
score: z.number().int(),
|
/** 在这份题单里做对了没有(代码通过,或流程图拿到 A / S) */
|
||||||
hint: z.string().nullable(),
|
|
||||||
isCompleted: z.boolean(),
|
isCompleted: z.boolean(),
|
||||||
|
/** 在这份题单里做对的时刻 */
|
||||||
|
solvedTime: z.string().nullable(),
|
||||||
|
/** 在这份题单里交错了几次(还没做对时显示「交过 n 次」) */
|
||||||
|
wrongCount: z.number().int(),
|
||||||
|
/**
|
||||||
|
* 布置期内、以前做对过、旧代码正被藏着 —— 行上标「以前做对过 · 旧代码先藏着」。
|
||||||
|
* 规则见后端 services/problemset.ts 的 problemSetLockCutoffs
|
||||||
|
*/
|
||||||
|
oldCodeHidden: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const joinProblemSetRequestSchema = z.object({
|
export const joinProblemSetRequestSchema = z.object({
|
||||||
problemSetId: z.number().int().positive(),
|
problemSetId: z.number().int().positive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const completedProblemSchema = z.object({
|
/** 老师看某个班在这份题单里的情况:真名 × 每道题 */
|
||||||
id: z.number().int(),
|
export const problemSetClassCellSchema = z.object({
|
||||||
_id: z.string(),
|
/** 在题单里做对的时刻 */
|
||||||
title: z.string(),
|
solvedTime: z.string().nullable(),
|
||||||
|
/** 在题单里交错的次数 */
|
||||||
|
wrongCount: z.number().int(),
|
||||||
|
/** 加入之前就做对过这道题(任何入口) */
|
||||||
|
solvedBefore: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const problemSetProgressSchema = z.object({
|
export const problemSetClassStudentSchema = z.object({
|
||||||
id: z.number().int(),
|
userId: z.number().int(),
|
||||||
|
username: z.string(),
|
||||||
|
realName: z.string().nullable(),
|
||||||
|
/** 没加入为 null */
|
||||||
|
joinTime: z.string().nullable(),
|
||||||
|
/** 做对的必做题数 */
|
||||||
|
completedCount: z.number().int(),
|
||||||
|
cells: z.array(problemSetClassCellSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const problemSetClassViewSchema = z.object({
|
||||||
|
/** 加入过这份题单的学生分属哪些班:三分之一以上的人加入了的班在前(按最近一次加入),零星几个人的在后 */
|
||||||
|
classes: z.array(
|
||||||
|
z.object({ className: z.string(), joined: z.number().int(), size: z.number().int() }),
|
||||||
|
),
|
||||||
|
/** 这次看的是哪个班;谁都没加入时为 null */
|
||||||
|
className: z.string().nullable(),
|
||||||
|
problems: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
_id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
isRequired: z.boolean(),
|
||||||
|
/** 这个班在题单里做对的人数 */
|
||||||
|
solved: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/** 必做题数(全是选做时等于全部题数),「做完」的分母 */
|
||||||
|
totalCount: z.number().int(),
|
||||||
|
/** 这个班的全部学生(按学号),没加入的 joinTime 为 null */
|
||||||
|
students: z.array(problemSetClassStudentSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 某道题为什么藏着旧代码:落在哪些正在布置的题单里 */
|
||||||
|
export const problemSetLockSchema = z.object({
|
||||||
problemSetId: z.number().int(),
|
problemSetId: z.number().int(),
|
||||||
user: sampleUserSchema,
|
title: z.string(),
|
||||||
joinTime: z.string(),
|
assignedUntil: z.string(),
|
||||||
completeTime: z.string().nullable(),
|
|
||||||
isCompleted: z.boolean(),
|
|
||||||
progressPercentage: z.number(),
|
|
||||||
completedProblemsCount: z.number().int(),
|
|
||||||
totalProblemsCount: z.number().int(),
|
|
||||||
totalScore: z.number().int(),
|
|
||||||
completedProblems: z.array(completedProblemSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const problemSetProgressListSchema = paginatedSchema(problemSetProgressSchema).extend({
|
|
||||||
statistics: z.object({
|
|
||||||
total: z.number().int(),
|
|
||||||
completed: z.number().int(),
|
|
||||||
avgProgress: z.number(),
|
|
||||||
}),
|
|
||||||
problems: z.array(completedProblemSchema),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const userBadgeSchema = z.object({
|
export const userBadgeSchema = z.object({
|
||||||
@@ -112,10 +157,11 @@ export type ProblemSet = z.infer<typeof problemSetSchema>
|
|||||||
export type ProblemSetList = z.infer<typeof problemSetListSchema>
|
export type ProblemSetList = z.infer<typeof problemSetListSchema>
|
||||||
export type ProblemSetBadge = z.infer<typeof problemSetBadgeSchema>
|
export type ProblemSetBadge = z.infer<typeof problemSetBadgeSchema>
|
||||||
export type ProblemSetProblem = z.infer<typeof problemSetProblemSchema>
|
export type ProblemSetProblem = z.infer<typeof problemSetProblemSchema>
|
||||||
export type ProblemSetProgress = z.infer<typeof problemSetProgressSchema>
|
export type ProblemSetClassView = z.infer<typeof problemSetClassViewSchema>
|
||||||
export type ProblemSetProgressList = z.infer<typeof problemSetProgressListSchema>
|
export type ProblemSetClassStudent = z.infer<typeof problemSetClassStudentSchema>
|
||||||
|
export type ProblemSetLock = z.infer<typeof problemSetLockSchema>
|
||||||
export type UserBadge = z.infer<typeof userBadgeSchema>
|
export type UserBadge = z.infer<typeof userBadgeSchema>
|
||||||
export type CompletedProblem = z.infer<typeof completedProblemSchema>
|
export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
|
||||||
|
|
||||||
export type ProblemSetUserProgressSummary = z.infer<typeof problemSetUserProgressSummarySchema>
|
export type ProblemSetUserProgressSummary = z.infer<typeof problemSetUserProgressSummarySchema>
|
||||||
export type JoinProblemSetRequest = z.infer<typeof joinProblemSetRequestSchema>
|
export type JoinProblemSetRequest = z.infer<typeof joinProblemSetRequestSchema>
|
||||||
Reference in new issue
Block a user