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
@@ -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 要匹配整个班),明细那边必须精确到人,口径不同。
|
||||
|
||||
Reference in new issue
Block a user