fix(题目推荐): 已 AC 过滤下推 SQL、难度按语义排序、难度标签渲染
Deploy / deploy (push) Waiting to run

相似题目推荐(GET /problems/:displayId/similar)三个 bug:

- 排除已 AC 的题发生在 limit(5) 之后,候选被筛掉几条就少几条,甚至清零让整块
  不渲染。而这个接口只在刚 AC 或连挂三次时才调用,正是最容易全中的时候。
  (老栈是 .exclude(...) 在切片之前,重写时引入的回归)
- order by difficulty 是 text 字典序,High < Low < Mid,「由易到难」实际是最难的
  先上。改成显式 case 排名,并加上共享标签数作为第一排序键、id 兜稳定序。
- 前端 ref<any[]> 盖住了类型错误:getSimilarProblems 已过 filterResult,难度是
  中文,模板却在比 'Low' / 'High' 再查 DIFFICULTY 表,结果每条推荐都渲染成黄色
  的「中等」。

顺带:比赛题不再发这个请求(接口只在公开题库按 displayId 找,撞号会在比赛中把
题库列给学生);多余的 id in (子查询) 换成 join 中间表。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 09:24:46 +08:00
co-authored by Claude Opus 5
parent 954797a9a5
commit b16e11e85d
2 changed files with 46 additions and 32 deletions
+36 -24
View File
@@ -17,6 +17,7 @@ import {
ilike, ilike,
inArray, inArray,
isNull, isNull,
ne,
notInArray, notInArray,
or, or,
sql, sql,
@@ -335,6 +336,18 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
.from(schema.problemTags) .from(schema.problemTags)
.where(eq(schema.problemTags.problemId, target.id)) .where(eq(schema.problemTags.problemId, target.id))
if (targetTags.length === 0) return success(c, []) 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 是 textLow / 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 const rows = await db
.select({ .select({
problem: schema.problem, problem: schema.problem,
@@ -344,40 +357,39 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
.from(schema.problem) .from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, 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( .where(
and( and(
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
sql`${schema.problem.id} <> ${target.id}`, ne(schema.problem.id, target.id),
inArray( solvedIds.length ? notInArray(schema.problem.id, solvedIds) : undefined,
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemtagId,
targetTags.map((tag) => tag.id),
),
),
),
), ),
) )
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) .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) .limit(5)
const [tags, statuses] = await Promise.all([ const tags = await getProblemTags(rows.map((row) => row.problem.id))
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,
)
return success( return success(
c, c,
filtered.map((row) => listItem(row, tags, statuses)), rows.map((row) => listItem(row, tags, statuses)),
) )
}) })
@@ -5,8 +5,7 @@ import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants" import type { Problem, ProblemFiltered, ProblemStatus } from "utils/types"
import type { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -46,11 +45,14 @@ const sqlChangedTables = computed(() => {
const router = useRouter() const router = useRouter()
// 相似题目推荐 // 相似题目推荐
const similarProblems = ref<any[]>([]) const similarProblems = ref<ProblemFiltered[]>([])
const similarLoaded = ref(false) const similarLoaded = ref(false)
async function loadSimilarProblems() { async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return if (similarLoaded.value || !problem.value) return
// 比赛题不推荐:接口按 displayId 在**公开题库**里找,比赛题的编号默认是 1/2/3,
// 一般白跑一趟 404,撞上同号公开题时反而会在比赛中把题库列给学生。
if (problem.value.contestId !== null) return
try { try {
similarProblems.value = await getSimilarProblems(problem.value._id) similarProblems.value = await getSimilarProblems(problem.value._id)
} catch { } catch {
@@ -407,19 +409,19 @@ function type(status: ProblemStatus) {
{{ sp.title }} {{ sp.title }}
</n-button> </n-button>
</n-flex> </n-flex>
<!-- getSimilarProblems 已经过 filterResult难度是中文不是 Low/Mid/High -->
<n-tag <n-tag
v-if="sp.difficulty"
size="small" size="small"
:type=" :type="
sp.difficulty === 'Low' sp.difficulty === '简单'
? 'success' ? 'success'
: sp.difficulty === 'High' : sp.difficulty === '困难'
? 'error' ? 'error'
: 'warning' : 'warning'
" "
> >
{{ {{ sp.difficulty }}
DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等"
}}
</n-tag> </n-tag>
</n-flex> </n-flex>
</n-list-item> </n-list-item>