From b16e11e85d6e6b50ab7b0d9c5032d1dcb8a4f7e8 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 22 Sep 2026 09:24:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E7=9B=AE=E6=8E=A8=E8=8D=90):=20?= =?UTF-8?q?=E5=B7=B2=20AC=20=E8=BF=87=E6=BB=A4=E4=B8=8B=E6=8E=A8=20SQL?= =?UTF-8?q?=E3=80=81=E9=9A=BE=E5=BA=A6=E6=8C=89=E8=AF=AD=E4=B9=89=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E3=80=81=E9=9A=BE=E5=BA=A6=E6=A0=87=E7=AD=BE=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 相似题目推荐(GET /problems/:displayId/similar)三个 bug: - 排除已 AC 的题发生在 limit(5) 之后,候选被筛掉几条就少几条,甚至清零让整块 不渲染。而这个接口只在刚 AC 或连挂三次时才调用,正是最容易全中的时候。 (老栈是 .exclude(...) 在切片之前,重写时引入的回归) - order by difficulty 是 text 字典序,High < Low < Mid,「由易到难」实际是最难的 先上。改成显式 case 排名,并加上共享标签数作为第一排序键、id 兜稳定序。 - 前端 ref 盖住了类型错误:getSimilarProblems 已过 filterResult,难度是 中文,模板却在比 'Low' / 'High' 再查 DIFFICULTY 表,结果每条推荐都渲染成黄色 的「中等」。 顺带:比赛题不再发这个请求(接口只在公开题库按 displayId 找,撞号会在比赛中把 题库列给学生);多余的 id in (子查询) 换成 join 中间表。 Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/routes/problem.ts | 60 +++++++++++-------- .../oj/problem/components/ProblemContent.vue | 18 +++--- 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 7785b24..01b56e7 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -17,6 +17,7 @@ import { ilike, inArray, isNull, + ne, notInArray, or, sql, @@ -335,6 +336,18 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { .from(schema.problemTags) .where(eq(schema.problemTags.problemId, target.id)) if (targetTags.length === 0) return success(c, []) + // 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的 + // 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)—— + // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 + const statuses = await getProblemStatuses(c.get("user")?.id) + const solvedIds = Object.entries(statuses) + .filter(([, value]) => toObject(value).status === JudgeStatus.ACCEPTED) + .map(([key]) => Number(key)) + .filter((id) => Number.isInteger(id)) + // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— + // High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。 + const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end` + const sharedTags = count(schema.problemTags.problemtagId) const rows = await db .select({ problem: schema.problem, @@ -344,40 +357,39 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { .from(schema.problem) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + // 直接 join 中间表(而不是 id in (子查询))是为了数出重合了几个标签, + // 拿来当第一排序键。(problem_id, problemtag_id) 上有唯一约束,不会重复计数。 + .innerJoin( + schema.problemTags, + and( + eq(schema.problemTags.problemId, schema.problem.id), + inArray( + schema.problemTags.problemtagId, + targetTags.map((tag) => tag.id), + ), + ), + ) .where( and( eq(schema.problem.visible, true), isNull(schema.problem.contestId), - sql`${schema.problem.id} <> ${target.id}`, - inArray( - schema.problem.id, - db - .select({ id: schema.problemTags.problemId }) - .from(schema.problemTags) - .where( - inArray( - schema.problemTags.problemtagId, - targetTags.map((tag) => tag.id), - ), - ), - ), + ne(schema.problem.id, target.id), + solvedIds.length ? notInArray(schema.problem.id, solvedIds) : undefined, ), ) .groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) - .orderBy(asc(schema.problem.difficulty)) + // 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条 + .orderBy( + desc(sharedTags), + asc(difficultyRank), + desc(schema.problem.acceptedNumber), + asc(schema.problem.id), + ) .limit(5) - const [tags, statuses] = await Promise.all([ - getProblemTags(rows.map((row) => row.problem.id)), - getProblemStatuses(c.get("user")?.id), - ]) - const filtered = rows.filter( - (row) => - toObject(statuses[String(row.problem.id)]).status !== - JudgeStatus.ACCEPTED, - ) + const tags = await getProblemTags(rows.map((row) => row.problem.id)) return success( c, - filtered.map((row) => listItem(row, tags, statuses)), + rows.map((row) => listItem(row, tags, statuses)), ) }) diff --git a/apps/web/src/oj/problem/components/ProblemContent.vue b/apps/web/src/oj/problem/components/ProblemContent.vue index f3b2818..aff246e 100644 --- a/apps/web/src/oj/problem/components/ProblemContent.vue +++ b/apps/web/src/oj/problem/components/ProblemContent.vue @@ -5,8 +5,7 @@ import { storeToRefs } from "pinia" import { useCodeStore } from "oj/store/code" import { useProblemStore } from "oj/store/problem" import { createTestSubmission } from "utils/judge" -import { DIFFICULTY } from "utils/constants" -import type { Problem, ProblemStatus } from "utils/types" +import type { Problem, ProblemFiltered, ProblemStatus } from "utils/types" import Copy from "shared/components/Copy.vue" import { useDark } from "@vueuse/core" import { MdPreview } from "md-editor-v3" @@ -46,11 +45,14 @@ const sqlChangedTables = computed(() => { const router = useRouter() // 相似题目推荐 -const similarProblems = ref([]) +const similarProblems = ref([]) const similarLoaded = ref(false) async function loadSimilarProblems() { if (similarLoaded.value || !problem.value) return + // 比赛题不推荐:接口按 displayId 在**公开题库**里找,比赛题的编号默认是 1/2/3, + // 一般白跑一趟 404,撞上同号公开题时反而会在比赛中把题库列给学生。 + if (problem.value.contestId !== null) return try { similarProblems.value = await getSimilarProblems(problem.value._id) } catch { @@ -407,19 +409,19 @@ function type(status: ProblemStatus) { {{ sp.title }} + - {{ - DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等" - }} + {{ sp.difficulty }}