From e5a6f2d1e7cdcab33a5646272d0d4045784e18f5 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 1 Sep 2026 00:01:58 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E5=8D=95):=20=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81=E8=BF=9B=E5=BA=A6=E5=88=86=E6=AF=8D?= =?UTF-8?q?=E3=80=81=E6=88=AA=E6=AD=A2=E6=97=B6=E9=97=B4=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E7=AD=89=E5=85=AD=E5=A4=84=E9=9B=B6=E7=A2=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## user-progress 缺归属校验 学生端那条 GET /problem-sets/:id/user-progress 只有 requireTeacher,没有归属校验, 任何 Teacher Admin 都能读到别人建的题单的学生名单与进度。补上,口径与后台 loadOwned 一致(超管放行,其余人只能看自己建的),越权报 404。 顺带订正 docs/specs/phase4-review-authz.md:449:那条写着题单进度「显式下发真名」, 与代码不符 —— 学生端这条走 sampleUser 且没传 includeRealName,realName 恒为 null (SQL 里那次 leftJoin userProfile 是白查的)。真正下发真名的是后台那条 GET /admin/problem-sets/:id/progress,结论仍成立,但当时漏掉了归属校验这个缺口。 ## 头部进度的分母 分母只算必做题之后,ProblemSetHeader 还在拿 completedCount / problemsCount 算 —— 前者是必做完成数,后者是总题数。做完全部必做题的人会看到「9 / 10、90%」,而同一张 卡片上又标着「已完成」,题单 6 那 10 个人正是这种。改成读 userProgress,另外把 「另有 N 道选做」标出来,否则「共 10 道题目」和「9 / 9」对不上。 ## 截止时间 end_time 管的不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」, 而学生端一个字都不显示 —— 被挡住的人不知道为什么,也不知道什么时候解锁。头部加一个 带解释的「截止 …」标签;提交列表那个锁图标的说明也补上另外两条解锁路径。 ## 两个必然筛空的筛选器 学生端题单列表的难度、状态两个下拉:线上 16 个题单全是 Easy / active,选「中等」 「困难」「已归档」永远是空列表。撤掉,保留关键词搜索。接口那两个 query 参数留着, 哪天真的用起这两个字段再把 select 加回来。 ## 题目移出题单时的提交记录 旧栈 problemset/signals.py 的 post_delete 会清掉该题在本题单的 ProblemSetSubmission, OJ2 没做,于是那张表一直在攒指向已移出题单的孤儿行。补上。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/src/routes/admin/problemset.ts | 8 ++- apps/api/src/routes/problemset.ts | 15 ++++-- .../problem/components/ProblemSubmission.vue | 2 +- .../components/ProblemSetHeader.vue | 33 +++++++++--- apps/web/src/oj/problemset/list.vue | 53 ++----------------- docs/specs/phase4-review-authz.md | 12 ++++- 6 files changed, 62 insertions(+), 61 deletions(-) diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts index 0f948fb..f299c19 100644 --- a/apps/api/src/routes/admin/problemset.ts +++ b/apps/api/src/routes/admin/problemset.ts @@ -275,8 +275,14 @@ adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeache const deleted = await db.delete(schema.problemsetProblem).where(and( eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id), - )).returning({ id: schema.problemsetProblem.id }) + )).returning({ id: schema.problemsetProblem.id, problemId: schema.problemsetProblem.problemId }) if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中") + // 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。 + // 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。 + await db.delete(schema.problemsetSubmission).where(and( + eq(schema.problemsetSubmission.problemsetId, row.id), + eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId), + )) await resyncProgress(row.id) return success(c, null) }) diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 629e23b..4c2376b 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -365,10 +365,17 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => { problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => { const id = queryInteger(c.req.param("id"), 0, { min: 1 }) - const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and( - eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"), - )).limit(1) - if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") + const [problemSet] = await db.select({ id: schema.problemset.id, createdById: schema.problemset.createdById }) + .from(schema.problemset).where(and( + eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"), + )).limit(1) + // 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行, + // 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。 + // 越权报「不存在」,不泄露题单存在与否。 + const user = c.get("user")! + if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) { + return failure(c, 404, "problem-set-not-found", "题单不存在") + } const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const className = c.req.query("className")?.trim() diff --git a/apps/web/src/oj/problem/components/ProblemSubmission.vue b/apps/web/src/oj/problem/components/ProblemSubmission.vue index bb02a4c..66645e1 100644 --- a/apps/web/src/oj/problem/components/ProblemSubmission.vue +++ b/apps/web/src/oj/problem/components/ProblemSubmission.vue @@ -53,7 +53,7 @@ const columns: DataTableColumn[] = [ h(Icon, { icon: "catppuccin:lock" }), ), default: () => - "这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。", + "这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。", }, ), ]) diff --git a/apps/web/src/oj/problemset/components/ProblemSetHeader.vue b/apps/web/src/oj/problemset/components/ProblemSetHeader.vue index 8eaf331..8976775 100644 --- a/apps/web/src/oj/problemset/components/ProblemSetHeader.vue +++ b/apps/web/src/oj/problemset/components/ProblemSetHeader.vue @@ -1,6 +1,7 @@