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:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user