From d9e6a2a3f0ac6f9ceb0cc64084d7d3a6b5b3c511 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 31 Aug 2026 08:47:57 -0600 Subject: [PATCH] =?UTF-8?q?perf(=E9=A2=98=E5=8D=95):=20=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E7=AB=AF=E9=A2=98=E7=9B=AE=E5=88=97=E8=A1=A8=E5=B0=91=E4=B8=A4?= =?UTF-8?q?=E6=AC=A1=20join=20=E4=B8=80=E6=AC=A1=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=EF=BC=8C=E9=A1=BA=E5=B8=A6=E6=8A=8A=E5=B9=B6=E5=88=97=E7=9A=84?= =?UTF-8?q?=20order=20=E6=8E=92=E7=A8=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 排序 学生端 /problem-sets/:id/problems 只按 order 排,没有 tiebreaker,而卡片是按数组 下标编号的(#1 #2 #3)。order 并列时 Postgres 不保证次序,题单 8(3 道题 order 都是 10)、题单 11(2 道并列 4)、题单 14(3 道并列 0)实际就有并列,于是「第 3 题」指 哪道题每次刷新都可能变。后台那条列表一直是 order + id 排的,两边本来就不一致。 补上 asc(id)。教师进度视图里那份题目清单(同一路由文件 :398)有同样的问题,一并补。 实跑:四道题、三道 order 并列,连打 5 次次序完全一致。 ## 载荷 这个接口原来复用 problemListItemSchema,为此要多 join user + user_profile 凑 createdBy、再多查一次标签表凑 tags —— 而题单卡片只渲染题号、标题、难度、分数和 完成标记。tags / submissionNumber / acceptedNumber / createdBy / contestId / allowFlowchart / showFlowchart / hasAstRules 一个都不用,myStatus 甚至写死是 null。 而且 select 的是 schema.problem 整行,题面、样例、答案、ast_rules、flowchart_data、 sql_display 全都白拉回来一遍。 改成 problemSetProblemItemSchema(id / _id / title / difficulty 四个字段),查询相应 收窄:两次 join 去掉,标签那次查询整条去掉,行查询只取四列。每次请求从 4 条查询降到 3 条,其中最大的那条不再拖整行题面。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/src/routes/problemset.ts | 59 +++++++++++++---------------- packages/contract/src/problemset.ts | 19 +++++++++- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 0bb872a..2484b84 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -1,5 +1,4 @@ import { - problemListItemSchema, problemSetBadgeSchema, problemSetListSchema, problemSetProblemSchema, @@ -168,44 +167,37 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset) .where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1) if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") - const rows = await db.select({ link: schema.problemsetProblem, problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) - .from(schema.problemsetProblem).innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) - .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) - .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) - .where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order)) - const problemIds = rows.map((row) => row.problem.id) - const [tagRows, progressRows] = await Promise.all([ - problemIds.length ? db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags) - .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)).where(inArray(schema.problemTags.problemId, problemIds)) : Promise.resolve([]), - c.get("user") ? db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress) - .where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) : Promise.resolve([]), - ]) - const tags = new Map() - for (const tag of tagRows) tags.set(tag.problemId, [...(tags.get(tag.problemId) ?? []), tag.name]) + // 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、 + // flowchart_data、sql_display 一起拉回来,题单页一个都不用。 + // + // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组 + // 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话 + // 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。 + const rows = await db.select({ + link: schema.problemsetProblem, + problemId: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + difficulty: schema.problem.difficulty, + }) + .from(schema.problemsetProblem) + .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) + .where(eq(schema.problemsetProblem.problemsetId, id)) + .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) + const progressRows = c.get("user") + ? await db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress) + .where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) + : [] const completed = objectValue(progressRows[0]?.detail) - return success(c, rows.map(({ link, problem, user, realName }) => problemSetProblemSchema.parse({ + return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => problemSetProblemSchema.parse({ id: link.id, problemsetId: link.problemsetId, - problem: problemListItemSchema.parse({ - id: problem.id, - _id: problem.displayId, - title: problem.title, - submissionNumber: problem.submissionNumber, - acceptedNumber: problem.acceptedNumber, - difficulty: problem.difficulty, - createdBy: sampleUser(user, realName), - tags: tags.get(problem.id) ?? [], - contestId: problem.contestId, - allowFlowchart: problem.allowFlowchart, - showFlowchart: problem.showFlowchart, - hasAstRules: problem.astRules !== null, - myStatus: null, - }), + problem: { id: problemId, _id: displayId, title, difficulty }, order: link.order, isRequired: link.isRequired, score: link.score, hint: link.hint, - isCompleted: String(problem.id) in completed, + isCompleted: String(problemId) in completed, }))) }) @@ -395,7 +387,8 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c .orderBy(desc(schema.problemsetProgress.isCompleted), desc(schema.problemsetProgress.progressPercentage), asc(schema.problemsetProgress.joinTime)).limit(limit).offset(offset), db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem) .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) - .where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order)), + .where(eq(schema.problemsetProblem.problemsetId, id)) + .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)), ]) const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem])) const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({ diff --git a/packages/contract/src/problemset.ts b/packages/contract/src/problemset.ts index abdb094..a9d6ecd 100644 --- a/packages/contract/src/problemset.ts +++ b/packages/contract/src/problemset.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { paginatedSchema, sampleUserSchema } from "./common" -import { problemListItemSchema } from "./problem" +import { maskedProblemDifficultySchema } from "./problem" export const problemSetUserProgressSummarySchema = z.object({ isJoined: z.boolean(), @@ -41,10 +41,25 @@ export const problemSetSchema = z.object({ export const problemSetListSchema = paginatedSchema(problemSetSchema) +/** + * 题单页的题目卡片只渲染题号、标题、难度,所以只下发这三样(外加题目主键)。 + * + * 以前这里复用 problemListItemSchema,代价是服务端每次都要多 join 一次 user + + * user_profile 凑 createdBy、再多查一次标签表凑 tags,而 tags / submissionNumber / + * acceptedNumber / createdBy / flowchart / hasAstRules / myStatus 在题单页一个都不渲染 + * —— myStatus 甚至是写死的 null。 + */ +export const problemSetProblemItemSchema = z.object({ + id: z.number().int(), + _id: z.string(), + title: z.string(), + difficulty: maskedProblemDifficultySchema, +}) + export const problemSetProblemSchema = z.object({ id: z.number().int(), problemsetId: z.number().int(), - problem: problemListItemSchema, + problem: problemSetProblemItemSchema, order: z.number().int(), isRequired: z.boolean(), score: z.number().int(),