Compare commits

...

2 Commits

Author SHA1 Message Date
8861393529 fix(流程图): 评分明细的顺序是乱的,40 分的那项排在最后
Some checks failed
Deploy / deploy (push) Has been cancelled
AI 是按分值从高到低返回的:`逻辑正确性(40) → 完整性(30) → 规范性(20) → 清晰度(10)`。
但 `ai_criteria_details` 存在 jsonb 列里,而 Postgres 的 jsonb **不保留键序** ——
它按「键长度 + 字节序」重排,读出来变成 `完整性 → 清晰度 → 规范性 → 逻辑正确性`,
权重最高的那项被排到了最后。

评分弹框和教师端的评分详情都是直接 `v-for` 遍历这个对象,所以两处都乱。
统计面板因为用的是写死的 `CRITERIA_ORDER`,一直是对的 —— 于是同一份数据在两个
地方的顺序还不一致。

把顺序抽到 `utils/constants` 共享,三处统一走 `sortFlowchartCriteria()`;
表里没有的键排到后面,AI 万一返回别的评分项也不会丢。

顺带把限流的提示改得能看懂:撞上 429 时原来只显示「流程图提交失败」,学生会以为
是自己的图有问题然后反复点,越点等得越久。现在按错误码分支,提示「提交太频繁了,
缓一会儿再交」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:53:29 -06:00
e921506f40 fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉
`bullmq` 对自定义 jobId 有一条兼容老式可重复任务的校验:一旦包含 `:`,就必须
正好切成三段(`job.js` 里的 `split(':').length !== 3`),否则抛
`Custom Id cannot contain :`。

- 代码提交的重判用的是 `${id}:rejudge:${时间戳}` —— 三段,能过;
- 流程图的重判用的是 `${id}:${时间戳}` —— **两段,必抛**。

所以这个接口从上线起就没有成功过一次。更糟的是清空评分发生在入队**之前**:

    await db.update(...).set({ status: 0, aiScore: null, ... })
    await flowchartQueue.add(...)   // ← 在这里抛,500

每点一次「重新判题」,原来的分数、等级、反馈、评分明细就永久丢一次,提交卡在
PENDING,队列里没有任何任务会来救它,老师看到的只是「重新评分失败」。

改成三段式 jobId,并把入队失败落成 FAILED(而不是留在 PENDING)——
与 `POST /flowcharts` 的处理保持一致。

之前几轮验证没发现,是因为当时手上只有一条 PENDING 的提交,被 409(状态不允许
重判)挡在了入队之前,正好绕开了这个 bug。这次完整走教师流程才撞上。

实测:修复前点重判 → 前端「重新评分失败」、库里 status 变 0 且分数清空、
api 日志抛 `Custom Id cannot contain :`;修复后 → 「重新评分已提交」,
70分B级 重评为 86分A级。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:51:00 -06:00
5 changed files with 68 additions and 9 deletions

View File

@@ -330,7 +330,22 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await flowchartQueue.add("evaluate", { submissionId: row.flowchart.id }, { jobId: `${row.flowchart.id}:${Date.now()}` })
try {
// jobId 必须**正好三段**bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
// 任务的校验job.js 的 `split(':').length !== 3`),两段会直接抛
// `Custom Id cannot contain :`。原来写的是 `${id}:${时间戳}`,于是这个接口
// 从来没成功过 —— 而清空评分在入队之前,每点一次就把原来的分数永久清掉、
// 提交卡在 PENDING 且没有任何任务会来救它。
await flowchartQueue.add(
"evaluate",
{ submissionId: row.flowchart.id },
{ jobId: `${row.flowchart.id}:retry:${Date.now()}` },
)
} catch (error) {
// 入队失败就落 FAILED别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
}
return success(c, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" }))
})

View File

