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:
xuyueandClaude Opus 5.5 committed 2026-10-16 14:53:24 +08:00
1 parent 5787efe023
commit 465dbfd42f
14 files changed
+388 -361

No files matched your search

+1 -1
View File
@@ -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>()
+4 -12
View File
@@ -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 取,不校验归属 ——
+15 -76
View File
@@ -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)
+2 -6
View File
@@ -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) {
+3 -6
View File
@@ -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