Files
OJ2/apps/api/src/services/contest.ts
yuetsh 5222e012e1 fix(比赛): 修四处 —— 倒计时两倍速、排名不自动刷新、题目状态恒空、比赛隐藏后审核页取不到代码
查比赛功能时实跑出来的四个问题,都在这一条里修掉:

**倒计时两倍速**(store/contest.ts)。init() 里 setInterval 之前不清旧表,而
detail.vue 在「未开始 → 进行中」那一刻会再 init 一次(为了捞开赛后才拿得到的题),
于是两个 interval 一起给 now 加 1000。学生赛前挂着页面就会中招:一场 60 分钟的
比赛,真过了 30 分钟页面就显示「已结束」、倒计时归零,而服务端还在正常收提交。
ojnext 里就有,是原样搬过来的。

**排名页「开启自动刷新」开着但不刷新**(contest/pages/rank.vue)。useIntervalFn
传的是 immediate: false,而 watch(autoRefresh) 只在开关变化时才 resume ——
开关初值就是 true、进页面不产生变化,表从没启动过,得手动关一次再开。改成
watchEffect,由「开关 + 比赛进行中」共同驱动,顺带不再在赛后空转轮询。同样来自
ojnext。

**比赛题的 myStatus 恒为 null**(routes/contest.ts)。判题其实把状态记进了
user_profile 的 acm_problems_status.contest_problems,只是这两条路由硬编码下发
空值,于是题目页的「状态」列永远是「未做」,赛后也不恢复。旧后端在赛后/管理员
视角是给的,这是回归。不按赛中赛后分档:这是学生自己的判题结果,不泄露别人任何
信息(旧后端赛中不给,只是因为它整条路换了个 serializer)。

**比赛一隐藏,审核页的「查看代码」必 404**(services/contest.ts)。acm-helper
故意不卡 visible(赛后核查恰恰发生在比赛收起来之后),它调的比赛提交列表却卡着,
两边对不上。findVisibleContest 换成 findAccessibleContest:公开的谁都取得到,
隐藏的只有比赛管理员取得到,学生看隐藏比赛照旧 404。

实跑验证(dev 全栈,判题走临时 worker 绕开本机 token 不一致):

- 浏览器跨过开赛时刻挂着不动 —— 墙钟 20.0 秒,倒计时正好减 20 秒(原来会减 40)。
- 排名页停在「无数据」,另一账号提交一发 AC,5 秒内表格自己长出
  `1 student2 1/3 0:00:42`,没刷新页面。
- 学生 AC 后列表和详情都回 myStatus: 0,没做过的另一个学生仍是 null,匿名照旧 401。
- 比赛隐藏 + 已结束:出题人 detail / problems / rank / submissions / acm-helper
  全 200,学生这 5 条全 404,提交也 404,隐藏比赛不进公开列表。
- 排名记账口径未受影响:10 次提交(8 编译失败 + 2 AC)落库 submission_number=9、
  accepted_number=1、total_time=231=ac_time、is_first_ac=true。

tsc / vue-tsc / check:routes(175 条无遮蔽)均干净,测试数据已清库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:19:29 -06:00

117 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto"
import { eq } from "drizzle-orm"
import type { Context, MiddlewareHandler } from "hono"
import type { AppEnv } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { getContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { failure } from "../http"
export type ContestRow = typeof schema.contest.$inferSelect
/**
* 走过 requireContestAccess 的路由,可以从 c.var.contest 直接拿到已鉴权的比赛。
*
* 类型上是可选的(同一个 router 里还有不涉及比赛的路由),所以 handler 里要写 `!`。
* 万一漏挂中间件,这里会在运行时抛错变成 500 —— 吵闹但安全,
* 而漏调 canAccessContest 是静默放行,两者不可同日而语。
*/
export interface ContestEnv extends AppEnv {
Variables: AppEnv["Variables"] & { contest?: ContestRow }
}
export function contestStatus(contest: ContestRow) {
const now = Date.now()
if (Date.parse(contest.startTime) > now) return "1" as const
if (Date.parse(contest.endTime) < now) return "-1" as const
return "0" as const
}
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
}
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
}
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
if (!candidate || !expected) return false
if (candidate === expected) return true
const parts = candidate.split("#")
if (parts.length !== 2) return false
const [signature, expiresAt] = parts
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
}
/**
* 取一场「这个人看得见」的比赛公开visible的谁都取得到隐藏的只有比赛管理员
* (出题人本人 / 超管)取得到,对其余人一律当作不存在。
*
* 原来这里一律卡 visible于是老师赛后把比赛收起来之后核查页的「查看代码」必然 404
* 那个页面自己**故意不卡** visible赛后核查恰恰发生在比赛收起来之后
* admin/contest.ts 的说明),它调的比赛提交列表却卡着,两边对不上。
*
* 放宽的只有出题人自己的视角,学生看隐藏比赛照旧是 404。
*/
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) {
const [contest] = await db.select().from(schema.contest)
.where(eq(schema.contest.id, id)).limit(1)
if (!contest) return null
return contest.visible || isContestAdmin(user, contest) ? contest : null
}
// 泛型而不是写死 Context<AppEnv>requireContestAccess 传进来的是 Context<ContestEnv>
// 它比 AppEnv 多一个变量,而 Hono 的 Context 在 Variables 上是逆变的,写死会类型不兼容。
export async function canAccessContest<E extends AppEnv>(
c: Context<E>,
contest: ContestRow,
checkType: "details" | "problems" | "ranks" | "submissions",
) {
const user = c.get("user")
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
if (isContestAdmin(user, contest)) return { ok: true as const }
if (contest.password) {
const stored = await getContestPassword(c, contest.id)
if (!checkContestPassword(stored, contest.password)) {
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
}
}
if (contestStatus(contest) === "1" && checkType !== "details") {
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
}
return { ok: true as const }
}
/**
* 比赛内容路由的守卫中间件。旧后端用 `@check_contest_permission` 装饰器,漏挂一眼看得出来;
* 手工在 handler 里调 `canAccessContest` 则漏调一次就是静默放行,而且这类路由挂的是
* `optionalAuth`(本身不拦人),从路由注册那一行完全看不出它受保护。这个中间件把
* 「取比赛 → 404 → 鉴权 → 401/403」四步收进注册行里恢复旧后端那种显眼程度。
*
* 通过后比赛对象放进 `c.var.contest`handler 直接取,不必再查一次库。
*
* 注意:`POST /submissions` 用不了它 —— 那里的比赛 id 来自请求体而非路径参数,
* 中间件跑的时候还没解析 body。那一处仍是手工调用见 submission.ts 内的说明。
*/
export function requireContestAccess(
checkType: "details" | "problems" | "ranks" | "submissions",
paramName = "id",
): MiddlewareHandler<ContestEnv> {
return async (c, next) => {
const id = Number(c.req.param(paramName))
const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, checkType)
if (!access.ok) {
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
}
c.set("contest", contest)
await next()
}
}