只说「还没画对」,学生改了几次还是这一句,看不出在往前走。等级照旧存 S/A/B/C, 标题换成话:画对了,画得很清楚 / 画对了 / 快画对了 / 对了一部分 / 还没画对; 下面一根完成度条,走对几组亮几段;第二次起没画对时说比上次多(少)对了几组 (详情接口带上同一道题上一次走对了几组)。提交列表右栏的图从 42% 加宽到 62%, 小图也放大填满图框(最多 1.5 倍)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
863 lines
37 KiB
TypeScript
863 lines
37 KiB
TypeScript
import { randomBytes } from "node:crypto"
|
||
|
||
import {
|
||
createFlowchartRequestSchema,
|
||
isFlowchartPass,
|
||
type CreateFlowchartResponse,
|
||
type FlowchartScores,
|
||
type FlowchartList,
|
||
type FlowchartListItem,
|
||
type FlowchartStatistics,
|
||
type FlowchartSubmission,
|
||
NO_CLASS,
|
||
STUDENT_ROLES,
|
||
} from "@oj2/contract"
|
||
import { and, asc, count, desc, eq, inArray, isNull, lt, sql, type SQL } from "drizzle-orm"
|
||
import { Hono } from "hono"
|
||
|
||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||
import type { AuthUser } from "../auth/session"
|
||
import { config } from "../config"
|
||
import { db, schema } from "../db"
|
||
import { failure, readJson, success } from "../http"
|
||
import { flowchartQueue } from "../queue"
|
||
import { getBooleanOption } from "../services/options"
|
||
import { problemSetLockCutoffs, recordSolvedAndNotify } from "../services/problemset"
|
||
import { consumeToken } from "../services/throttling"
|
||
import { buildWordFrequencies } from "../services/word-frequency"
|
||
import { dayStart } from "../time"
|
||
import { unknownDisplayIds } from "../services/submission-filters"
|
||
import {
|
||
classCondition,
|
||
isAdminRole,
|
||
isTeacherOrAbove,
|
||
matchedUsers,
|
||
asRecord,
|
||
parseDisplayIds,
|
||
queryInteger,
|
||
scopedUsers,
|
||
rounded,
|
||
stripClassPrefix,
|
||
} from "./helpers"
|
||
|
||
export const flowchartRoutes = new Hono<AppEnv>()
|
||
|
||
// AI 评分单独一个限流桶,与代码提交的 `throttling:user:<id>` 分开计数
|
||
function flowchartThrottleKey(userId: number) {
|
||
return `flowchart:${userId}`
|
||
}
|
||
|
||
/**
|
||
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
||
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
|
||
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
||
*/
|
||
function hiddenByProblemSet(
|
||
user: AuthUser,
|
||
row: { userId: number; problemId: number; createTime: string },
|
||
cutoffs: Map<number, string>,
|
||
) {
|
||
if (row.userId !== user.id || isTeacherOrAbove(user)) return false
|
||
const cutoff = cutoffs.get(row.problemId)
|
||
return cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)
|
||
}
|
||
|
||
function canView(
|
||
user: AuthUser,
|
||
row: { userId: number; problemId: number; createTime: string },
|
||
problem: { createdById: number },
|
||
cutoffs: Map<number, string>,
|
||
) {
|
||
if (hiddenByProblemSet(user, row, cutoffs)) return false
|
||
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
|
||
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
||
}
|
||
|
||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
||
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||
return problemSetLockCutoffs(user.id, [...new Set(own)])
|
||
}
|
||
|
||
/** 上一次走对了几组(见契约 previousCases):同一个人、同一道题、更早、评完了的那一次 */
|
||
async function previousCases(flowchart: typeof schema.flowchartSubmission.$inferSelect) {
|
||
const [previous] = await db
|
||
.select({ details: schema.flowchartSubmission.aiCriteriaDetails })
|
||
.from(schema.flowchartSubmission)
|
||
.where(
|
||
and(
|
||
eq(schema.flowchartSubmission.userId, flowchart.userId),
|
||
eq(schema.flowchartSubmission.problemId, flowchart.problemId),
|
||
eq(schema.flowchartSubmission.status, 2),
|
||
lt(schema.flowchartSubmission.createTime, flowchart.createTime),
|
||
),
|
||
)
|
||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||
.limit(1)
|
||
const details = asRecord(previous?.details)
|
||
if (details.version !== 2 || !Array.isArray(details.cases)) return null
|
||
const cases = details.cases.map((item) => asRecord(item))
|
||
return { passed: cases.filter((item) => item.ok === true).length, total: cases.length }
|
||
}
|
||
|
||
function flowchartData(
|
||
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
||
username: string,
|
||
previous: { passed: number; total: number } | null = null,
|
||
) {
|
||
return {
|
||
id: flowchart.id,
|
||
username,
|
||
problemId: flowchart.problemId,
|
||
mermaidCode: flowchart.mermaidCode,
|
||
flowchartData: asRecord(flowchart.flowchartData),
|
||
status: flowchart.status,
|
||
createTime: flowchart.createTime,
|
||
aiScore: flowchart.aiScore,
|
||
aiGrade: flowchart.aiGrade,
|
||
aiFeedback: flowchart.aiFeedback,
|
||
aiSuggestions: flowchart.aiSuggestions,
|
||
aiCriteriaDetails: asRecord(flowchart.aiCriteriaDetails),
|
||
aiProvider: flowchart.aiProvider,
|
||
aiModel: flowchart.aiModel,
|
||
processingTime: flowchart.processingTime,
|
||
evaluationTime: flowchart.evaluationTime,
|
||
previousCases: previous,
|
||
} satisfies FlowchartSubmission
|
||
}
|
||
|
||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||
const parsed = await readJson(c, createFlowchartRequestSchema)
|
||
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) {
|
||
return failure(
|
||
c,
|
||
400,
|
||
"invalid-request",
|
||
parsed.error?.issues[0]?.message ?? "Flowchart data is too large",
|
||
)
|
||
}
|
||
const [problem] = await db
|
||
.select({
|
||
id: schema.problem.id,
|
||
allow: schema.problem.allowFlowchart,
|
||
contestId: schema.problem.contestId,
|
||
})
|
||
.from(schema.problem)
|
||
.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",
|
||
)
|
||
const userId = c.get("user")!.id
|
||
/**
|
||
* 同一道题、一模一样的图,已经按走例子评过:直接照搬那一次的结果,不再问 AI。
|
||
* 原来同一张图连点几次能抽出不同的等级(有一张 C 再交变 S),22% 的提交和上一次一模一样。
|
||
* 前端在图没改过时已经不让交,这里兜住别的路子(另开一个页面、两个人画得一字不差)。
|
||
* 只认走例子的结果(version 2):老的四项分本来就不稳,不值得照搬
|
||
*/
|
||
const [previous] = await db
|
||
.select()
|
||
.from(schema.flowchartSubmission)
|
||
.where(
|
||
and(
|
||
eq(schema.flowchartSubmission.problemId, problem.id),
|
||
eq(schema.flowchartSubmission.mermaidCode, parsed.data.mermaidCode),
|
||
eq(schema.flowchartSubmission.status, 2),
|
||
sql`${schema.flowchartSubmission.aiCriteriaDetails}->>'version' = '2'`,
|
||
),
|
||
)
|
||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||
.limit(1)
|
||
if (previous) {
|
||
const id = randomBytes(16).toString("hex")
|
||
const now = new Date().toISOString()
|
||
await db.insert(schema.flowchartSubmission).values({
|
||
...previous,
|
||
id,
|
||
userId,
|
||
flowchartData: parsed.data.flowchartData,
|
||
createTime: now,
|
||
processingTime: 0,
|
||
evaluationTime: now,
|
||
})
|
||
if (isFlowchartPass(previous.aiGrade) && problem.contestId === null) {
|
||
await recordSolvedAndNotify(userId, problem.id, now)
|
||
}
|
||
// 前端订阅这一条时,WebSocket 那边按库里的状态回放「评完了」
|
||
return success(
|
||
c,
|
||
{ submissionId: id, status: "pending" } satisfies CreateFlowchartResponse,
|
||
201,
|
||
)
|
||
}
|
||
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
|
||
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
|
||
// 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。
|
||
const throttle = await consumeToken("user", flowchartThrottleKey(userId))
|
||
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,
|
||
userId,
|
||
problemId: problem.id,
|
||
mermaidCode: parsed.data.mermaidCode,
|
||
flowchartData: parsed.data.flowchartData,
|
||
status: 0,
|
||
createTime: new Date().toISOString(),
|
||
aiScore: null,
|
||
aiGrade: null,
|
||
aiFeedback: null,
|
||
aiSuggestions: null,
|
||
aiCriteriaDetails: {},
|
||
aiProvider: "deepseek",
|
||
aiModel: config.aiModel,
|
||
processingTime: null,
|
||
evaluationTime: null,
|
||
})
|
||
try {
|
||
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
||
} catch {
|
||
await db
|
||
.update(schema.flowchartSubmission)
|
||
.set({ status: 3 })
|
||
.where(eq(schema.flowchartSubmission.id, id))
|
||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||
}
|
||
return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201)
|
||
})
|
||
|
||
/**
|
||
* 题号 / 用户名筛选一律先解析成 `flowchart_submission` 自己的列,不靠 join 之后比
|
||
* `problem._id` / `user.username`。同一套做法见 submission.ts 的
|
||
* problemFilter / usernameFilter,这里是两个好处:
|
||
*
|
||
* - 列表的 count 因此**一个 join 都不用挂**。挂了就回不到最小索引上的 index-only
|
||
* scan,而这张表每行带 3KB 的 flowchart_data + 1.2KB 的 mermaid_code,堆页密度低,
|
||
* 回表比 submission 那边贵。
|
||
* - 筛条件落在驱动表上,规划器能走 flowchart_user_time_idx / flowchart_problem_time_idx,
|
||
* 不必顺着时间索引倒扫再逐行 join 过滤。
|
||
*
|
||
* 查无此题 / 此人时留**恒假**条件 —— 少推一个 filter 就成了「不筛」,
|
||
* 「查无此班」会变成「全站」。
|
||
*/
|
||
async function flowchartProblemFilter(displayId: string) {
|
||
// 和代码提交列表一样可以一次筛几道
|
||
const lowered = parseDisplayIds(displayId).map((id) => id.toLowerCase())
|
||
if (!lowered.length) return sql`false`
|
||
const problems = await db
|
||
.select({ id: schema.problem.id })
|
||
.from(schema.problem)
|
||
.where(
|
||
and(
|
||
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||
isNull(schema.problem.contestId),
|
||
),
|
||
)
|
||
return problems.length
|
||
? inArray(
|
||
schema.flowchartSubmission.problemId,
|
||
problems.map((row) => row.id),
|
||
)
|
||
: sql`false`
|
||
}
|
||
|
||
/** 按班级(user.class_name)筛,口径同 submission-statistics.ts 的 classNameFilter */
|
||
async function flowchartClassFilter(className: string) {
|
||
const users = await db
|
||
.select({ id: schema.user.id })
|
||
.from(schema.user)
|
||
.where(classCondition(className))
|
||
return users.length
|
||
? inArray(
|
||
schema.flowchartSubmission.userId,
|
||
users.map((row) => row.id),
|
||
)
|
||
: sql`false`
|
||
}
|
||
|
||
/**
|
||
* 用户名筛选。默认是「包含」;`exact` 时整名匹配(提交列表的「只看他」、协作条带进来的),
|
||
* 学号互相包含是常态,ks24a1 会混进 ks24a10–ks24a19
|
||
*/
|
||
async function flowchartUserFilter(username: string, exact = false) {
|
||
const ids = exact
|
||
? (
|
||
await db
|
||
.select({ id: schema.user.id })
|
||
.from(schema.user)
|
||
.where(eq(schema.user.username, username))
|
||
.limit(1)
|
||
).map((row) => row.id)
|
||
: (await matchedUsers(username)).map((row) => row.id)
|
||
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`
|
||
}
|
||
|
||
/**
|
||
* 列表只取这几列。原来是 `select({ flowchart: 整行, problem: 整行 })`,把
|
||
* mermaid_code、flowchart_data、ai_feedback、ai_suggestions、ai_criteria_details
|
||
* 和**整张题目表**(description / 标准答案 / 标准流程图…)一起拉回来,而响应一个
|
||
* 都用不到:生产快照实测流程图行均 4.9KB(p90 6.9KB)、题目行均 2.2KB,默认 10 行
|
||
* 一页白拉 ~70KB,limit=250 时 1.7MB。
|
||
*
|
||
* 对齐 submission.ts 的 submissionListColumns —— 那边同样是手写白名单,
|
||
* 刻意不取 code / info。
|
||
*/
|
||
const flowchartListColumns = {
|
||
flowchart: {
|
||
id: schema.flowchartSubmission.id,
|
||
// showLink 判定要(本人 + 题单闸门),序列化本身用不到
|
||
userId: schema.flowchartSubmission.userId,
|
||
problemId: schema.flowchartSubmission.problemId,
|
||
status: schema.flowchartSubmission.status,
|
||
createTime: schema.flowchartSubmission.createTime,
|
||
aiScore: schema.flowchartSubmission.aiScore,
|
||
aiGrade: schema.flowchartSubmission.aiGrade,
|
||
aiProvider: schema.flowchartSubmission.aiProvider,
|
||
aiModel: schema.flowchartSubmission.aiModel,
|
||
processingTime: schema.flowchartSubmission.processingTime,
|
||
evaluationTime: schema.flowchartSubmission.evaluationTime,
|
||
},
|
||
username: schema.user.username,
|
||
problem: {
|
||
displayId: schema.problem.displayId,
|
||
title: schema.problem.title,
|
||
// 同上,canView 要
|
||
createdById: schema.problem.createdById,
|
||
},
|
||
}
|
||
|
||
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||
const user = c.get("user")!
|
||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||
const displayId = c.req.query("problemDisplayId")?.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, { results: [], total: 0, unknownProblems: [] } satisfies FlowchartList)
|
||
}
|
||
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
||
const className = c.req.query("className")?.trim()
|
||
const onlyMyself =
|
||
c.req.query("myself") === "1" ||
|
||
(!username && !className && STUDENT_ROLES.includes(user.adminType))
|
||
const filters: Array<SQL | undefined> = []
|
||
filters.push(
|
||
...(await Promise.all([
|
||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||
!onlyMyself && username
|
||
? flowchartUserFilter(username, c.req.query("exactUsername") === "1")
|
||
: undefined,
|
||
!onlyMyself && className ? flowchartClassFilter(className) : undefined,
|
||
])),
|
||
)
|
||
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||
if (c.req.query("today") === "1")
|
||
filters.push(sql`${schema.flowchartSubmission.createTime} >= ${dayStart()}`)
|
||
if (["S", "A", "B", "C"].includes(grade ?? ""))
|
||
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
|
||
const where = and(...filters)
|
||
const [totalRows, rows, unknownProblems] = await Promise.all([
|
||
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
|
||
db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
|
||
db
|
||
.select(flowchartListColumns)
|
||
.from(schema.flowchartSubmission)
|
||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||
.where(where)
|
||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||
.limit(limit)
|
||
.offset(offset),
|
||
displayId ? unknownDisplayIds(displayId, null) : [],
|
||
])
|
||
const cutoffs = await cutoffsFor(
|
||
user,
|
||
rows.map((row) => row.flowchart),
|
||
)
|
||
return success(c, {
|
||
results: rows.map(
|
||
({ flowchart, username, problem }) =>
|
||
({
|
||
id: flowchart.id,
|
||
username,
|
||
problemDisplayId: problem.displayId,
|
||
problemTitle: problem.title,
|
||
status: flowchart.status,
|
||
createTime: flowchart.createTime,
|
||
aiScore: flowchart.aiScore,
|
||
aiGrade: flowchart.aiGrade,
|
||
aiProvider: flowchart.aiProvider,
|
||
aiModel: flowchart.aiModel,
|
||
processingTime: flowchart.processingTime,
|
||
evaluationTime: flowchart.evaluationTime,
|
||
showLink: canView(user, flowchart, problem, cutoffs),
|
||
}) satisfies FlowchartListItem,
|
||
),
|
||
total: totalRows[0]?.value ?? 0,
|
||
unknownProblems,
|
||
} satisfies FlowchartList)
|
||
})
|
||
|
||
const FLOWCHART_COMPLETED = 2
|
||
|
||
/**
|
||
* 词云取的提交条数上限,同时也是分词的文本条数上限。
|
||
*
|
||
* 数值统计(总数、均分、等级分布、各项平均分、完成人数)按整个时间窗**精确**计算,
|
||
* 但那几项现在全是 SQL 聚合,代价不随窗口里的行数走。**不能采样** —— 采了之后老师
|
||
* 看到的完成率和均分就是错的,而且从界面上看不出来。
|
||
*
|
||
* 会随数据量线性变重的只剩词云:每条 feedback / suggestions / comment 都要走一遍
|
||
* jieba,而前端的「全部时段」是不带 start 的(统计页 oj/statistics/ 的
|
||
* 「全部」时段),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的,
|
||
* 看的是高频问题,取最近这些条足够。
|
||
*
|
||
* 这里**同时**卡了两道:SQL 侧 `order by create_time desc limit N` 只取最近 N 条提交,
|
||
* JS 侧 pushText 再卡 N 条文本。生产快照实测一条提交出 5.97 段文本(几项 comment +
|
||
* feedback + suggestions,最少的一条也有 1 段),所以先到的一直是文本那道闸——3000 段
|
||
* 在 500 条出头就满了,行数那道只是兜底:真遇到一批评语全空的提交,词云少看几条,
|
||
* 可以接受。原来只有 JS 那道,行早就整批拉回内存了。
|
||
*/
|
||
const WORDCLOUD_TEXT_LIMIT = 3000
|
||
|
||
/** 「每人最好的一次」最多给多少人。快照里有流程图的学生一共 203 人 */
|
||
const PEOPLE_LIMIT = 500
|
||
|
||
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||
const end = c.req.query("end")?.trim()
|
||
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
||
const start = c.req.query("start")?.trim()
|
||
|
||
const filters: Array<SQL | undefined> = [
|
||
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
||
sql`${schema.flowchartSubmission.createTime} <= ${end}`,
|
||
]
|
||
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
|
||
|
||
// 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错,
|
||
// 不退化成「全部题目」—— 否则打错一个字看到的是全站数据
|
||
const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
|
||
// 和代码统计同一个上限(submission-statistics.ts 的 STATISTICS_MAX_PROBLEMS)
|
||
if (displayIds.length > 20) return failure(c, 400, "invalid-request", "At most 20 problems")
|
||
if (displayIds.length) {
|
||
const lowered = displayIds.map((id) => id.toLowerCase())
|
||
const problems = await db
|
||
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||
.from(schema.problem)
|
||
.where(
|
||
and(
|
||
inArray(sql`lower(${schema.problem.displayId})`, lowered),
|
||
isNull(schema.problem.contestId),
|
||
eq(schema.problem.visible, true),
|
||
),
|
||
)
|
||
const found = new Set(problems.map((row) => row.displayId.toLowerCase()))
|
||
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
|
||
if (missing) return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`)
|
||
filters.push(
|
||
inArray(
|
||
schema.flowchartSubmission.problemId,
|
||
problems.map((row) => row.id),
|
||
),
|
||
)
|
||
}
|
||
|
||
// 用户名 / 班级先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口
|
||
// (submission-statistics.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂。
|
||
// 班级按 class_name 精确匹配(scopedUsers 的注释说了为什么不能拿 ks231 模糊匹配)
|
||
const scoped = await scopedUsers(
|
||
c.req.query("username")?.trim() || undefined,
|
||
c.req.query("className")?.trim() || undefined,
|
||
)
|
||
const matched = scoped ?? []
|
||
if (scoped) {
|
||
const ids = matched.map((row) => row.id)
|
||
// 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」
|
||
filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`)
|
||
}
|
||
const where = and(...filters)
|
||
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
|
||
// 未禁用的普通用户才进分母,教师和管理员不算。
|
||
// 「没填班级」不是一个真的班(两百来个夏令营、自己注册的号混在一起),「谁没做」列出来全是
|
||
// 不相干的人,当没有花名册
|
||
const roster =
|
||
scoped && c.req.query("className")?.trim() !== NO_CLASS
|
||
? matched.filter((row) => !row.isDisabled && STUDENT_ROLES.includes(row.adminType))
|
||
: []
|
||
|
||
/**
|
||
* 五条查询,每条的代价都和窗口里的行数脱钩(词云那条卡了 limit)。
|
||
*
|
||
* 原来是**一条**不带 limit 的 `select(username, score, grade, criteria, feedback,
|
||
* suggestions) order by create_time desc`,把整个时间窗的行拉进内存再用 JS 算 ——
|
||
* 词云的 3000 条上限是在 JS 里截的,行早就全回来了。备份实测每行的 AI 文本约 366B
|
||
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
|
||
* 点击 18MB,而老师是开着面板反复切时段、切班的。
|
||
*/
|
||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows, peopleRows] =
|
||
await Promise.all([
|
||
db
|
||
.select({
|
||
total: count(),
|
||
/**
|
||
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
|
||
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
|
||
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
|
||
*/
|
||
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
|
||
Number,
|
||
),
|
||
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
|
||
Number,
|
||
),
|
||
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
|
||
// 按 user_id 就不必 join user
|
||
completedCount:
|
||
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number),
|
||
})
|
||
.from(schema.flowchartSubmission)
|
||
.where(where),
|
||
db
|
||
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
|
||
.from(schema.flowchartSubmission)
|
||
.where(where)
|
||
.groupBy(schema.flowchartSubmission.aiGrade),
|
||
/**
|
||
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
|
||
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
|
||
* `typeof detail.score !== "number"` 的 continue 一致。
|
||
*
|
||
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
|
||
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
|
||
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
|
||
*
|
||
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
|
||
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
|
||
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
|
||
*
|
||
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
|
||
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
|
||
* 实测 254ms → 842ms,而满分本来就是几个常数。
|
||
*/
|
||
db.execute<{ key: string; avg: number }>(sql`
|
||
select e.key as key, avg((e.value->>'score')::double precision) as avg
|
||
from ${schema.flowchartSubmission}
|
||
cross join lateral jsonb_each(
|
||
case when jsonb_typeof(${schema.flowchartSubmission.aiCriteriaDetails}) = 'object'
|
||
then ${schema.flowchartSubmission.aiCriteriaDetails}
|
||
else '{}'::jsonb end
|
||
) e
|
||
where ${where} and jsonb_typeof(e.value->'score') = 'number'
|
||
group by e.key
|
||
`),
|
||
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
|
||
db
|
||
.select({
|
||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||
feedback: schema.flowchartSubmission.aiFeedback,
|
||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||
})
|
||
.from(schema.flowchartSubmission)
|
||
.where(where)
|
||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||
.limit(WORDCLOUD_TEXT_LIMIT),
|
||
// 「谁没做」只在有花名册时算得出来,行数也就一个班
|
||
roster.length
|
||
? db
|
||
.selectDistinct({ userId: schema.flowchartSubmission.userId })
|
||
.from(schema.flowchartSubmission)
|
||
.where(where)
|
||
: [],
|
||
// 每人最好的一次。等级取分数最高那张的等级(同分取最近的),只算普通学生
|
||
db
|
||
.select({
|
||
username: schema.user.username,
|
||
className: schema.user.className,
|
||
bestScore: sql<number | null>`max(${schema.flowchartSubmission.aiScore})`,
|
||
bestGrade: sql<string | null>`(array_agg(${schema.flowchartSubmission.aiGrade}
|
||
order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`,
|
||
count: count(),
|
||
// 拿到 A / S 的题数。查几道题时要每道都拿到才算完成,和代码统计「几道都做完」同口径
|
||
goodProblems: sql<number>`count(distinct ${schema.flowchartSubmission.problemId})
|
||
filter (where ${schema.flowchartSubmission.aiGrade} in ('S', 'A'))`.mapWith(Number),
|
||
})
|
||
.from(schema.flowchartSubmission)
|
||
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
|
||
.where(
|
||
and(
|
||
where,
|
||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||
eq(schema.user.isDisabled, false),
|
||
),
|
||
)
|
||
.groupBy(schema.user.id, schema.user.username, schema.user.className)
|
||
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
|
||
.limit(500),
|
||
])
|
||
|
||
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
|
||
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
|
||
username: row.username,
|
||
realName: stripClassPrefix(row.username, row.className),
|
||
bestScore: row.bestScore === null ? null : Number(row.bestScore),
|
||
bestGrade: row.bestGrade,
|
||
count: row.count,
|
||
goodProblems: row.goodProblems,
|
||
}))
|
||
|
||
if (!totals || totals.total === 0) {
|
||
return success(c, {
|
||
totalCount: 0,
|
||
avgScore: 0,
|
||
gradeDistribution: {},
|
||
criteriaAverages: {},
|
||
personCount: roster.length,
|
||
completedCount: 0,
|
||
wordFrequencies: [],
|
||
commonMistakes: [],
|
||
// 一条提交都没有时,花名册上的人**全都**是「没做」—— 原来这里写死空数组,
|
||
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
|
||
dataUnaccepted: roster.map((row) => ({
|
||
username: row.username,
|
||
realName: stripClassPrefix(row.username, row.className),
|
||
})),
|
||
people: [],
|
||
peopleTruncated: false,
|
||
problemCount: displayIds.length,
|
||
} satisfies FlowchartStatistics)
|
||
}
|
||
|
||
const gradeDistribution: Record<string, number> = {}
|
||
for (const row of gradeRows) {
|
||
// 旧后端用 values_list("ai_grade") 分组,null 也会成为一个桶;这里保持同样的口径。
|
||
// null 和空串会分成两组,合并到同一个桶里
|
||
const grade = row.grade ?? ""
|
||
gradeDistribution[grade] = (gradeDistribution[grade] ?? 0) + row.n
|
||
}
|
||
|
||
/**
|
||
* 词云原料和每项满分都从同一批行里取 —— 这批行本来就要读(见下面的 textRows),
|
||
* 白嫖一遍,不额外查库。
|
||
*
|
||
* 满分的口径是「按 create_time 倒序,某项**第一次**出现时写的那个 max,不是数字就
|
||
* 退回 100」,和原来逐行遍历时那句 `if (bucket) ... else set(max)` 完全一致,只是
|
||
* 遍历范围从整个时间窗收成最近 WORDCLOUD_TEXT_LIMIT 条。满分是评分标准里的常数
|
||
* (完整性 30、逻辑正确性 40…),几万条里换一次都算多;真出现一项**只**在更早的
|
||
* 行里有过,它的平均分照常出(那是 SQL 全窗口算的),满分退回 100。
|
||
*/
|
||
const criteriaMax = new Map<string, number>()
|
||
const texts: string[] = []
|
||
const pushText = (value: string) => {
|
||
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
|
||
}
|
||
const mistakes = new Map<string, number>()
|
||
for (const row of textRows) {
|
||
const report = asRecord(row.criteria)
|
||
if (report.version === 2 && Array.isArray(report.cases)) {
|
||
const first = report.cases.map((item) => asRecord(item)).find((item) => item.ok === false)
|
||
if (typeof first?.why === "string" && first.why) {
|
||
mistakes.set(first.why, (mistakes.get(first.why) ?? 0) + 1)
|
||
}
|
||
}
|
||
for (const [key, value] of Object.entries(report)) {
|
||
const detail = asRecord(value)
|
||
// 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收
|
||
if (typeof detail.score !== "number") continue
|
||
if (!criteriaMax.has(key)) {
|
||
criteriaMax.set(key, typeof detail.max === "number" ? detail.max : 100)
|
||
}
|
||
if (typeof detail.comment === "string" && detail.comment) pushText(detail.comment)
|
||
}
|
||
if (row.feedback) pushText(row.feedback)
|
||
if (row.suggestions) pushText(row.suggestions)
|
||
}
|
||
|
||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||
for (const row of criteriaRows) {
|
||
criteriaAverages[row.key] = {
|
||
avg: rounded(row.avg, 1),
|
||
max: criteriaMax.get(row.key) ?? 100,
|
||
}
|
||
}
|
||
|
||
const submitted = new Set(submittedRows.map((row) => row.userId))
|
||
return success(c, {
|
||
totalCount: totals.total,
|
||
avgScore: totals.scoreCount ? rounded(totals.scoreSum / totals.scoreCount, 1) : 0,
|
||
gradeDistribution,
|
||
criteriaAverages,
|
||
personCount: roster.length,
|
||
completedCount: totals.completedCount,
|
||
wordFrequencies: await buildWordFrequencies(texts),
|
||
commonMistakes: [...mistakes]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8)
|
||
.map(([text, n]) => ({ text, count: n })),
|
||
dataUnaccepted: roster
|
||
.filter((row) => !submitted.has(row.id))
|
||
.map((row) => ({
|
||
username: row.username,
|
||
realName: stripClassPrefix(row.username, row.className),
|
||
})),
|
||
people,
|
||
peopleTruncated,
|
||
problemCount: displayIds.length,
|
||
} satisfies FlowchartStatistics)
|
||
})
|
||
|
||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||
const [row] = await db
|
||
.select({
|
||
flowchart: schema.flowchartSubmission,
|
||
username: schema.user.username,
|
||
problem: schema.problem,
|
||
})
|
||
.from(schema.flowchartSubmission)
|
||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||
.limit(1)
|
||
const user = c.get("user")!
|
||
if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||
return success(c, flowchartData(row.flowchart, row.username, await previousCases(row.flowchart)))
|
||
})
|
||
|
||
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(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
|
||
// 评分再抽一次,别人的提交只有老师能动
|
||
if (row.flowchart.userId !== user.id && !isTeacherOrAbove(user))
|
||
return failure(c, 403, "permission-denied", "权限不足")
|
||
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 (!isTeacherOrAbove(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,
|
||
})
|
||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||
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 {
|
||
// 入队失败就落 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, {
|
||
submissionId: row.flowchart.id,
|
||
status: "pending",
|
||
} satisfies CreateFlowchartResponse)
|
||
})
|
||
|
||
/**
|
||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
|
||
* 结果页签的历次分数(scores)从这一份出。
|
||
*
|
||
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
|
||
* 的学生每切一次题就要搬几百 KB,而结果页签只要分数。
|
||
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
||
*/
|
||
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||
const [rows, cutoffs] = await Promise.all([
|
||
db
|
||
.select({
|
||
id: schema.flowchartSubmission.id,
|
||
userId: schema.flowchartSubmission.userId,
|
||
problemId: schema.flowchartSubmission.problemId,
|
||
createTime: schema.flowchartSubmission.createTime,
|
||
aiScore: schema.flowchartSubmission.aiScore,
|
||
aiGrade: schema.flowchartSubmission.aiGrade,
|
||
})
|
||
.from(schema.flowchartSubmission)
|
||
.where(
|
||
and(
|
||
eq(schema.flowchartSubmission.userId, user.id),
|
||
eq(schema.flowchartSubmission.problemId, problemId),
|
||
eq(schema.flowchartSubmission.status, 2),
|
||
),
|
||
)
|
||
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
||
cutoffsFor(user, [{ userId: user.id, problemId }]),
|
||
])
|
||
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, cutoffs))
|
||
return { visible, hidden: rows.length - visible.length }
|
||
}
|
||
|
||
/**
|
||
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
|
||
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。
|
||
*/
|
||
flowchartRoutes.get("/problems/:id/flowchart/scores", requireAuth, async (c) => {
|
||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const { visible, hidden } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||
return success(c, {
|
||
scores: visible.map((flowchart) => ({
|
||
id: flowchart.id,
|
||
score: flowchart.aiScore ?? 0,
|
||
grade: flowchart.aiGrade ?? "",
|
||
createTime: flowchart.createTime,
|
||
})),
|
||
hidden,
|
||
} satisfies FlowchartScores)
|
||
})
|