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
@@ -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