refactor(后端): 权限判断收到一处,路由文件之间不再互相 import
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5787efe023
commit
465dbfd42f
14 files changed
+388
-361
No files matched your search
@@ -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<AppEnv>()
|
||||
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
/**
|
||||
* 「只能管自己建的」。对齐旧 `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 取,不校验归属 ——
|
||||
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
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<ProblemRow, "contestId" | "createdById">,
|
||||
) {
|
||||
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<string>()
|
||||
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<string, number>()
|
||||
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)
|
||||
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||
|
||||
/** 对齐旧 ensure_created_by:超管放行,其余人只能碰自己建的。越权报「不存在」 */
|
||||
function ownedBy(user: AuthUser, row: { createdById: number }) {
|
||||
return user.adminType === "Super Admin" || row.createdById === user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
||||
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
/** 能管所有题目:超管,或 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
|
||||
|
||||
@@ -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<typeof classMemberIds>
|
||||
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<boolean>`${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<boolean>`${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<number>`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 个平时在用的学生一道都没交(占三分之一),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ContestEnv>()
|
||||
|
||||
@@ -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 要匹配整个班),明细那边必须精确到人,口径不同。
|
||||
|
||||
@@ -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<ContestEnv>()
|
||||
|
||||
|
||||
@@ -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<typeof classMemberIds>
|
||||
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<boolean>`${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<boolean>`${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<number>`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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<string>()
|
||||
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<string, number>()
|
||||
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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
Reference in new issue
Block a user