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,
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 是 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
.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)),
)
})
@@ -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<any[]>([])
const similarProblems = ref<ProblemFiltered[]>([])
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 }}
</n-button>
</n-flex>
<!-- getSimilarProblems 已经过 filterResult难度是中文不是 Low/Mid/High -->
<n-tag
v-if="sp.difficulty"
size="small"
:type="
sp.difficulty === 'Low'
sp.difficulty === '简单'
? 'success'
: sp.difficulty === 'High'
: sp.difficulty === '困难'
? 'error'
: 'warning'
"
>
{{
DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等"
}}
{{ sp.difficulty }}
</n-tag>
</n-flex>
</n-list-item>