From 8f08ed03a02d5c756f48ea4792b8a12c886c564e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 27 Aug 2026 06:14:05 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=B5=81=E7=A8=8B=E5=9B=BE):=20=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=E8=83=BD=E7=BF=BB=E5=87=BA=E5=85=A8=E7=8F=AD=E8=AF=84?= =?UTF-8?q?=E5=88=86=E3=80=81AI=20=E8=B0=83=E7=94=A8=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 列表漏了一道门 代码提交列表在 `routes/submission.ts` 里有 `submission_list_show_all` 兜底: 关掉时非管理员一律返回空。流程图列表**从来没有这道门**,而它的过滤是 if (myself === "1" || (!username && 是普通用户)) 只看自己 else if (username) 按用户名模糊匹配 —— 只要带上 `username`,第二支就把第一支的限制绕过去了。学生在提交记录页把 语言切成「流程图」、用户名框随便填一个字,就能翻出全班同学的 AI 评分,不需要 动接口。补上和代码提交同一套口径。 ## 提交与重判没有限流 每一次流程图提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源,而这两个 入口都没限流。`canView` 还允许**本人**重试自己的提交,等于学生可以对着自己的 提交反复点,无上限地刷 AI 调用。 限流桶不能直接用 `throttling:user:` —— 那是代码提交在用的桶(capacity 20, 回填约 1.8 个/分钟),共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙 交不上去。单独开 `throttling:user:flowchart:`。 重判对教师放行:成批点几十行是他们的正常用法。 ## 提交编号的权限判断在前端自己算了一遍 契约里 `flowchartListItem.showLink` 是后端逐行下发的(与 `GET /flowcharts/:id` 的放行条件同源),前端却没用,自己按「超管或本人」重算了一次 —— 教师因此看得到 「重新判题」却打不开评分详情。 更要命的是无权限那一支渲染的 `n-text` **照样挂着 @click**,权限判断只改了外观。 学生点别人的编号,后端以 404 挡下,`loadSubmission` 只 console.error,于是弹出 一个 600px 高的空白面板,什么提示都没有。 Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/flowchart.ts | 31 ++++++++++++++++++- .../submission/components/FlowchartLink.vue | 18 +++++------ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index e32842c..25f8d64 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -18,6 +18,8 @@ import { config } from "../config" import { db, schema } from "../db" import { failure, success } from "../http" import { flowchartQueue } from "../queue" +import { getBooleanOption } from "../services/options" +import { consumeToken } from "../services/throttling" import { buildWordFrequencies } from "../services/word-frequency" import { isAdminRole, @@ -30,6 +32,11 @@ import { export const flowchartRoutes = new Hono() +// AI 评分单独一个限流桶,与代码提交的 `throttling:user:` 分开计数 +function flowchartThrottleKey(userId: number) { + return `flowchart:${userId}` +} + function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) { return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id } @@ -67,6 +74,13 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => { .where(eq(schema.problem.id, parsed.data.problemId)).limit(1) if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission") + // 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。 + // 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶, + // 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。 + const throttle = await consumeToken("user", flowchartThrottleKey(c.get("user")!.id)) + if (!throttle.allowed) { + return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`) + } const id = randomBytes(16).toString("hex") await db.insert(schema.flowchartSubmission).values({ id, @@ -103,6 +117,12 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { const displayId = c.req.query("problemId")?.trim() const username = c.req.query("username")?.trim() const grade = c.req.query("grade") + // 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉 + // submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门, + // 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。 + if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { + return success(c, flowchartListSchema.parse({ results: [], total: 0 })) + } if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id)) else if (username) filters.push(ilike(schema.user.username, `%${username}%`)) @@ -274,11 +294,20 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => { }) flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { + const user = c.get("user")! const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1) - if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist") + if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist") if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry") + // canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。 + // 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法 + if (!isAdminRole(user)) { + const throttle = await consumeToken("user", flowchartThrottleKey(user.id)) + if (!throttle.allowed) { + return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`) + } + } await db.update(schema.flowchartSubmission).set({ status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null, aiCriteriaDetails: {}, processingTime: null, evaluationTime: null, diff --git a/apps/web/src/oj/submission/components/FlowchartLink.vue b/apps/web/src/oj/submission/components/FlowchartLink.vue index 34a1cdb..04595c9 100644 --- a/apps/web/src/oj/submission/components/FlowchartLink.vue +++ b/apps/web/src/oj/submission/components/FlowchartLink.vue @@ -1,16 +1,15 @@