diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 4992b64..76d509d 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -22,7 +22,7 @@ import { checkContestPassword, contestDetailsAllowed, contestStatus, - findVisibleContest, + findAccessibleContest, isContestAdmin, requireContestAccess, type ContestEnv, @@ -91,8 +91,10 @@ contestRoutes.get("/contests", async (c) => { })) }) -contestRoutes.get("/contests/:id", async (c) => { - const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) +// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」—— +// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在 +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") const byId = await creators([contest.createdById]) return success(c, serializeContest( @@ -103,7 +105,7 @@ contestRoutes.get("/contests/:id", 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") const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null)) 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) => { - 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") const access = await canAccessContest(c, contest, "details") 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, problemId: number) { + const status = objectValue(statuses[String(problemId)]).status + return typeof status === "number" ? status : null +} + async function contestProblemTags(problemIds: number[]) { if (problemIds.length === 0) return new Map() 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)) const tags = await contestProblemTags(rows.map((row) => row.problem.id)) 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({ id: problem.id, _id: problem.displayId, @@ -152,7 +178,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess(" allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, 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") const tags = await contestProblemTags([row.problem.id]) const allowed = contestDetailsAllowed(c.get("user"), contest) + const statuses = await contestProblemStatuses(c.get("user")?.id) return success(c, problemDetailSchema.parse({ id: row.problem.id, _id: row.problem.displayId, @@ -189,7 +216,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont contestId: contest.id, tags: tags.get(row.problem.id) ?? [], createdBy: sampleUser(row.user, row.realName), - myStatus: null, + myStatus: myStatusOf(statuses, row.problem.id), + // 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0 myFailedCount: 0, allowFlowchart: row.problem.allowFlowchart, showFlowchart: row.problem.showFlowchart, diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 0c4f58f..da7749c 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -27,7 +27,7 @@ import { judgeQueue } from "../queue" import { canAccessContest, contestStatus, - findVisibleContest, + findAccessibleContest, isContestAdmin, requireContestAccess, type ContestEnv, @@ -68,7 +68,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => { if (parsed.data.contestId) { // 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体, // 中间件跑的时候 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") const access = await canAccessContest(c, contest, "problems") if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts index c21ef30..5c07e64 100644 --- a/apps/api/src/services/contest.ts +++ b/apps/api/src/services/contest.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" -import { and, eq } from "drizzle-orm" +import { eq } from "drizzle-orm" import type { Context, MiddlewareHandler } from "hono" 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 } -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) - .where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1) - return contest ?? null + .where(eq(schema.contest.id, id)).limit(1) + if (!contest) return null + return contest.visible || isContestAdmin(user, contest) ? contest : null } // 泛型而不是写死 Context:requireContestAccess 传进来的是 Context, @@ -93,7 +104,7 @@ export function requireContestAccess( ): MiddlewareHandler { return async (c, next) => { 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") const access = await canAccessContest(c, contest, checkType) if (!access.ok) { diff --git a/apps/web/src/oj/contest/pages/rank.vue b/apps/web/src/oj/contest/pages/rank.vue index 5c65ae3..f25fd59 100644 --- a/apps/web/src/oj/contest/pages/rank.vue +++ b/apps/web/src/oj/contest/pages/rank.vue @@ -272,7 +272,16 @@ async function downloadExcel() { // 监听分页参数变化 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(() => { listRanks() diff --git a/apps/web/src/oj/store/contest.ts b/apps/web/src/oj/store/contest.ts index 4393ce8..058a184 100644 --- a/apps/web/src/oj/store/contest.ts +++ b/apps/web/src/oj/store/contest.ts @@ -61,6 +61,11 @@ export const useContestStore = defineStore("contest", () => { contest.value = res // now 是学生侧比赛专有的服务器时间,用来对齐倒计时 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) { timer = setInterval(() => { now.value = now.value + 1000 @@ -79,6 +84,7 @@ export const useContestStore = defineStore("contest", () => { toggleAccess(false) now.value = 0 if (timer) clearInterval(timer) + timer = 0 } async function checkPassword(contestID: string, password: string) {