diff --git a/apps/api/src/flowchart/grade.ts b/apps/api/src/flowchart/grade.ts new file mode 100644 index 0000000..4310bf1 --- /dev/null +++ b/apps/api/src/flowchart/grade.ts @@ -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" +} diff --git a/apps/api/src/flowchart/run.ts b/apps/api/src/flowchart/run.ts index f23db93..cbe24c3 100644 --- a/apps/api/src/flowchart/run.ts +++ b/apps/api/src/flowchart/run.ts @@ -5,6 +5,8 @@ import { config } from "../config" import { db, schema } from "../db" import { publishFlowchartUpdate } from "../events" import { completeChat } from "../services/ai" +import { recordSolvedAndNotify } from "../services/problemset" +import { gradeForScore, isFlowchartPass } from "./grade" import type { FlowchartJobData } from "./job" 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)}` } -/** - * 等级一律由分数推出来,不采信模型自报的 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) { const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] const json = block ?? value.match(/\{[\s\S]*\}/)?.[0] @@ -89,6 +79,17 @@ export async function evaluateFlowchart( evaluationTime: new Date().toISOString(), }) .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, { type: "flowchart_evaluation_completed", submissionId: row.flowchart.id, diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index be76f14..e2aa5b5 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -6,11 +6,8 @@ import { and, eq, inArray } from "drizzle-orm" import { config } from "../config" import { db, schema } from "../db" import { publishAchievementNotification } from "../events" -import { - updateAchievementsForProblemSet, - updateAchievementsForSubmission, -} from "../services/achievements" -import { recordSolvedProblem } from "../services/problemset" +import { updateAchievementsForSubmission } from "../services/achievements" +import { recordSolvedAndNotify } from "../services/problemset" import { checkAst, type AstRule } from "./ast" import { publishSubmissionUpdate } from "./events" import { asRecord } from "../routes/helpers" @@ -580,43 +577,12 @@ export async function judgeSubmission(job: JudgeJobData) { // 进度已经落库,跳回题单页看到的就是新数据。 // 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。 if (row.submission.contestId === null && isAccepted(result)) { - try { - const { updated, earned } = await recordSolvedProblem( - row.submission.userId, - row.problem.id, - row.submission.id, - 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) - } + await recordSolvedAndNotify( + row.submission.userId, + row.problem.id, + row.submission.id, + row.submission.createTime, + ) } try { diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 8b38592..1e9826f 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -17,6 +17,7 @@ import { Hono } from "hono" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" +import { FLOWCHART_PASS_GRADES } from "../flowchart/grade" import { JudgeStatus } from "../judge/status" import { calendarDay, dayStart, localTime } from "../time" 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])) + // 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。 + // 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」 + const drawn = ids.length + ? await db + .select({ + problemId: schema.flowchartSubmission.problemId, + passed: sql`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, { className: user.className, day: lesson.day, diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index d603b34..f028cbf 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -5,6 +5,7 @@ import { type CreateFlowchartResponse, type FlowchartCurrent, type FlowchartDetail, + type FlowchartScores, type FlowchartList, type FlowchartListItem, type FlowchartStatistics, @@ -14,11 +15,13 @@ import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizz 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 { problemSetJoinTimes } from "../services/problemset" import { consumeToken } from "../services/throttling" import { buildWordFrequencies } from "../services/word-frequency" import { dayStart } from "../time" @@ -38,14 +41,38 @@ function flowchartThrottleKey(userId: number) { return `flowchart:${userId}` } -function canView( - user: import("../auth/session").AuthUser, - row: { userId: number }, - problem: { createdById: number }, +/** + * 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸 + * (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图 + * 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。 + */ +function hiddenByProblemSet( + user: AuthUser, + row: { userId: number; problemId: number; createTime: string }, + joinTimes: Map, ) { + 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, +) { + if (hiddenByProblemSet(user, row, joinTimes)) return false 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()) + const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId) + return problemSetJoinTimes(user.id, [...new Set(own)]) +} + function flowchartData( flowchart: typeof schema.flowchartSubmission.$inferSelect, username: string, @@ -188,8 +215,9 @@ async function flowchartUserFilter(username: string) { const flowchartListColumns = { flowchart: { id: schema.flowchartSubmission.id, - // showLink 判定要,序列化本身用不到 + // showLink 判定要(本人 + 题单闸门),序列化本身用不到 userId: schema.flowchartSubmission.userId, + problemId: schema.flowchartSubmission.problemId, status: schema.flowchartSubmission.status, createTime: schema.flowchartSubmission.createTime, aiScore: schema.flowchartSubmission.aiScore, @@ -250,6 +278,10 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { .limit(limit) .offset(offset), ]) + const joinTimes = await joinTimesFor( + user, + rows.map((row) => row.flowchart), + ) return success(c, { results: rows.map( ({ flowchart, username, problem }) => @@ -266,7 +298,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { aiModel: flowchart.aiModel, processingTime: flowchart.processingTime, evaluationTime: flowchart.evaluationTime, - showLink: canView(user, flowchart, problem), + showLink: canView(user, flowchart, problem, joinTimes), }) satisfies FlowchartListItem, ), 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)) .where(eq(schema.flowchartSubmission.id, c.req.param("id"))) .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 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)) .where(eq(schema.flowchartSubmission.id, c.req.param("id"))) .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") if (![2, 3].includes(row.flowchart.status)) 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) }) +/** + * 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。 + * 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) => { const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) - const rows = await db - .select({ - 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)) + const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId) + const latest = visible.at(-1)?.flowchart return success(c, { - count: rows.length, - score: rows[0]?.score ?? 0, - grade: rows[0]?.grade ?? "", + count: visible.length, + score: latest?.aiScore ?? 0, + grade: latest?.aiGrade ?? "", } satisfies FlowchartCurrent) }) flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { const problemId = queryInteger(c.req.param("id"), 0, { min: 1 }) const page = queryInteger(c.req.query("page"), 0, { min: 0 }) - const rows = await 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, c.get("user")!.id), - eq(schema.flowchartSubmission.problemId, problemId), - eq(schema.flowchartSubmission.status, 2), - ), - ) - .orderBy(asc(schema.flowchartSubmission.createTime)) + const { visible: rows } = await myEvaluatedFlowcharts(c.get("user")!, problemId) 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") return success(c, { @@ -630,3 +664,21 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => count: rows.length, } 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) +}) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index d54f782..aef4091 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -10,7 +10,7 @@ import { type SubmissionListItem, type SubmissionTrace, } 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 { optionalAuth, requireAuth, requireSuperAdmin } from "../auth/middleware" @@ -28,6 +28,7 @@ import { } from "../services/contest" import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" +import { problemSetJoinTimes } from "../services/problemset" import { consumeToken } from "../services/throttling" import { dayStart } from "../time" 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() - 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`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 需要的列, // 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。 function canViewSubmission( diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index 83bd39d..8a4e99f 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -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 { publishAchievementNotification } from "../events" import { asRecord } from "../routes/helpers" +import { updateAchievementsForProblemSet } from "./achievements" type BadgeRow = typeof schema.problemsetBadge.$inferSelect type ProgressRow = typeof schema.problemsetProgress.$inferSelect @@ -238,7 +240,12 @@ export async function resyncProgress(problemsetId: number) { export async function recordSolvedProblem( userId: number, problemId: number, - submissionId: string, + /** + * 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id + * 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」—— + * 那道闸看的是 progress_detail(routes/submission.ts 的 problemSetJoinTimes) + */ + submissionId: string | null, solvedAt: string, ) { const joined = await db @@ -281,7 +288,7 @@ export async function recordSolvedProblem( ), ) .limit(1) - if (!existing) { + if (!existing && submissionId !== null) { await tx .insert(schema.problemsetSubmission) .values({ problemsetId, userId, submissionId, problemId }) @@ -334,3 +341,99 @@ export async function recordSolvedProblem( } 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() + 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`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 +} diff --git a/packages/contract/src/flowchart.ts b/packages/contract/src/flowchart.ts index d66efa1..57ca5a2 100644 --- a/packages/contract/src/flowchart.ts +++ b/packages/contract/src/flowchart.ts @@ -73,6 +73,19 @@ export const flowchartDetailSchema = z.object({ 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({ totalCount: z.number().int(), avgScore: z.number(), @@ -107,6 +120,7 @@ export type FlowchartListItem = z.infer export type FlowchartList = z.infer export type FlowchartCurrent = z.infer export type FlowchartDetail = z.infer +export type FlowchartScores = z.infer export type CreateFlowchartResponse = z.infer export type CreateFlowchartRequest = z.infer