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
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
|||||||
checkContestPassword,
|
checkContestPassword,
|
||||||
contestDetailsAllowed,
|
contestDetailsAllowed,
|
||||||
contestStatus,
|
contestStatus,
|
||||||
findVisibleContest,
|
findAccessibleContest,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
requireContestAccess,
|
requireContestAccess,
|
||||||
type ContestEnv,
|
type ContestEnv,
|
||||||
@@ -91,8 +91,10 @@ contestRoutes.get("/contests", async (c) => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id", async (c) => {
|
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
|
||||||
|
contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
|
||||||
|
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
const byId = await creators([contest.createdById])
|
const byId = await creators([contest.createdById])
|
||||||
return success(c, serializeContest(
|
return success(c, serializeContest(
|
||||||
@@ -103,7 +105,7 @@ contestRoutes.get("/contests/:id", async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
|
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
|
||||||
@@ -115,12 +117,35 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
const access = await canAccessContest(c, contest, "details")
|
const access = await canAccessContest(c, contest, "details")
|
||||||
return success(c, contestAccessSchema.parse({ access: access.ok }))
|
return success(c, contestAccessSchema.parse({ access: access.ok }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户在**比赛题**上的做题状态。判题回写记在 user_profile 的
|
||||||
|
* `acm_problems_status.contest_problems`(judge/run.ts),公开题库那份记在 `problems`
|
||||||
|
* 下,两边互不干扰。
|
||||||
|
*
|
||||||
|
* 原来这两条路由一律下发空状态,于是比赛题目页的「状态」列永远是「未做」,赛后也不
|
||||||
|
* 恢复 —— 而库里其实一直记着。
|
||||||
|
*
|
||||||
|
* 不按「比赛结没结束」分档:这是学生自己的判题结果,赛中赛后都不泄露别人的任何信息
|
||||||
|
* (旧后端赛中不下发,纯粹是因为它整条路换了个 serializer,不是什么保密考虑)。
|
||||||
|
*/
|
||||||
|
async function contestProblemStatuses(userId: number | undefined) {
|
||||||
|
if (!userId) return {}
|
||||||
|
const [profile] = await db.select({ status: schema.userProfile.acmProblemsStatus })
|
||||||
|
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
|
||||||
|
return objectValue(objectValue(profile?.status).contest_problems)
|
||||||
|
}
|
||||||
|
|
||||||
|
function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
|
||||||
|
const status = objectValue(statuses[String(problemId)]).status
|
||||||
|
return typeof status === "number" ? status : null
|
||||||
|
}
|
||||||
|
|
||||||
async function contestProblemTags(problemIds: number[]) {
|
async function contestProblemTags(problemIds: number[]) {
|
||||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||||
@@ -139,6 +164,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
|
|||||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
|
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
|
||||||
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
||||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||||
|
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||||
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
|
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
|
||||||
id: problem.id,
|
id: problem.id,
|
||||||
_id: problem.displayId,
|
_id: problem.displayId,
|
||||||
@@ -152,7 +178,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
|
|||||||
allowFlowchart: problem.allowFlowchart,
|
allowFlowchart: problem.allowFlowchart,
|
||||||
showFlowchart: problem.showFlowchart,
|
showFlowchart: problem.showFlowchart,
|
||||||
hasAstRules: problem.astRules !== null,
|
hasAstRules: problem.astRules !== null,
|
||||||
myStatus: null,
|
myStatus: myStatusOf(statuses, problem.id),
|
||||||
})))
|
})))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -165,6 +191,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
|
|||||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
const tags = await contestProblemTags([row.problem.id])
|
const tags = await contestProblemTags([row.problem.id])
|
||||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||||
|
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||||
return success(c, problemDetailSchema.parse({
|
return success(c, problemDetailSchema.parse({
|
||||||
id: row.problem.id,
|
id: row.problem.id,
|
||||||
_id: row.problem.displayId,
|
_id: row.problem.displayId,
|
||||||
@@ -189,7 +216,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
|
|||||||
contestId: contest.id,
|
contestId: contest.id,
|
||||||
tags: tags.get(row.problem.id) ?? [],
|
tags: tags.get(row.problem.id) ?? [],
|
||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
myStatus: null,
|
myStatus: myStatusOf(statuses, row.problem.id),
|
||||||
|
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
|
||||||
myFailedCount: 0,
|
myFailedCount: 0,
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
allowFlowchart: row.problem.allowFlowchart,
|
||||||
showFlowchart: row.problem.showFlowchart,
|
showFlowchart: row.problem.showFlowchart,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { judgeQueue } from "../queue"
|
|||||||
import {
|
import {
|
||||||
canAccessContest,
|
canAccessContest,
|
||||||
contestStatus,
|
contestStatus,
|
||||||
findVisibleContest,
|
findAccessibleContest,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
requireContestAccess,
|
requireContestAccess,
|
||||||
type ContestEnv,
|
type ContestEnv,
|
||||||
@@ -68,7 +68,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
|||||||
if (parsed.data.contestId) {
|
if (parsed.data.contestId) {
|
||||||
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
|
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
|
||||||
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
|
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
|
||||||
const contest = await findVisibleContest(parsed.data.contestId)
|
const contest = await findAccessibleContest(c.get("user"), parsed.data.contestId)
|
||||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
const access = await canAccessContest(c, contest, "problems")
|
const access = await canAccessContest(c, contest, "problems")
|
||||||
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import type { Context, MiddlewareHandler } from "hono"
|
import type { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import type { AppEnv } from "../auth/middleware"
|
import type { AppEnv } from "../auth/middleware"
|
||||||
@@ -48,10 +48,21 @@ export function checkContestPassword(candidate: string | null | undefined, expec
|
|||||||
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findVisibleContest(id: number) {
|
/**
|
||||||
|
* 取一场「这个人看得见」的比赛:公开(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)
|
const [contest] = await db.select().from(schema.contest)
|
||||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
.where(eq(schema.contest.id, id)).limit(1)
|
||||||
return contest ?? null
|
if (!contest) return null
|
||||||
|
return contest.visible || isContestAdmin(user, contest) ? contest : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 泛型而不是写死 Context<AppEnv>:requireContestAccess 传进来的是 Context<ContestEnv>,
|
// 泛型而不是写死 Context<AppEnv>:requireContestAccess 传进来的是 Context<ContestEnv>,
|
||||||
@@ -93,7 +104,7 @@ export function requireContestAccess(
|
|||||||
): MiddlewareHandler<ContestEnv> {
|
): MiddlewareHandler<ContestEnv> {
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const id = Number(c.req.param(paramName))
|
const id = Number(c.req.param(paramName))
|
||||||
const contest = Number.isInteger(id) && id > 0 ? await findVisibleContest(id) : null
|
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")
|
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
const access = await canAccessContest(c, contest, checkType)
|
const access = await canAccessContest(c, contest, checkType)
|
||||||
if (!access.ok) {
|
if (!access.ok) {
|
||||||
|
|||||||
@@ -272,7 +272,16 @@ async function downloadExcel() {
|
|||||||
|
|
||||||
// 监听分页参数变化
|
// 监听分页参数变化
|
||||||
watch([() => query.page, () => query.limit], listRanks)
|
watch([() => query.page, () => query.limit], listRanks)
|
||||||
watch(autoRefresh, (checked) => (checked ? resume() : pause()))
|
|
||||||
|
// 自动刷新只在比赛进行中有意义(开关本身也只在这一档渲染),所以由「开关 + 比赛状态」
|
||||||
|
// 一起驱动。原来只 watch(autoRefresh):开关初值就是 true、进页面不产生变化,而
|
||||||
|
// useIntervalFn 建的时候又传了 immediate: false,于是表从没启动过 —— 开关明明是开着的,
|
||||||
|
// 排名却一直不刷新,得手动关一次再开。
|
||||||
|
watchEffect(() => {
|
||||||
|
const running = contestStore.contestStatus === ContestStatus.underway
|
||||||
|
if (autoRefresh.value && running) resume()
|
||||||
|
else pause()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
listRanks()
|
listRanks()
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const useContestStore = defineStore("contest", () => {
|
|||||||
contest.value = res
|
contest.value = res
|
||||||
// now 是学生侧比赛专有的服务器时间,用来对齐倒计时
|
// now 是学生侧比赛专有的服务器时间,用来对齐倒计时
|
||||||
now.value = getTime(parseISO(res.now ?? res.createTime))
|
now.value = getTime(parseISO(res.now ?? res.createTime))
|
||||||
|
// 先停掉上一轮的表。init() 会被调第二次:detail.vue 在「未开始 → 进行中」那一刻
|
||||||
|
// 重新 init 一次(为了把开赛后才拿得到的题目捞回来),不清的话两个 setInterval
|
||||||
|
// 一起给 now 加 1000,倒计时变两倍速 —— 学生赛前挂着页面就会中招,一场 60 分钟的
|
||||||
|
// 比赛过了 30 分钟页面就显示「已结束」,而服务端其实还在正常收提交。
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
if (contestStatus.value !== ContestStatus.finished) {
|
if (contestStatus.value !== ContestStatus.finished) {
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
now.value = now.value + 1000
|
now.value = now.value + 1000
|
||||||
@@ -79,6 +84,7 @@ export const useContestStore = defineStore("contest", () => {
|
|||||||
toggleAccess(false)
|
toggleAccess(false)
|
||||||
now.value = 0
|
now.value = 0
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
|
timer = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkPassword(contestID: string, password: string) {
|
async function checkPassword(contestID: string, password: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user