fix(学生管理员): 260 班看板 23 人、数据统计只有 22 人,学生管理员还能看同学的作业代码——学生管理员一律按学生算
Deploy / deploy (push) Canceled after 0s

- 统计、题目页各班表、流程图统计的班级名单原来只认普通用户,改成和看板、排行榜一样的
  STUDENT_ROLES,学生管理员进名单、进分母
- 看别人的提交代码和流程图只给老师、超管和出题人;题单防抄对学生管理员也生效。
  原来「比赛进行中学生管理员不能看」那条特例随之去掉
- 题单的「用户进度」原来只给题单创建者,其余老师点开报「题单不存在」;改成所有老师都能看,
  和看板、统计对所有班开放一致。后台能改能删的接口仍只给创建者

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-08 14:00:11 +08:00
1 parent 30f34f0e36
commit af2ca31de5
6 files changed
+52 -47

No files matched your search

+14 -7
View File
@@ -11,6 +11,7 @@ import {
type FlowchartStatistics, type FlowchartStatistics,
type FlowchartSubmission, type FlowchartSubmission,
NO_CLASS, NO_CLASS,
STUDENT_ROLES,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm" import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -57,7 +58,7 @@ function hiddenByProblemSet(
row: { userId: number; problemId: number; createTime: string }, row: { userId: number; problemId: number; createTime: string },
joinTimes: Map<number, string>, joinTimes: Map<number, string>,
) { ) {
if (row.userId !== user.id || isAdminRole(user)) return false if (row.userId !== user.id || isTeacherOrAbove(user)) return false
const joinTime = joinTimes.get(row.problemId) const joinTime = joinTimes.get(row.problemId)
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime) return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
} }
@@ -69,12 +70,13 @@ function canView(
joinTimes: Map<number, string>, joinTimes: Map<number, string>,
) { ) {
if (hiddenByProblemSet(user, row, joinTimes)) return false if (hiddenByProblemSet(user, row, joinTimes)) return false
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id // 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
} }
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */ /** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) { function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
if (isAdminRole(user)) return Promise.resolve(new Map<number, string>()) if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId) const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
return problemSetJoinTimes(user.id, [...new Set(own)]) return problemSetJoinTimes(user.id, [...new Set(own)])
} }
@@ -287,7 +289,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己 // 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
const className = c.req.query("className")?.trim() const className = c.req.query("className")?.trim()
const onlyMyself = const onlyMyself =
c.req.query("myself") === "1" || (!username && !className && user.adminType === "Regular User") c.req.query("myself") === "1" ||
(!username && !className && STUDENT_ROLES.includes(user.adminType))
const filters: Array<SQL | undefined> = [] const filters: Array<SQL | undefined> = []
filters.push( filters.push(
...(await Promise.all([ ...(await Promise.all([
@@ -430,7 +433,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
// 不相干的人,当没有花名册 // 不相干的人,当没有花名册
const roster = const roster =
scoped && c.req.query("className")?.trim() !== NO_CLASS scoped && c.req.query("className")?.trim() !== NO_CLASS
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User") ? matched.filter((row) => !row.isDisabled && STUDENT_ROLES.includes(row.adminType))
: [] : []
/** /**
@@ -532,7 +535,11 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
.from(schema.flowchartSubmission) .from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId)) .innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
.where( .where(
and(where, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)), and(
where,
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
) )
.groupBy(schema.user.id, schema.user.username, schema.user.className) .groupBy(schema.user.id, schema.user.username, schema.user.className)
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`) .orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
@@ -664,7 +671,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
.limit(1) .limit(1)
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart]))) if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
return failure(c, 404, "flowchart-not-found", "Submission does not exist") return failure(c, 404, "flowchart-not-found", "Submission does not exist")
// 看得见不等于能重评:canView 放进来的学生管理员、出题人只能看。重评会清掉原来的 // 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
// 评分再抽一次,别人的提交只有老师能动 // 评分再抽一次,别人的提交只有老师能动
if (row.flowchart.userId !== user.id && !isTeacherOrAbove(user)) if (row.flowchart.userId !== user.id && !isTeacherOrAbove(user))
return failure(c, 403, "permission-denied", "权限不足") return failure(c, 403, "permission-denied", "权限不足")
+5 -5
View File
@@ -1,6 +1,6 @@
import { ADMIN_ROLES, NO_CLASS, TEACHER_ROLES, type SampleUser } from "@oj2/contract" import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
import { and, count, eq, ilike, isNull, notInArray, sql } from "drizzle-orm" import { and, count, eq, ilike, inArray, isNull, notInArray, sql } from "drizzle-orm"
import type { AuthUser } from "../auth/session" import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
@@ -163,12 +163,12 @@ export async function countFailedSubmissions(userId: number, problemId: number)
* 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。 * 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。
* 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。 * 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。
* *
* NO_CLASS 只圈普通用户:老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交 * NO_CLASS 只圈学生(含学生管理员,见 STUDENT_ROLES):老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交
* 会混进「没填班级」,还算进做完的人数里(切课那边也只算普通学生,两边口径一致) * 会混进「没填班级」,还算进做完的人数里(切课那边也只算学生,两边口径一致)
*/ */
export function classCondition(className: string) { export function classCondition(className: string) {
return className === NO_CLASS return className === NO_CLASS
? and(isNull(schema.user.className), eq(schema.user.adminType, "Regular User")) ? and(isNull(schema.user.className), inArray(schema.user.adminType, [...STUDENT_ROLES]))
: eq(schema.user.className, className) : eq(schema.user.className, className)
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { type ProblemStats } from "@oj2/contract" import { STUDENT_ROLES, type ProblemStats } from "@oj2/contract"
import { and, count, eq, inArray, sql } from "drizzle-orm" import { and, count, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -185,7 +185,7 @@ async function classRows(problemId: number): Promise<NonNullable<ProblemStats["c
.where( .where(
and( and(
inArray(schema.user.className, names), inArray(schema.user.className, names),
eq(schema.user.adminType, "Regular User"), inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
), ),
) )
+5 -9
View File
@@ -399,10 +399,7 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => { problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db const [problemSet] = await db
.select({ .select({ id: schema.problemset.id })
id: schema.problemset.id,
createdById: schema.problemset.createdById,
})
.from(schema.problemset) .from(schema.problemset)
.where( .where(
and( and(
@@ -412,11 +409,10 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
), ),
) )
.limit(1) .limit(1)
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行, // 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。 // 也是任何老师看任何班,这里和它们一致。后台那条(admin/problemset.ts 的 loadOwned)
// 越权报「不存在」,不泄露题单存在与否。 // 能改能删,仍然只给创建者
const user = c.get("user")! if (!problemSet) {
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
return failure(c, 404, "problem-set-not-found", "题单不存在") return failure(c, 404, "problem-set-not-found", "题单不存在")
} }
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
+13 -7
View File
@@ -8,6 +8,7 @@
import { import {
NO_CLASS, NO_CLASS,
STUDENT_ROLES,
type ProblemLanguage, type ProblemLanguage,
type SubmissionLessons, type SubmissionLessons,
type SubmissionStatistics, type SubmissionStatistics,
@@ -388,8 +389,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
filters.push(matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`) filters.push(matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`)
} }
const where = and(...filters) const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母 // 花名册:只有未禁用的学生(含学生管理员)算进班级人数和「谁没做」,老师不进分母
const enrolled = matched.filter((row) => !row.isDisabled && row.adminType === "Regular User") const enrolled = matched.filter((row) => !row.isDisabled && STUDENT_ROLES.includes(row.adminType))
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉 // 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
@@ -524,7 +525,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
const rosterIds = new Set(rosterRows.map((row) => row.id)) const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => { const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false if (isDone(row)) return false
return scoped ? rosterIds.has(row.userId) : !row.isDisabled && row.adminType === "Regular User" if (scoped) return rosterIds.has(row.userId)
return !row.isDisabled && row.adminType !== null && STUDENT_ROLES.includes(row.adminType)
}) })
const dataAttempted = attemptedRows.map((row) => ({ const dataAttempted = attemptedRows.map((row) => ({
username: row.username, username: row.username,
@@ -587,10 +589,14 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
className: schema.user.className, className: schema.user.className,
}) })
.from(schema.submission) .from(schema.submission)
// 只要普通学生:老师试题的提交不该出现在「谁做了几次」里 // 只要学生(含学生管理员):老师试题的提交不该出现在「谁做了几次」里
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where( .where(
and(...filters, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)), and(
...filters,
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
) )
.orderBy(desc(schema.submission.createTime)) .orderBy(desc(schema.submission.createTime))
.limit(GRID_LIMIT + 1) .limit(GRID_LIMIT + 1)
@@ -672,7 +678,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
.where( .where(
and( and(
inArray(schema.user.className, names as string[]), inArray(schema.user.className, names as string[]),
eq(schema.user.adminType, "Regular User"), inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
), ),
) )
@@ -892,7 +898,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher
.where( .where(
and( and(
inArray(schema.user.className, lessonClasses), inArray(schema.user.className, lessonClasses),
eq(schema.user.adminType, "Regular User"), inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
), ),
) )
+13 -17
View File
@@ -33,7 +33,7 @@ import { getBooleanOption } from "../services/options"
import { problemSetJoinTimes } from "../services/problemset" import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { dayStart } from "../time" import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers" import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
import { import {
classNameFilter, classNameFilter,
exactUsernameFilter, exactUsernameFilter,
@@ -289,26 +289,22 @@ function canViewSubmission(
user: AuthUser | null, user: AuthUser | null,
row: { userId: number; problemId: number; createTime: string }, row: { userId: number; problemId: number; createTime: string },
problem: { createdById: number }, problem: { createdById: number },
contest: typeof schema.contest.$inferSelect | null,
problemSetJoinTime?: Map<number, string>, problemSetJoinTime?: Map<number, string>,
) { ) {
if (!user) return false if (!user) return false
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,管理员不受限,对齐旧后端 // 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,老师不受限。
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
if (row.userId === user.id && !isAdminRole(user)) { if (row.userId === user.id && !isTeacherOrAbove(user)) {
const joinTime = problemSetJoinTime?.get(row.problemId) const joinTime = problemSetJoinTime?.get(row.problemId)
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
} }
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里 // 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
// (contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。 // 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
// 老师和超管不受影响 —— 他们不参赛。旧后端这里是 `not user.is_regular_user()`, // 学生管理员同样放行;OJ2 先收紧到「比赛没结束时不给」,现在整条都不给。
// 学生管理员同样放行,所以这条是 OJ2 相对旧栈**收紧**的一处,不是修回归。
// //
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人, // 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
// 他早就知道答案了,挡他没有意义。 // 他早就知道答案了,挡他没有意义。
const elevated = const elevated = isTeacherOrAbove(user)
isAdminRole(user) &&
!(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
// 这三条就是全部:别人的代码谁都看不到,比赛内外一样。 // 这三条就是全部:别人的代码谁都看不到,比赛内外一样。
// 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉, // 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉,
// 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条 // 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条
@@ -404,10 +400,10 @@ async function submissionDetail(id: string, user: AuthUser) {
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103) // 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const joinTimes = const joinTimes =
isAdminRole(user) || row.submission.userId !== user.id isTeacherOrAbove(user) || row.submission.userId !== user.id
? undefined ? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId]) : await problemSetJoinTimes(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, row.contest, joinTimes)) return null if (!canViewSubmission(user, row.submission, row.problem, joinTimes)) return null
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
@@ -574,7 +570,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
]) ])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([ const [joinTimes, problemsetTitles] = await Promise.all([
user && !isAdminRole(user) user && !isTeacherOrAbove(user)
? problemSetJoinTimes(user.id, [ ? problemSetJoinTimes(user.id, [
...new Set( ...new Set(
rows rows
@@ -593,7 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
id: submission.id, id: submission.id,
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, null, joinTimes) : false, showLink: user ? canViewSubmission(user, submission, problem, joinTimes) : false,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,
@@ -669,7 +665,7 @@ submissionRoutes.get(
id: submission.id, id: submission.id,
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, contest) : false, showLink: user ? canViewSubmission(user, submission, problem) : false,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,