From 465dbfd42f0a9cb161bc49ec28d5c972f3172da9 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 16 Oct 2026 14:53:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E5=90=8E=E7=AB=AF):=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=88=A4=E6=96=AD=E6=94=B6=E5=88=B0=E4=B8=80=E5=A4=84?= =?UTF-8?q?=EF=BC=8C=E8=B7=AF=E7=94=B1=E6=96=87=E4=BB=B6=E4=B9=8B=E9=97=B4?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BA=92=E7=9B=B8=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/permissions.ts:「超管或创建者」原来在比赛、题单、比赛题目(4 处)、 services/contest.ts 各抄一遍,「题目全部权限」在题目和标签两个路由各写一份 - services/submission-filters.ts:提交列表的用户名 / 班级 / 题号筛选,原来 submission.ts、flowchart.ts 从 submission-statistics.ts 这个路由文件里拿 - services/class-activity.ts:课堂做题记录的口径和「正在上课的班」,原来后台 conf.ts 从 classroom.ts 路由里拿 - services/problem-tags.ts:标签名去重、按名查标签,原来 tag.ts 从 admin/problem.ts 拿 纯搬家。三种身份(超管 / 老师 / 学生)打了 26 个受影响的接口,改前改后状态码和响应体 逐字节一致,包括「不是自己的比赛 / 题单 / 题目报不存在」那几条。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/conf.ts | 2 +- apps/api/src/routes/admin/contest.ts | 16 +-- apps/api/src/routes/admin/problem.ts | 91 ++----------- apps/api/src/routes/admin/problemset.ts | 8 +- apps/api/src/routes/admin/tag.ts | 9 +- apps/api/src/routes/classroom.ts | 130 ++---------------- apps/api/src/routes/flowchart.ts | 2 +- apps/api/src/routes/submission-statistics.ts | 136 +------------------ apps/api/src/routes/submission.ts | 4 +- apps/api/src/services/class-activity.ts | 130 ++++++++++++++++++ apps/api/src/services/contest.ts | 3 +- apps/api/src/services/permissions.ts | 44 ++++++ apps/api/src/services/problem-tags.ts | 39 ++++++ apps/api/src/services/submission-filters.ts | 135 ++++++++++++++++++ 14 files changed, 388 insertions(+), 361 deletions(-) create mode 100644 apps/api/src/services/class-activity.ts create mode 100644 apps/api/src/services/permissions.ts create mode 100644 apps/api/src/services/problem-tags.ts create mode 100644 apps/api/src/services/submission-filters.ts diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 64141f0..872be03 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -26,7 +26,7 @@ import { STUDENT_ROLES } from "@oj2/contract" import { TEACHER_ROLES } from "../helpers" import { onlineUserIds } from "../../auth/presence" import { JudgeStatus } from "../../judge/status" -import { suggestActiveClass } from "../classroom" +import { suggestActiveClass } from "../../services/class-activity" import { classPrefixCondition, queryInteger } from "../helpers" export const adminConfRoutes = new Hono() diff --git a/apps/api/src/routes/admin/contest.ts b/apps/api/src/routes/admin/contest.ts index 0204d83..f89fd9a 100644 --- a/apps/api/src/routes/admin/contest.ts +++ b/apps/api/src/routes/admin/contest.ts @@ -9,22 +9,14 @@ import { and, count, countDistinct, desc, eq, ilike, inArray } from "drizzle-orm import { Hono } from "hono" import { requireTeacher, type AppEnv } from "../../auth/middleware" -import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { contestStatus } from "../../services/contest" import { queryInteger, sampleUser } from "../helpers" +import { ownsOrSuper } from "../../services/permissions" export const adminContestRoutes = new Hono() -/** - * 「只能管自己建的」。对齐旧 `ensure_created_by`:超管放行,其余人只能碰自己创建的。 - * 越权时报的是**不存在**而不是无权限 —— 不泄露「有这么个东西但你看不到」。 - */ -function ownedBy(user: AuthUser, contest: { createdById: number }) { - return user.adminType === "Super Admin" || contest.createdById === user.id -} - async function serialize(row: { contest: typeof schema.contest.$inferSelect user: typeof schema.user.$inferSelect @@ -132,7 +124,7 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => { adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => { const [row] = await selectContest(queryInteger(c.req.param("id"), 0, { min: 1 })) - if (!row || !ownedBy(c.get("user")!, row.contest)) { + if (!row || !ownsOrSuper(c.get("user")!, row.contest)) { return failure(c, 404, "contest-not-found", "Contest does not exist") } return success(c, await serialize(row)) @@ -170,7 +162,7 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => { const parsed = await parseBody(c, updateContestRequestSchema) if (!parsed.success) return parsed.response const [existing] = await selectContest(id) - if (!existing || !ownedBy(c.get("user")!, existing.contest)) { + if (!existing || !ownsOrSuper(c.get("user")!, existing.contest)) { return failure(c, 404, "contest-not-found", "Contest does not exist") } const error = validatePayload(parsed.data) @@ -307,7 +299,7 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => .from(schema.contest) .where(eq(schema.contest.id, contestId)) .limit(1) - if (!contest || !ownedBy(c.get("user")!, contest)) { + if (!contest || !ownsOrSuper(c.get("user")!, contest)) { return failure(c, 404, "contest-not-found", "Contest does not exist") } // rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 —— diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 3939b05..7d780de 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -30,7 +30,6 @@ import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from " import { Hono } from "hono" import { requireProblemPermission, type AppEnv } from "../../auth/middleware" -import type { AuthUser } from "../../auth/session" import { db, schema, type DbOrTx } from "../../db" import { failure, parseBody, success } from "../../http" import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast" @@ -50,38 +49,15 @@ import { config } from "../../config" import { readFile } from "node:fs/promises" import { resolve } from "node:path" import { getTopReactions } from "../../services/reaction" +import { canEditProblem, canManageAllProblems, ownsOrSuper } from "../../services/permissions" import { problemUserCounts } from "../../services/problem-progress" +import { findTagsByName, normalizeTagNames } from "../../services/problem-tags" import { asRecord, queryInteger, sampleUser } from "../helpers" export const adminProblemRoutes = new Hono() type ProblemRow = typeof schema.problem.$inferSelect -function canManageAll(user: AuthUser) { - return user.adminType === "Super Admin" || user.problemPermission === "All" -} - -/** - * 题目归属判断。比赛题看**比赛**的创建者,公开题看题目自己的创建者 —— - * 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。 - * 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。 - */ -export async function canEdit( - user: AuthUser, - problem: Pick, -) { - if (user.adminType === "Super Admin") return true - if (problem.contestId === null) { - return canManageAll(user) || problem.createdById === user.id - } - const [contest] = await db - .select({ createdById: schema.contest.createdById }) - .from(schema.contest) - .where(eq(schema.contest.id, problem.contestId)) - .limit(1) - return Boolean(contest && contest.createdById === user.id) -} - async function tagNames(problemId: number) { return (await tagNamesFor([problemId])).get(problemId) ?? [] } @@ -103,42 +79,6 @@ async function tagNamesFor(problemIds: number[]) { return result } -/** - * 把一批标签名去空格、去重(大小写不敏感),保留每个名字第一次出现时的原始大小写。 - * 解析和批量打标签两条路都用它,口径对齐旧 resolve_tags。 - */ -export function normalizeTagNames(names: string[]) { - const wanted: string[] = [] - const seen = new Set() - for (const raw of names) { - const name = raw.trim() - if (!name || seen.has(name.toLowerCase())) continue - seen.add(name.toLowerCase()) - wanted.push(name) - } - return wanted -} - -/** - * 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。 - * 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。 - */ -export async function findTagsByName(tx: DbOrTx, names: string[]) { - const map = new Map() - if (names.length === 0) return map - const rows = await tx - .select({ id: schema.problemTag.id, name: schema.problemTag.name }) - .from(schema.problemTag) - .where( - inArray( - sql`lower(${schema.problemTag.name})`, - names.map((name) => name.toLowerCase()), - ), - ) - for (const row of rows) map.set(row.name.toLowerCase(), row.id) - return map -} - /** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */ async function resolveTags(tx: DbOrTx, names: string[]) { const wanted = normalizeTagNames(names) @@ -416,7 +356,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { // library=1 是比赛的「从题库中选择」:要列的是 from-public 实际放行的范围 —— 所有可见的 // 公开题,加上自己能改的。只按 created_by 过滤的话,「仅管理自己创建」的老师一道题都看不到 const library = c.req.query("library") === "1" - if (!canManageAll(user)) { + if (!canManageAllProblems(user)) { const own = eq(schema.problem.createdById, user.id) filters.push(library ? or(eq(schema.problem.visible, true), own)! : own) } @@ -526,7 +466,7 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => { .where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .limit(1) if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!(await canEdit(c.get("user")!, row))) { + if (!(await canEditProblem(c.get("user")!, row))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } return success(c, await serialize(row)) @@ -562,7 +502,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => { .where(eq(schema.problem.id, id)) .limit(1) if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!(await canEdit(c.get("user")!, existing))) { + if (!(await canEditProblem(c.get("user")!, existing))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } const validated = await validateProblem(parsed.data) @@ -611,7 +551,7 @@ adminProblemRoutes.delete("/problems/:id", requireProblemPermission, async (c) = .where(eq(schema.problem.id, id)) .limit(1) if (!existing) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!(await canEdit(c.get("user")!, existing))) { + if (!(await canEditProblem(c.get("user")!, existing))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } return deleteProblem(c, id) @@ -652,7 +592,7 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission .where(eq(schema.contest.id, contestId)) .limit(1) const user = c.get("user")! - if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) { + if (!contest || !ownsOrSuper(user, contest)) { return failure(c, 404, "contest-not-found", "Contest does not exist") } const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) @@ -719,7 +659,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio .where(eq(schema.contest.id, contestId)) .limit(1) const user = c.get("user")! - if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) { + if (!contest || !ownsOrSuper(user, contest)) { return failure(c, 404, "contest-not-found", "Contest does not exist") } const parsed = await parseBody(c, createProblemRequestSchema) @@ -754,7 +694,7 @@ adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, a // 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传, // 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。 // 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。 - if (!(await canEdit(c.get("user")!, problem))) { + if (!(await canEditProblem(c.get("user")!, problem))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } if (!problem.contestId || problem.isPublic) { @@ -804,8 +744,7 @@ adminProblemRoutes.post( // 「比赛不存在」和「比赛存在但不是你的」必须回同一个码。分开报的话,带一个已知有效的 // problemId 就能靠错误码差异枚举出哪些 contestId 真实存在。全仓其余跨租户路径都是 // 统一码(contest 系列一律 contest-not-found),这里对齐。 - const denyContest = - !contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id) + const denyContest = !contest || !ownsOrSuper(user, contest) if (denyContest) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") // 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 —— @@ -813,7 +752,7 @@ adminProblemRoutes.post( if (problem.contestId !== null) { return failure(c, 400, "not-a-public-problem", "只能从公开题库添加题目") } - if (!problem.visible && !(await canEdit(user, problem))) { + if (!problem.visible && !(await canEditProblem(user, problem))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } if (contestStatus(contest) === "-1") @@ -863,7 +802,7 @@ adminProblemRoutes.put( .where(eq(schema.contest.id, contestId)) .limit(1) const user = c.get("user")! - if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) { + if (!contest || !ownsOrSuper(user, contest)) { return failure(c, 404, "contest-not-found", "Contest does not exist") } if (contestStatus(contest) === "-1") @@ -930,7 +869,7 @@ adminProblemRoutes.get("/problems/:id/test-cases", requireProblemPermission, asy .where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .limit(1) if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!(await canEdit(c.get("user")!, problem))) { + if (!(await canEditProblem(c.get("user")!, problem))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } try { @@ -959,7 +898,7 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as .where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .limit(1) if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!(await canEdit(c.get("user")!, problem))) { + if (!(await canEditProblem(c.get("user")!, problem))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } const info = await readInfo(problem.testCaseId) @@ -985,7 +924,7 @@ adminProblemRoutes.get("/problems/:id/test-case-files", requireProblemPermission .where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) .limit(1) if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!(await canEdit(c.get("user")!, problem))) { + if (!(await canEditProblem(c.get("user")!, problem))) { return failure(c, 404, "problem-not-found", "Problem does not exist") } const info = await readInfo(problem.testCaseId) diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts index 1fd927f..bf8e98e 100644 --- a/apps/api/src/routes/admin/problemset.ts +++ b/apps/api/src/routes/admin/problemset.ts @@ -21,16 +21,12 @@ import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { recalculateBadge, resyncProgress } from "../../services/problemset" import { queryInteger, sampleUser } from "../helpers" +import { ownsOrSuper } from "../../services/permissions" export const adminProblemSetRoutes = new Hono() type BadgeRow = typeof schema.problemsetBadge.$inferSelect -/** 对齐旧 ensure_created_by:超管放行,其余人只能碰自己建的。越权报「不存在」 */ -function ownedBy(user: AuthUser, row: { createdById: number }) { - return user.adminType === "Super Admin" || row.createdById === user.id -} - /** * 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 —— * 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。 @@ -42,7 +38,7 @@ async function loadOwned(c: { req: { param(name: string): string } }, user: Auth .from(schema.problemset) .where(eq(schema.problemset.id, id)) .limit(1) - return row && ownedBy(user, row) ? row : null + return row && ownsOrSuper(user, row) ? row : null } async function serialize(row: typeof schema.problemset.$inferSelect) { diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 9389aad..550552b 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -14,19 +14,16 @@ import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, ne, sql } fr import { Hono } from "hono" import { requireProblemPermission, type AppEnv } from "../../auth/middleware" -import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { completeChat } from "../../services/ai" import { asRecord, queryInteger } from "../helpers" -import { canEdit, findTagsByName, normalizeTagNames } from "./problem" +import { canEditProblem, canManageAllProblems } from "../../services/permissions" +import { findTagsByName, normalizeTagNames } from "../../services/problem-tags" export const adminTagRoutes = new Hono() /** 能管所有题目:超管,或 problemPermission 为 All */ -function canManageAllProblems(user: AuthUser) { - return user.adminType === "Super Admin" || user.problemPermission === "All" -} // ---------------------------------------------------------------- 标签 @@ -244,7 +241,7 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async ( const user = c.get("user")! // 和编辑、删除同一个口径(canEdit):比赛题看比赛的创建者。原来只看题目的创建者, // 而从题库加进比赛的题保留原作者,老师在自己比赛里点「可见」一律 404 - if (!(await canEdit(user, problem))) { + if (!(await canEditProblem(user, problem))) { return failure(c, 404, "problem-not-found", "题目不存在") } await db diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 0b8f001..6bd292f 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -11,7 +11,6 @@ import { type LastVisit, type LessonLanguage, type StatisticInfo, - FLOWCHART_PASS_GRADES, } from "@oj2/contract" import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm" import { Hono } from "hono" @@ -22,7 +21,14 @@ import { getLoginWindow } from "../auth/session" import { presenceOf } from "../collab/state" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" -import { JudgeStatus } from "../judge/status" +import { + activityRows, + classMemberIds, + FLOWCHART_PASSED, + lessonLanguageFilter, + SOLVED_RESULTS, + suggestActiveClass, +} from "../services/class-activity" import { accepted } from "../services/learning-stats" import { calendarDay, dayStart, localTime } from "../time" @@ -37,9 +43,6 @@ const CLASS_ACTIVITY_MIN_USERS = 5 /** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */ const CLASS_ACTIVITY_LOOKBACK_DAYS = 7 const CLASS_ACTIVITY_LIMIT = 8 -/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ -const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] - interface ClassDayGroup { day: string problemId: number @@ -48,93 +51,6 @@ interface ClassDayGroup { acceptedCount: number } -/** - * 流程图画到 A / S 算这道题做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。 - * 等级由分数推出(flowchart/grade.ts),重新评分会先把 grade 清空,所以 status = 2 其实 - * 是冗余的,留着是把「评完了」写明。 - */ -const FLOWCHART_PASSED = and( - eq(schema.flowchartSubmission.status, 2), - inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]), -)! - -/** 这个班正常状态的学生,给下面的查询当 `user_id in (...)` 用 */ -function classMemberIds(className: string) { - return db - .select({ id: schema.user.id }) - .from(schema.user) - .where( - and( - eq(schema.user.className, className), - eq(schema.user.isDisabled, false), - inArray(schema.user.adminType, [...STUDENT_ROLES]), - ), - ) -} - -/** - * 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计 - * (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数, - * 口径和课堂条 / 看板的「做完」一致: - * - * - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败; - * - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。 - * - * 有的题一节课全班都在画流程图、代码提交个位数,只数代码的话这节课在统计里等于没上。 - * 两边都按 `user_id in (...)` + `create_time >=` 走各自的 (user_id, create_time) 索引。 - */ -/** - * 老师布置时选了语言:代码只数这个语言交的。SQL 题例外 —— 它只收 SQL,按 C 去数的话, - * C 的作业里夹一道 SQL 题,学生怎么交都算不了做完。非 SQL 题交不进 SQL(提交接口按题目的 - * 语言拦),所以一律放行 SQL 不会把别的题算宽 - */ -function lessonLanguageFilter(language: LessonLanguage | null | undefined) { - return language ? inArray(schema.submission.language, [language, "SQL"]) : undefined -} - -function activityRows(options: { - since: string - users?: number[] | ReturnType - problemIds?: number[] - /** 老师布置时选了语言:代码只数这个语言交的(见 lessonLanguageFilter),流程图不受影响 */ - language?: LessonLanguage | null -}) { - const { since, users, problemIds, language } = options - const code = db - .select({ - userId: schema.submission.userId, - problemId: schema.submission.problemId, - createTime: schema.submission.createTime, - solved: sql`${inArray(schema.submission.result, SOLVED_RESULTS)}`.as("solved"), - }) - .from(schema.submission) - .where( - and( - isNull(schema.submission.contestId), - gte(schema.submission.createTime, since), - users ? inArray(schema.submission.userId, users) : undefined, - problemIds ? inArray(schema.submission.problemId, problemIds) : undefined, - lessonLanguageFilter(language), - ), - ) - const flowchart = db - .select({ - userId: schema.flowchartSubmission.userId, - problemId: schema.flowchartSubmission.problemId, - createTime: schema.flowchartSubmission.createTime, - solved: sql`${FLOWCHART_PASSED}`.as("solved"), - }) - .from(schema.flowchartSubmission) - .where( - and( - gte(schema.flowchartSubmission.createTime, since), - users ? inArray(schema.flowchartSubmission.userId, users) : undefined, - problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined, - ), - ) - return code.unionAll(flowchart).as("activity") -} - /** * 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生, * 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛, @@ -468,36 +384,6 @@ const RECENT_LESSONS = 5 /** 往回找最近几节课时最多看多远:寒暑假之后开学第一节课,前面几节是上学期的,没意义 */ const RECENT_LESSONS_WINDOW_DAYS = 45 -/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ -const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 - -/** - * 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课 - * 的班悄悄留在框里,老师看的整个是别人的班)。 - */ -export async function suggestActiveClass() { - // 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班 - const activity = activityRows({ - since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(), - }) - const users = sql`count(distinct ${activity.userId})::int` - const [row] = await db - .select({ className: schema.user.className, users }) - .from(activity) - .innerJoin(schema.user, eq(schema.user.id, activity.userId)) - .where( - and( - isNotNull(schema.user.className), - eq(schema.user.isDisabled, false), - inArray(schema.user.adminType, [...STUDENT_ROLES]), - ), - ) - .groupBy(schema.user.className) - .orderBy(desc(users)) - .limit(1) - return row?.className ?? null -} - /** * 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、 * 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一), diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 3202008..b6e6156 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -26,7 +26,7 @@ import { problemSetLockCutoffs, recordSolvedAndNotify } from "../services/proble import { consumeToken } from "../services/throttling" import { buildWordFrequencies } from "../services/word-frequency" import { dayStart } from "../time" -import { unknownDisplayIds } from "./submission-statistics" +import { unknownDisplayIds } from "../services/submission-filters" import { classCondition, isAdminRole, diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 6cca04b..6193282 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -15,7 +15,7 @@ import { type SubmissionStatisticsGrid, type TodaySubmissionStatistics, } from "@oj2/contract" -import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" +import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, requireTeacher } from "../auth/middleware" @@ -25,14 +25,7 @@ import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/s import { type ContestEnv } from "../services/contest" import { getBooleanOption } from "../services/options" import { dayStart, localTime } from "../time" -import { - classCondition, - isAdminRole, - parseDisplayIds, - rounded, - scopedUsers, - stripClassPrefix, -} from "./helpers" +import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers" export const submissionStatisticsRoutes = new Hono() @@ -191,131 +184,6 @@ async function findPublicProblemsByDisplayIds(displayIds: string[]) { return { ids: rows.map((row) => row.id), missing: missing ?? null } } -/** - * 两条提交列表的用户名筛选。**两边都要匹配**: - * - * - `user_id in (改过名的当前用户名匹配到的账号)` —— 老师用现在的班级前缀查 - * `ks248`,要能查出这个人改名之前交的那些(生产快照:比赛提交里有 685 条 - * 挂在旧名字下); - * - `submission.username ilike` —— 已删号的学生在 `user` 表里没有行,只剩提交里 - * 冻结的那份名字;顺带也让「按记得的旧名字查」还查得到。 - * - * 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来 - * 就不在花名册里);这两条是公开列表,不该因为改名或删号少给记录,所以取并集。 - * - * 账号那一支**先查出 id 再拼成字面列表**,不写成 `user_id in (子查询)`:子查询夹在 OR - * 里会被做成 hashed SubPlan,整条 OR 就不可索引,加了 trigram 索引照样全表扫。拆开之后 - * 两支各走各的索引(submission_public_metrics_idx + submission_public_username_trgm_idx), - * 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms, - * 和原来持平。 - */ -export async function usernameFilter(username: string) { - const like = `%${username}%` - const users = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(ilike(schema.user.username, like)) - const frozen = ilike(schema.submission.username, like) - return users.length - ? or( - inArray( - schema.submission.userId, - users.map((row) => row.id), - ), - frozen, - )! - : frozen -} - -/** - * 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里), - * 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件 - */ -export async function exactUsernameFilter(username: string) { - const [row] = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(eq(schema.user.username, username)) - .limit(1) - return row ? eq(schema.submission.userId, row.id) : sql`false` -} - -/** - * 按班级筛:`user.class_name` 精确匹配。原来列表只能拿用户名前缀(`ks253`)当班级, - * 那是「包含」匹配 —— ks253 会顺带捞进 ks2531 这类撞前缀的号,也要老师记得前缀怎么拼。 - * 查无此班留恒假条件 - */ -export async function classNameFilter(className: string) { - const users = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(classCondition(className)) - return users.length - ? inArray( - schema.submission.userId, - users.map((row) => row.id), - ) - : sql`false` -} - -/** - * 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。 - * 可以一次填几道(`1021,1022`),**任一道**对上就算。 - * 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能 - * 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。 - * - * 公开列表只认公开题、比赛列表只认本场的题:题号只在这个范围内唯一(比赛题的 `_id` - * 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。 - * 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。 - */ -/** - * 题号框里查不到的那几个(范围同 problemFilter:公开列表只认公开题,比赛列表只认本场的题)。 - * 列表把它们回给前端,打错一个字的时候老师能看到提示 - */ -export async function unknownDisplayIds(displayId: string, contestId: number | null) { - const ids = parseDisplayIds(displayId) - if (!ids.length) return [] - const rows = await db - .select({ displayId: schema.problem.displayId }) - .from(schema.problem) - .where( - and( - inArray( - sql`lower(${schema.problem.displayId})`, - ids.map((id) => id.toLowerCase()), - ), - contestId === null - ? isNull(schema.problem.contestId) - : eq(schema.problem.contestId, contestId), - ), - ) - const found = new Set(rows.map((row) => row.displayId.toLowerCase())) - return ids.filter((id) => !found.has(id.toLowerCase())) -} - -export async function problemFilter(displayId: string, contestId: number | null) { - // 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套 - const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase()) - if (!lowered.length) return sql`false` - const problems = await db - .select({ id: schema.problem.id }) - .from(schema.problem) - .where( - and( - inArray(sql`lower(${schema.problem.displayId})`, lowered), - contestId === null - ? isNull(schema.problem.contestId) - : eq(schema.problem.contestId, contestId), - ), - ) - return problems.length - ? inArray( - schema.submission.problemId, - problems.map((row) => row.id), - ) - : sql`false` -} - /** * 两个统计接口共用的范围:时间窗 + 题号。**用户名不在里面** —— 统计那边是 * ilike 模糊匹配(填 ks251 要匹配整个班),明细那边必须精确到人,口径不同。 diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 8c78d14..c465391 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -38,10 +38,10 @@ import { classNameFilter, exactUsernameFilter, problemFilter, - submissionStatisticsRoutes, unknownDisplayIds, usernameFilter, -} from "./submission-statistics" +} from "../services/submission-filters" +import { submissionStatisticsRoutes } from "./submission-statistics" export const submissionRoutes = new Hono() diff --git a/apps/api/src/services/class-activity.ts b/apps/api/src/services/class-activity.ts new file mode 100644 index 0000000..5b4622b --- /dev/null +++ b/apps/api/src/services/class-activity.ts @@ -0,0 +1,130 @@ +import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LessonLanguage } from "@oj2/contract" +import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from "drizzle-orm" + +import { db, schema } from "../db" +import { JudgeStatus } from "../judge/status" + +/** + * 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」 + * 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班, + * 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。 + */ +/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ +export const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] + +/** + * 流程图画到 A / S 算这道题做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。 + * 等级由分数推出(flowchart/grade.ts),重新评分会先把 grade 清空,所以 status = 2 其实 + * 是冗余的,留着是把「评完了」写明。 + */ +export const FLOWCHART_PASSED = and( + eq(schema.flowchartSubmission.status, 2), + inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]), +)! + +/** 这个班正常状态的学生,给下面的查询当 `user_id in (...)` 用 */ +export function classMemberIds(className: string) { + return db + .select({ id: schema.user.id }) + .from(schema.user) + .where( + and( + eq(schema.user.className, className), + eq(schema.user.isDisabled, false), + inArray(schema.user.adminType, [...STUDENT_ROLES]), + ), + ) +} + +/** + * 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计 + * (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数, + * 口径和课堂条 / 看板的「做完」一致: + * + * - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败; + * - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。 + * + * 有的题一节课全班都在画流程图、代码提交个位数,只数代码的话这节课在统计里等于没上。 + * 两边都按 `user_id in (...)` + `create_time >=` 走各自的 (user_id, create_time) 索引。 + */ +/** + * 老师布置时选了语言:代码只数这个语言交的。SQL 题例外 —— 它只收 SQL,按 C 去数的话, + * C 的作业里夹一道 SQL 题,学生怎么交都算不了做完。非 SQL 题交不进 SQL(提交接口按题目的 + * 语言拦),所以一律放行 SQL 不会把别的题算宽 + */ +export function lessonLanguageFilter(language: LessonLanguage | null | undefined) { + return language ? inArray(schema.submission.language, [language, "SQL"]) : undefined +} + +export function activityRows(options: { + since: string + users?: number[] | ReturnType + problemIds?: number[] + /** 老师布置时选了语言:代码只数这个语言交的(见 lessonLanguageFilter),流程图不受影响 */ + language?: LessonLanguage | null +}) { + const { since, users, problemIds, language } = options + const code = db + .select({ + userId: schema.submission.userId, + problemId: schema.submission.problemId, + createTime: schema.submission.createTime, + solved: sql`${inArray(schema.submission.result, SOLVED_RESULTS)}`.as("solved"), + }) + .from(schema.submission) + .where( + and( + isNull(schema.submission.contestId), + gte(schema.submission.createTime, since), + users ? inArray(schema.submission.userId, users) : undefined, + problemIds ? inArray(schema.submission.problemId, problemIds) : undefined, + lessonLanguageFilter(language), + ), + ) + const flowchart = db + .select({ + userId: schema.flowchartSubmission.userId, + problemId: schema.flowchartSubmission.problemId, + createTime: schema.flowchartSubmission.createTime, + solved: sql`${FLOWCHART_PASSED}`.as("solved"), + }) + .from(schema.flowchartSubmission) + .where( + and( + gte(schema.flowchartSubmission.createTime, since), + users ? inArray(schema.flowchartSubmission.userId, users) : undefined, + problemIds ? inArray(schema.flowchartSubmission.problemId, problemIds) : undefined, + ), + ) + return code.unionAll(flowchart).as("activity") +} + +/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ +const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 + +/** + * 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课 + * 的班悄悄留在框里,老师看的整个是别人的班)。 + */ +export async function suggestActiveClass() { + // 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班 + const activity = activityRows({ + since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(), + }) + const users = sql`count(distinct ${activity.userId})::int` + const [row] = await db + .select({ className: schema.user.className, users }) + .from(activity) + .innerJoin(schema.user, eq(schema.user.id, activity.userId)) + .where( + and( + isNotNull(schema.user.className), + eq(schema.user.isDisabled, false), + inArray(schema.user.adminType, [...STUDENT_ROLES]), + ), + ) + .groupBy(schema.user.className) + .orderBy(desc(users)) + .limit(1) + return row?.className ?? null +} diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts index e568404..3cc8f36 100644 --- a/apps/api/src/services/contest.ts +++ b/apps/api/src/services/contest.ts @@ -17,6 +17,7 @@ import type { AuthUser } from "../auth/session" import { getContestPassword } from "../auth/session" import { db, schema } from "../db" import { failure } from "../http" +import { ownsOrSuper } from "./permissions" export type ContestRow = typeof schema.contest.$inferSelect @@ -39,7 +40,7 @@ export function contestStatus(contest: ContestRow) { } export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) { - return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin")) + return Boolean(user && ownsOrSuper(user, contest)) } export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) { diff --git a/apps/api/src/services/permissions.ts b/apps/api/src/services/permissions.ts new file mode 100644 index 0000000..5b9bd6f --- /dev/null +++ b/apps/api/src/services/permissions.ts @@ -0,0 +1,44 @@ +import { eq } from "drizzle-orm" + +import type { AuthUser } from "../auth/session" +import { db, schema } from "../db" + +/** + * 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、 + * 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里 + * 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。 + */ + +/** + * 「只能管自己建的」:超管放行,其余人只能碰自己创建的。对齐旧后端 `ensure_created_by`。 + * 越权时调用方一律报**不存在**而不是无权限 —— 不泄露「有这么个东西但你看不到」。 + */ +export function ownsOrSuper(user: AuthUser, row: { createdById: number }) { + return user.adminType === "Super Admin" || row.createdById === user.id +} + +/** 公开题的「全部」权限:超管,或题目权限是 All */ +export function canManageAllProblems(user: AuthUser) { + return user.adminType === "Super Admin" || user.problemPermission === "All" +} + +/** + * 题目归属判断。比赛题看**比赛**的创建者,公开题看题目自己的创建者 —— + * 对齐旧后端:`ensure_created_by(problem.contest, user)` vs `ensure_created_by(problem, user)`。 + * 一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。 + */ +export async function canEditProblem( + user: AuthUser, + problem: { contestId: number | null; createdById: number }, +) { + if (user.adminType === "Super Admin") return true + if (problem.contestId === null) { + return canManageAllProblems(user) || problem.createdById === user.id + } + const [contest] = await db + .select({ createdById: schema.contest.createdById }) + .from(schema.contest) + .where(eq(schema.contest.id, problem.contestId)) + .limit(1) + return Boolean(contest && contest.createdById === user.id) +} diff --git a/apps/api/src/services/problem-tags.ts b/apps/api/src/services/problem-tags.ts new file mode 100644 index 0000000..68d740b --- /dev/null +++ b/apps/api/src/services/problem-tags.ts @@ -0,0 +1,39 @@ +import { inArray, sql } from "drizzle-orm" + +import { schema, type DbOrTx } from "../db" + +/** + * 把一批标签名去空格、去重(大小写不敏感),保留每个名字第一次出现时的原始大小写。 + * 解析和批量打标签两条路都用它,口径对齐旧 resolve_tags。 + */ +export function normalizeTagNames(names: string[]) { + const wanted: string[] = [] + const seen = new Set() + for (const raw of names) { + const name = raw.trim() + if (!name || seen.has(name.toLowerCase())) continue + seen.add(name.toLowerCase()) + wanted.push(name) + } + return wanted +} + +/** + * 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。 + * 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。 + */ +export async function findTagsByName(tx: DbOrTx, names: string[]) { + const map = new Map() + if (names.length === 0) return map + const rows = await tx + .select({ id: schema.problemTag.id, name: schema.problemTag.name }) + .from(schema.problemTag) + .where( + inArray( + sql`lower(${schema.problemTag.name})`, + names.map((name) => name.toLowerCase()), + ), + ) + for (const row of rows) map.set(row.name.toLowerCase(), row.id) + return map +} diff --git a/apps/api/src/services/submission-filters.ts b/apps/api/src/services/submission-filters.ts new file mode 100644 index 0000000..83583ff --- /dev/null +++ b/apps/api/src/services/submission-filters.ts @@ -0,0 +1,135 @@ +import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" + +import { db, schema } from "../db" +import { classCondition, parseDisplayIds } from "../routes/helpers" + +/** + * 两条提交列表(公开列表、比赛里的列表)的筛选条件:用户名、班级、题号。 + * 原来写在 routes/submission-statistics.ts 里,submission.ts 和 flowchart.ts 都要从 + * 另一个路由文件 import —— 挪到 services 来。 + */ + +/** + * 两条提交列表的用户名筛选。**两边都要匹配**: + * + * - `user_id in (改过名的当前用户名匹配到的账号)` —— 老师用现在的班级前缀查 + * `ks248`,要能查出这个人改名之前交的那些(生产快照:比赛提交里有 685 条 + * 挂在旧名字下); + * - `submission.username ilike` —— 已删号的学生在 `user` 表里没有行,只剩提交里 + * 冻结的那份名字;顺带也让「按记得的旧名字查」还查得到。 + * + * 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来 + * 就不在花名册里);这两条是公开列表,不该因为改名或删号少给记录,所以取并集。 + * + * 账号那一支**先查出 id 再拼成字面列表**,不写成 `user_id in (子查询)`:子查询夹在 OR + * 里会被做成 hashed SubPlan,整条 OR 就不可索引,加了 trigram 索引照样全表扫。拆开之后 + * 两支各走各的索引(submission_public_metrics_idx + submission_public_username_trgm_idx), + * 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms, + * 和原来持平。 + */ +export async function usernameFilter(username: string) { + const like = `%${username}%` + const users = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(ilike(schema.user.username, like)) + const frozen = ilike(schema.submission.username, like) + return users.length + ? or( + inArray( + schema.submission.userId, + users.map((row) => row.id), + ), + frozen, + )! + : frozen +} + +/** + * 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里), + * 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件 + */ +export async function exactUsernameFilter(username: string) { + const [row] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1) + return row ? eq(schema.submission.userId, row.id) : sql`false` +} + +/** + * 按班级筛:`user.class_name` 精确匹配。原来列表只能拿用户名前缀(`ks253`)当班级, + * 那是「包含」匹配 —— ks253 会顺带捞进 ks2531 这类撞前缀的号,也要老师记得前缀怎么拼。 + * 查无此班留恒假条件 + */ +export async function classNameFilter(className: string) { + const users = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(classCondition(className)) + return users.length + ? inArray( + schema.submission.userId, + users.map((row) => row.id), + ) + : sql`false` +} + +/** + * 题号框里查不到的那几个(范围同 problemFilter:公开列表只认公开题,比赛列表只认本场的题)。 + * 列表把它们回给前端,打错一个字的时候老师能看到提示 + */ +export async function unknownDisplayIds(displayId: string, contestId: number | null) { + const ids = parseDisplayIds(displayId) + if (!ids.length) return [] + const rows = await db + .select({ displayId: schema.problem.displayId }) + .from(schema.problem) + .where( + and( + inArray( + sql`lower(${schema.problem.displayId})`, + ids.map((id) => id.toLowerCase()), + ), + contestId === null + ? isNull(schema.problem.contestId) + : eq(schema.problem.contestId, contestId), + ), + ) + const found = new Set(rows.map((row) => row.displayId.toLowerCase())) + return ids.filter((id) => !found.has(id.toLowerCase())) +} + +/** + * 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。 + * 可以一次填几道(`1021,1022`),**任一道**对上就算。 + * 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能 + * 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。 + * + * 公开列表只认公开题、比赛列表只认本场的题:题号只在这个范围内唯一(比赛题的 `_id` + * 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。 + * 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。 + */ +export async function problemFilter(displayId: string, contestId: number | null) { + // 一次可以筛几道(「这节课」那颗按钮一带就是五道),分隔符和统计面板同一套 + const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase()) + if (!lowered.length) return sql`false` + const problems = await db + .select({ id: schema.problem.id }) + .from(schema.problem) + .where( + and( + inArray(sql`lower(${schema.problem.displayId})`, lowered), + contestId === null + ? isNull(schema.problem.contestId) + : eq(schema.problem.contestId, contestId), + ), + ) + return problems.length + ? inArray( + schema.submission.problemId, + problems.map((row) => row.id), + ) + : sql`false` +}