feat(流程图): 流程图画到 A / S 算这道题做完——题单进度、课堂条都认;加入题单之前画的图先藏起来
Deploy / deploy (push) Canceled after 0s

- 流程图评到 A 或 S,记进所有已加入、且包含这道题的题单(和判题那一路同一个口径,不看从哪个
  入口交的),徽章和题单类成就照样推送。原来题单进度只在判代码时记,流程图作业在题单里永远是
  「没做」。流程图提交的 id 进不了 problemset_submission(外键指向代码提交表),所以只写
  progress_detail —— 解锁旧提交的那道闸看的也是它
- 课堂条(/me/class-activity)的 myStatus:流程图 A / S 也算 accepted,评完的非 A/S 算 tried
- 题单防抄闸门补到流程图上:加入题单之前画的图,对本人藏起来(列表 showLink、详情 404、
  current / history 不数它),规则和代码提交同一份(problemSetJoinTimes 挪进 services/problemset)。
  原来加入之前画到 A 的那张图点一下就能载回画布、交上去就是 A
- 新增 GET /problems/:id/flowchart/scores:一次给出自己评完的每一次的分数和等级,外加被藏起来
  的次数(原来 history 一次只给一条,看分数走势得一页一页翻)
- 「A/S 算过关」和按分数定等级收进 flowchart/grade.ts;判题那边的记账 + 通知抽成
  recordSolvedAndNotify,两条路共用

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 17:56:35 +08:00
1 parent 948e9a734d
commit 3fe3caa944
8 files changed
+280 -145

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
/**
* 流程图的等级。一律由分数推出来,不采信模型自报的 grade:提示词里写死了这四档,
* 但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果,甚至直接吐「优秀」;脏值会一路
* 串到等级分布图和「A/S 才算过关」的判断里。
*/
export function gradeForScore(score: number) {
if (score >= 90) return "S"
if (score >= 80) return "A"
if (score >= 70) return "B"
return "C"
}
/**
* 画到这两档算这道题做完:题单进度、课堂条、「你画的流程图」都按它
* (设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)
*/
export const FLOWCHART_PASS_GRADES = ["S", "A"]
export function isFlowchartPass(grade: string | null | undefined) {
return grade === "S" || grade === "A"
}
+13 -12
View File
@@ -5,6 +5,8 @@ import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishFlowchartUpdate } from "../events" import { publishFlowchartUpdate } from "../events"
import { completeChat } from "../services/ai" import { completeChat } from "../services/ai"
import { recordSolvedAndNotify } from "../services/problemset"
import { gradeForScore, isFlowchartPass } from "./grade"
import type { FlowchartJobData } from "./job" import type { FlowchartJobData } from "./job"
function evaluationPrompt(problem: typeof schema.problem.$inferSelect) { function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
@@ -16,18 +18,6 @@ function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
题目:${problem.title}\n${problem.description.slice(0, 2000)}` 题目:${problem.title}\n${problem.description.slice(0, 2000)}`
} }
/**
* 等级一律由分数推出来,不采信模型自报的 grade。
* 提示词里写死了这四档,但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果,
* 甚至直接吐「优秀」;脏值会一路串到等级分布图和「A/S 才展示流程图」的判断里。
*/
function gradeForScore(score: number) {
if (score >= 90) return "S"
if (score >= 80) return "A"
if (score >= 70) return "B"
return "C"
}
function parseEvaluation(value: string) { function parseEvaluation(value: string) {
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0] const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
@@ -89,6 +79,17 @@ export async function evaluateFlowchart(
evaluationTime: new Date().toISOString(), evaluationTime: new Date().toISOString(),
}) })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id)) .where(eq(schema.flowchartSubmission.id, row.flowchart.id))
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
// 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单
if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
await recordSolvedAndNotify(
row.flowchart.userId,
row.problem.id,
null,
new Date().toISOString(),
)
}
await publishFlowchartUpdate(row.flowchart.userId, { await publishFlowchartUpdate(row.flowchart.userId, {
type: "flowchart_evaluation_completed", type: "flowchart_evaluation_completed",
submissionId: row.flowchart.id, submissionId: row.flowchart.id,
+8 -42
View File
@@ -6,11 +6,8 @@ import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events" import { publishAchievementNotification } from "../events"
import { import { updateAchievementsForSubmission } from "../services/achievements"
updateAchievementsForProblemSet, import { recordSolvedAndNotify } from "../services/problemset"
updateAchievementsForSubmission,
} from "../services/achievements"
import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
import { asRecord } from "../routes/helpers" import { asRecord } from "../routes/helpers"
@@ -580,43 +577,12 @@ export async function judgeSubmission(job: JudgeJobData) {
// 进度已经落库,跳回题单页看到的就是新数据。 // 进度已经落库,跳回题单页看到的就是新数据。
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。 // 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
if (row.submission.contestId === null && isAccepted(result)) { if (row.submission.contestId === null && isAccepted(result)) {
try { await recordSolvedAndNotify(
const { updated, earned } = await recordSolvedProblem( row.submission.userId,
row.submission.userId, row.problem.id,
row.problem.id, row.submission.id,
row.submission.id, row.submission.createTime,
row.submission.createTime, )
)
if (earned.length > 0) {
await publishAchievementNotification(
row.submission.userId,
earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
})),
)
}
if (updated > 0) {
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
}
} catch (error) {
console.error(`Failed to record problem set progress for ${row.submission.id}`, error)
}
} }
try { try {
+24
View File
@@ -17,6 +17,7 @@ import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { FLOWCHART_PASS_GRADES } from "../flowchart/grade"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { calendarDay, dayStart, localTime } from "../time" import { calendarDay, dayStart, localTime } from "../time"
import { queryInteger, rounded } from "./helpers" import { queryInteger, rounded } from "./helpers"
@@ -326,6 +327,29 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
: [] : []
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted])) const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」
const drawn = ids.length
? await db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES)})`,
})
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
inArray(schema.flowchartSubmission.problemId, ids),
// 只数评完了的:评分中、评失败的那次不算「做过」
eq(schema.flowchartSubmission.status, 2),
),
)
.groupBy(schema.flowchartSubmission.problemId)
: []
for (const row of drawn) {
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
}
return success(c, { return success(c, {
className: user.className, className: user.className,
day: lesson.day, day: lesson.day,
+92 -40
View File
@@ -5,6 +5,7 @@ import {
type CreateFlowchartResponse, type CreateFlowchartResponse,
type FlowchartCurrent, type FlowchartCurrent,
type FlowchartDetail, type FlowchartDetail,
type FlowchartScores,
type FlowchartList, type FlowchartList,
type FlowchartListItem, type FlowchartListItem,
type FlowchartStatistics, type FlowchartStatistics,
@@ -14,11 +15,13 @@ import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizz
import { Hono } from "hono" import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, readJson, success } from "../http" import { failure, readJson, success } from "../http"
import { flowchartQueue } from "../queue" import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency" import { buildWordFrequencies } from "../services/word-frequency"
import { dayStart } from "../time" import { dayStart } from "../time"
@@ -38,14 +41,38 @@ function flowchartThrottleKey(userId: number) {
return `flowchart:${userId}` return `flowchart:${userId}`
} }
function canView( /**
user: import("../auth/session").AuthUser, * 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸
row: { userId: number }, * (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图
problem: { createdById: number }, * 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。
*/
function hiddenByProblemSet(
user: AuthUser,
row: { userId: number; problemId: number; createTime: string },
joinTimes: Map<number, string>,
) { ) {
if (row.userId !== user.id || isAdminRole(user)) return false
const joinTime = joinTimes.get(row.problemId)
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
}
function canView(
user: AuthUser,
row: { userId: number; problemId: number; createTime: string },
problem: { createdById: number },
joinTimes: Map<number, string>,
) {
if (hiddenByProblemSet(user, row, joinTimes)) return false
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
} }
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
if (isAdminRole(user)) return Promise.resolve(new Map<number, string>())
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
return problemSetJoinTimes(user.id, [...new Set(own)])
}
function flowchartData( function flowchartData(
flowchart: typeof schema.flowchartSubmission.$inferSelect, flowchart: typeof schema.flowchartSubmission.$inferSelect,
username: string, username: string,
@@ -188,8 +215,9 @@ async function flowchartUserFilter(username: string) {
const flowchartListColumns = { const flowchartListColumns = {
flowchart: { flowchart: {
id: schema.flowchartSubmission.id, id: schema.flowchartSubmission.id,
// showLink 判定要,序列化本身用不到 // showLink 判定要(本人 + 题单闸门),序列化本身用不到
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
status: schema.flowchartSubmission.status, status: schema.flowchartSubmission.status,
createTime: schema.flowchartSubmission.createTime, createTime: schema.flowchartSubmission.createTime,
aiScore: schema.flowchartSubmission.aiScore, aiScore: schema.flowchartSubmission.aiScore,
@@ -250,6 +278,10 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
.limit(limit) .limit(limit)
.offset(offset), .offset(offset),
]) ])
const joinTimes = await joinTimesFor(
user,
rows.map((row) => row.flowchart),
)
return success(c, { return success(c, {
results: rows.map( results: rows.map(
({ flowchart, username, problem }) => ({ flowchart, username, problem }) =>
@@ -266,7 +298,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
aiModel: flowchart.aiModel, aiModel: flowchart.aiModel,
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime, evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem), showLink: canView(user, flowchart, problem, joinTimes),
}) satisfies FlowchartListItem, }) satisfies FlowchartListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
@@ -514,7 +546,8 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
.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"))) .where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1) .limit(1)
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) const user = c.get("user")!
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
return failure(c, 404, "flowchart-not-found", "Submission does not exist") return failure(c, 404, "flowchart-not-found", "Submission does not exist")
return success(c, flowchartData(row.flowchart, row.username)) return success(c, flowchartData(row.flowchart, row.username))
}) })
@@ -527,7 +560,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
.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"))) .where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1) .limit(1)
if (!row || !canView(user, row.flowchart, row.problem)) if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
return failure(c, 404, "flowchart-not-found", "Submission does not exist") return failure(c, 404, "flowchart-not-found", "Submission does not exist")
if (![2, 3].includes(row.flowchart.status)) if (![2, 3].includes(row.flowchart.status))
return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry") return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
@@ -582,47 +615,48 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
} satisfies CreateFlowchartResponse) } satisfies CreateFlowchartResponse)
}) })
/**
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
* current / history / scores 三个接口都从这一份出。
*/
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
const [rows, joinTimes] = await Promise.all([
db
.select({
flowchart: schema.flowchartSubmission,
username: schema.user.username,
})
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
.orderBy(asc(schema.flowchartSubmission.createTime)),
joinTimesFor(user, [{ userId: user.id, problemId }]),
])
const visible = rows.filter((row) => !hiddenByProblemSet(user, row.flowchart, joinTimes))
return { visible, hidden: rows.length - visible.length }
}
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => { flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const rows = await db const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
.select({ const latest = visible.at(-1)?.flowchart
score: schema.flowchartSubmission.aiScore,
grade: schema.flowchartSubmission.aiGrade,
})
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
.orderBy(desc(schema.flowchartSubmission.createTime))
return success(c, { return success(c, {
count: rows.length, count: visible.length,
score: rows[0]?.score ?? 0, score: latest?.aiScore ?? 0,
grade: rows[0]?.grade ?? "", grade: latest?.aiGrade ?? "",
} satisfies FlowchartCurrent) } satisfies FlowchartCurrent)
}) })
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const page = queryInteger(c.req.query("page"), 0, { min: 0 }) const page = queryInteger(c.req.query("page"), 0, { min: 0 })
const rows = await db const { visible: rows } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
.select({
flowchart: schema.flowchartSubmission,
username: schema.user.username,
})
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.where(
and(
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
.orderBy(asc(schema.flowchartSubmission.createTime))
const selected = page === 0 ? rows.at(-1) : rows[page - 1] const selected = page === 0 ? rows.at(-1) : rows[page - 1]
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range") if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
return success(c, { return success(c, {
@@ -630,3 +664,21 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
count: rows.length, count: rows.length,
} satisfies FlowchartDetail) } satisfies FlowchartDetail)
}) })
/**
* 历次分数:结果页签上那一排「第 N 次 xx 分」。原来 history 一次只给一条,
* 要看分数走势得一页一页翻。`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)
})
+2 -48
View File
@@ -10,7 +10,7 @@ import {
type SubmissionListItem, type SubmissionListItem,
type SubmissionTrace, type SubmissionTrace,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, gt, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware" import { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware"
@@ -28,6 +28,7 @@ import {
} from "../services/contest" } from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code" import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { dayStart } from "../time" import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers" import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
@@ -248,53 +249,6 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
} }
}) })
/**
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
*
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
*
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
* - 题单被归档(status 不是 active)
*
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
* 行为不确定,换成聚合顺手定死。
*/
async function problemSetJoinTimes(userId: number, problemIds: number[]) {
const joinTimes = new Map<number, string>()
if (problemIds.length === 0) return joinTimes
const rows = await db
.select({
problemId: schema.problemsetProblem.problemId,
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
})
.from(schema.problemsetProgress)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
.innerJoin(
schema.problemsetProblem,
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
)
.where(
and(
eq(schema.problemsetProgress.userId, userId),
inArray(schema.problemsetProblem.problemId, problemIds),
eq(schema.problemset.status, "active"),
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
),
)
.groupBy(schema.problemsetProblem.problemId)
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
return joinTimes
}
// 参数按「实际用到的字段」声明,而不是整行 $inferSelect:列表接口只 select 需要的列, // 参数按「实际用到的字段」声明,而不是整行 $inferSelect:列表接口只 select 需要的列,
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。 // 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
function canViewSubmission( function canViewSubmission(
+106 -3
View File
@@ -1,7 +1,9 @@
import { and, eq, notInArray, sql } from "drizzle-orm" import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { asRecord } from "../routes/helpers" import { asRecord } from "../routes/helpers"
import { updateAchievementsForProblemSet } from "./achievements"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect type ProgressRow = typeof schema.problemsetProgress.$inferSelect
@@ -238,7 +240,12 @@ export async function resyncProgress(problemsetId: number) {
export async function recordSolvedProblem( export async function recordSolvedProblem(
userId: number, userId: number,
problemId: number, problemId: number,
submissionId: string, /**
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
* 那道闸看的是 progress_detail(routes/submission.ts 的 problemSetJoinTimes)
*/
submissionId: string | null,
solvedAt: string, solvedAt: string,
) { ) {
const joined = await db const joined = await db
@@ -281,7 +288,7 @@ export async function recordSolvedProblem(
), ),
) )
.limit(1) .limit(1)
if (!existing) { if (!existing && submissionId !== null) {
await tx await tx
.insert(schema.problemsetSubmission) .insert(schema.problemsetSubmission)
.values({ problemsetId, userId, submissionId, problemId }) .values({ problemsetId, userId, submissionId, problemId })
@@ -334,3 +341,99 @@ export async function recordSolvedProblem(
} }
return { updated, earned } return { updated, earned }
} }
/**
* 记题单进度,并把这一下拿到的徽章、解锁的题单类成就推给学生。判题(judge/run.ts)和
* 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
*/
export async function recordSolvedAndNotify(
userId: number,
problemId: number,
submissionId: string | null,
solvedAt: string,
) {
try {
const { updated, earned } = await recordSolvedProblem(userId, problemId, submissionId, solvedAt)
if (earned.length > 0) {
await publishAchievementNotification(
userId,
earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
})),
)
}
if (updated > 0) {
const unlocked = await updateAchievementsForProblemSet(userId)
await publishAchievementNotification(
userId,
unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
}
} catch (error) {
console.error(
`Failed to record problem set progress for user ${userId} problem ${problemId}`,
error,
)
}
}
/**
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
* 代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)共用这一道闸。
*
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
*
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
* - 题单被归档(status 不是 active)
*
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
* 行为不确定,换成聚合顺手定死。
*/
export async function problemSetJoinTimes(userId: number, problemIds: number[]) {
const joinTimes = new Map<number, string>()
if (problemIds.length === 0) return joinTimes
const rows = await db
.select({
problemId: schema.problemsetProblem.problemId,
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
})
.from(schema.problemsetProgress)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
.innerJoin(
schema.problemsetProblem,
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
)
.where(
and(
eq(schema.problemsetProgress.userId, userId),
inArray(schema.problemsetProblem.problemId, problemIds),
eq(schema.problemset.status, "active"),
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
),
)
.groupBy(schema.problemsetProblem.problemId)
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
return joinTimes
}
+14
View File
@@ -73,6 +73,19 @@ export const flowchartDetailSchema = z.object({
count: z.number().int(), count: z.number().int(),
}) })
/** 自己在一道题上评完的历次分数,早的在前;hidden = 被题单闸门藏起来的次数 */
export const flowchartScoresSchema = z.object({
scores: z.array(
z.object({
id: z.string(),
score: z.number(),
grade: z.string(),
createTime: z.string(),
}),
),
hidden: z.number().int(),
})
export const flowchartStatisticsSchema = z.object({ export const flowchartStatisticsSchema = z.object({
totalCount: z.number().int(), totalCount: z.number().int(),
avgScore: z.number(), avgScore: z.number(),
@@ -107,6 +120,7 @@ export type FlowchartListItem = z.infer<typeof flowchartListItemSchema>
export type FlowchartList = z.infer<typeof flowchartListSchema> export type FlowchartList = z.infer<typeof flowchartListSchema>
export type FlowchartCurrent = z.infer<typeof flowchartCurrentSchema> export type FlowchartCurrent = z.infer<typeof flowchartCurrentSchema>
export type FlowchartDetail = z.infer<typeof flowchartDetailSchema> export type FlowchartDetail = z.infer<typeof flowchartDetailSchema>
export type FlowchartScores = z.infer<typeof flowchartScoresSchema>
export type CreateFlowchartResponse = z.infer<typeof createFlowchartResponseSchema> export type CreateFlowchartResponse = z.infer<typeof createFlowchartResponseSchema>
export type CreateFlowchartRequest = z.infer<typeof createFlowchartRequestSchema> export type CreateFlowchartRequest = z.infer<typeof createFlowchartRequestSchema>