@@ -3,6 +3,7 @@ import { toRefs } from "vue"
// 工具函数
import { atou, utoa } from "utils/functions"
import { sortFlowchartCriteria } from "utils/constants"
// 组合式函数
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -79,6 +80,11 @@ const suggestionLines = computed(() =>
splitSuggestionLines(evaluation.value.suggestions),
)
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
const sortedCriteria = computed(() =>
sortFlowchartCriteria(evaluation.value.criteria_details),
)
function splitSuggestionLines(suggestions?: string | null) {
return suggestions
? suggestions
@@ -251,9 +257,17 @@ async function submitFlowchartData() {
}
message.success("流程图已提交,请耐心等待评分")
} catch (error) {
} catch (error: any) {
loading.value = false
message.error("流程图提交失败")
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
// 只说「提交失败」的话,学生会以为是自己的图有问题,然后反复点,越点越久
if (error?.error === "too-many-submissions") {
message.warning("提交太频繁了,缓一会儿再交")
} else if (error?.error === "flowchart-not-allowed") {
message.error("这道题不接受流程图提交")
} else {
message.error("流程图提交失败")
}
console.error("提交流程图失败:", error)
}
}
@@ -496,12 +510,12 @@ onUnmounted(() => {
<!-- 详细评分 -->
<n-card
v-if="evaluation.criteria_details"
v-if="sortedCriteria.length"
size="small"
title="详细评分"
>
<div
v-for="(detail, key) in evaluation.criteria_details"
v-for="[key, detail] in sortedCriteria"
:key="key"
style="margin-bottom: 12px"
>

View File

@@ -70,12 +70,12 @@
<!-- 详细评分 -->
<n-card
v-if="Object.keys(criteriaDetails).length > 0"
v-if="sortedCriteria.length > 0"
size="small"
title="详细评分"
>
<div
v-for="(detail, key) in criteriaDetails"
v-for="[key, detail] in sortedCriteria"
:key="key"
style="margin-bottom: 12px"
>
@@ -109,6 +109,7 @@
import { Icon } from "@iconify/vue"
import type { FlowchartSubmission } from "utils/types"
import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
interface Props {
submissionId: string
@@ -146,6 +147,9 @@ const criteriaDetails = computed<
}),
)
})
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
const loading = ref(false)
const rendering = ref(false)
const showLargeImage = ref(false)

View File

@@ -162,7 +162,7 @@
import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants"
import { Doughnut, Radar, Bar } from "vue-chartjs"
import {
Chart as ChartJS,
@@ -366,7 +366,8 @@ const doughnutOptions = {
},
}
const CRITERIA_ORDER = ["逻辑正确性", "完整性", "规范性", "清晰度"]
// 与评分明细共用同一份顺序,见 utils/constants
const CRITERIA_ORDER = FLOWCHART_CRITERIA_ORDER
const hasRadarData = computed(() =>
CRITERIA_ORDER.some((k) => k in data.criteriaAverages),

View File

@@ -304,6 +304,31 @@ export const RARITY_TEXT_COLOR: Record<
}
// 时间范围配置
/**
* 流程图评分项的展示顺序(按分值从高到低)。
*
* `ai_criteria_details` 存在 jsonb 列里,而 Postgres 的 jsonb **不保留键序** ——
* 它按「键长度 + 字节序」重排,读出来会变成 完整性 / 清晰度 / 规范性 / 逻辑正确性,
* 40 分的那项排到最后。凡是要展示评分明细的地方都按这个顺序排,别直接遍历对象。
*/
export const FLOWCHART_CRITERIA_ORDER = [
"逻辑正确性",
"完整性",
"规范性",
"清晰度",
]
/** 按 FLOWCHART_CRITERIA_ORDER 排序,表里没有的键排在后面并保持原有相对顺序 */
export function sortFlowchartCriteria<T>(
details: Record<string, T>,
): [string, T][] {
const rank = (key: string) => {
const i = FLOWCHART_CRITERIA_ORDER.indexOf(key)
return i === -1 ? Number.MAX_SAFE_INTEGER : i
}
return Object.entries(details).sort(([a], [b]) => rank(a) - rank(b))
}
export const DURATION_OPTIONS = [
{ label: "本节课内", value: "hours:1" },
{ label: "两节课内", value: "hours:2" },