fix(流程图): 学生能翻出全班评分、AI 调用没有限流

## 列表漏了一道门

代码提交列表在 `routes/submission.ts` 里有 `submission_list_show_all` 兜底:
关掉时非管理员一律返回空。流程图列表**从来没有这道门**,而它的过滤是

    if (myself === "1" || (!username && 是普通用户)) 只看自己
    else if (username) 按用户名模糊匹配

—— 只要带上 `username`,第二支就把第一支的限制绕过去了。学生在提交记录页把
语言切成「流程图」、用户名框随便填一个字,就能翻出全班同学的 AI 评分,不需要
动接口。补上和代码提交同一套口径。

## 提交与重判没有限流

每一次流程图提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源,而这两个
入口都没限流。`canView` 还允许**本人**重试自己的提交,等于学生可以对着自己的
提交反复点,无上限地刷 AI 调用。

限流桶不能直接用 `throttling:user:<id>` —— 那是代码提交在用的桶(capacity 20,
回填约 1.8 个/分钟),共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙
交不上去。单独开 `throttling:user:flowchart:<id>`。

重判对教师放行:成批点几十行是他们的正常用法。

## 提交编号的权限判断在前端自己算了一遍

契约里 `flowchartListItem.showLink` 是后端逐行下发的(与 `GET /flowcharts/:id`
的放行条件同源),前端却没用,自己按「超管或本人」重算了一次 —— 教师因此看得到
「重新判题」却打不开评分详情。

更要命的是无权限那一支渲染的 `n-text` **照样挂着 @click**,权限判断只改了外观。
学生点别人的编号,后端以 404 挡下,`loadSubmission` 只 console.error,于是弹出
一个 600px 高的空白面板,什么提示都没有。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 06:14:05 -06:00
parent 64facc5701
commit 8f08ed03a0
2 changed files with 37 additions and 12 deletions

View File

@@ -18,6 +18,8 @@ import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { flowchartQueue } from "../queue" import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency" import { buildWordFrequencies } from "../services/word-frequency"
import { import {
isAdminRole, isAdminRole,
@@ -30,6 +32,11 @@ import {
export const flowchartRoutes = new Hono<AppEnv>() export const flowchartRoutes = new Hono<AppEnv>()
// AI 评分单独一个限流桶,与代码提交的 `throttling:user:<id>` 分开计数
function flowchartThrottleKey(userId: number) {
return `flowchart:${userId}`
}
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) { function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id 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) .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) 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") 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") const id = randomBytes(16).toString("hex")
await db.insert(schema.flowchartSubmission).values({ await db.insert(schema.flowchartSubmission).values({
id, id,
@@ -103,6 +117,12 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const displayId = c.req.query("problemId")?.trim() const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim() const username = c.req.query("username")?.trim()
const grade = c.req.query("grade") 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 (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)) 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}%`)) 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) => { 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) const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1) .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") 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({ await db.update(schema.flowchartSubmission).set({
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null, status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null, aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,

View File

@@ -1,16 +1,15 @@
<template> <template>
<n-button v-if="showLink" type="info" text @click="handleClick"> <n-button v-if="flowchart.showLink" type="info" text @click="handleClick">
{{ flowchart.id.slice(0, 12) }} {{ flowchart.id.slice(0, 12) }}
</n-button> </n-button>
<n-text v-else class="flowchart-id" @click="handleClick"> <!-- 没权限时不能挂 @click后端 GET /flowcharts/:id 会以 404 挡下
前端只会得到一个静默失败的空白面板 -->
<n-text v-else class="flowchart-id" depth="3">
{{ flowchart.id.slice(0, 12) }} {{ flowchart.id.slice(0, 12) }}
</n-text> </n-text>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FlowchartSubmissionListItem } from "utils/types" import type { FlowchartSubmissionListItem } from "utils/types"
import { useUserStore } from "shared/store/user"
const userStore = useUserStore()
interface Props { interface Props {
flowchart: FlowchartSubmissionListItem flowchart: FlowchartSubmissionListItem
@@ -21,12 +20,9 @@ const emit = defineEmits<{
showDetail: [id: string] showDetail: [id: string]
}>() }>()
const showLink = computed(() => { // showLink 由后端逐行下发(见 routes/flowchart.ts 的 canView
if (!userStore.isAuthed) return false // GET /flowcharts/:id 的放行条件同源。原来前端自己按「超管或本人」算了一遍,
if (userStore.isSuperAdmin) return true // 既漏了教师,也和后端对不上。
return props.flowchart.username === userStore.user?.username
})
function handleClick() { function handleClick() {
emit("showDetail", props.flowchart.id) emit("showDetail", props.flowchart.id)
} }