fix(阶段4评审收尾): 清掉三条 Minor,顺带一个真 bug

## M4 禁用账号会把学生卡在登录死循环里(唯一学生会撞上的)

`getSessionUser` 对禁用用户返回 null,于是落到 401 `login-required`,
而前端拦截器见到这个码就弹登录框 —— 一个上课上到一半被禁用的学生会陷入
「弹登录框 → 登进去 → 又被弹」,完全看不出发生了什么。

会话解析改成返回 `{ user } | { user: null, reason: "anonymous" | "disabled" }`,
禁用报 403 `account-disabled`(凭证有效、是账号不让用了,和 login 接口对禁用
账号的回法一致)。会话照删,禁用立即生效。前端补一支:清登录态 + 明确提示,
**不弹登录框**。

实测:会话中途 UPDATE is_disabled=true → 同一会话下一个请求
403 `account-disabled`「账号已被禁用,请联系老师」。

## M3 三个端点的守卫写在 handler 体内

submissions/statistics、submissions/:id/rejudge、flowcharts/statistics 的档位
本来就是对的,但写成 handler 里的 if,违背了「守卫要从注册行上看得出来」的约定,
下一个人加同类端点容易漏掉那个 if。改用 requireTeacher / requireSuperAdmin。

实测档位没变:普通学生三个都 403;教师统计接口 200、重判仍 403。

## M2 from-public 的错误码构成比赛存在性预言机

比赛不存在回 `not-found`、存在但不属于你回 `contest-not-found`,带一个已知
有效的 problemId 就能靠错误码枚举出哪些 contestId 真实存在。统一成
`contest-not-found`,和全仓其余跨租户路径一致。

实测:两种情况现在都是 404 contest-not-found。

## 顺带:比赛里的 SQL 题看不到示例数据

改 M2 时 tsc 报 `sqlDisplay` 声明了没用到 —— 查下去是真 bug:
`POST /contests/:id/problems` 把展示数据算出来了,却往库里写死 null
(公开题那两条路径都是对的)。于是比赛里的 SQL 题打开后没有示例数据表和期望结果。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:23:23 -06:00
parent fb22b7d49f
commit cafa92a102
6 changed files with 84 additions and 47 deletions

View File

@@ -478,7 +478,9 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
acceptedNumber: 0,
statisticInfo: {},
isPublic: false,
sqlDisplay: null,
// 上面 generateSqlDisplay 已经把展示数据算好了,之前这里写死 null
// 结果比赛里的 SQL 题打开后看不到示例数据表和期望结果(公开题那两条路径都是对的)
sqlDisplay,
}).returning()
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
return row!
@@ -547,11 +549,14 @@ adminProblemRoutes.post("/contests/:contestId/problems/from-public", requireProb
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
const [problem] = await db.select().from(schema.problem)
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
if (!contest || !problem) return failure(c, 404, "not-found", "Contest or Problem does not exist")
const user = c.get("user")!
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// 「比赛不存在」和「比赛存在但不是你的」必须回同一个码。分开报的话,带一个已知有效的
// problemId 就能靠错误码差异枚举出哪些 contestId 真实存在。全仓其余跨租户路径都是
// 统一码contest 系列一律 contest-not-found这里对齐。
const denyContest =
!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)
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 取,不校验任何东西 ——
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
if (problem.contestId !== null) {

View File

@@ -13,7 +13,7 @@ import {
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
@@ -21,7 +21,6 @@ import { flowchartQueue } from "../queue"
import { buildWordFrequencies } from "../services/word-frequency"
import {
isAdminRole,
isTeacherOrAbove,
objectValue,
queryInteger,
rounded,
@@ -139,10 +138,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const FLOWCHART_COMPLETED = 2
flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user"))) {
return failure(c, 403, "permission-denied", "Teacher permission required")
}
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const end = c.req.query("end")?.trim()
if (!end) return failure(c, 400, "invalid-request", "end is required")
const start = c.req.query("start")?.trim()

View File

@@ -14,7 +14,12 @@ import {
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth } from "../auth/middleware"
import {
optionalAuth,
requireAuth,
requireSuperAdmin,
requireTeacher,
} from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
@@ -34,8 +39,6 @@ import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import {
isAdminRole,
isSuperAdmin,
isTeacherOrAbove,
queryInteger,
rounded,
stripClassPrefix,
@@ -218,10 +221,7 @@ async function matchedStudents(username: string) {
)
}
submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user"))) {
return failure(c, 403, "permission-denied", "Teacher permission required")
}
submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
const range = statisticsRange(c)
if (!range) return failure(c, 400, "invalid-request", "end is required")
@@ -334,10 +334,7 @@ submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => {
)
})
submissionRoutes.post("/submissions/:id/rejudge", requireAuth, async (c) => {
if (!isSuperAdmin(c.get("user"))) {
return failure(c, 403, "permission-denied", "Super admin permission required")
}
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => {
const [row] = await db
.select({ id: schema.submission.id, problemId: schema.submission.problemId })
.from(schema.submission)