feat(后台): 补回「最高票评价」列,重写时漏掉了

管理端题目列表的「反馈」列一直是空的:前端渲染逻辑还在,后端两处
硬编码 topReaction: null —— 旧后端的 reaction/services.py:get_top_reactions
没有跟着迁过来,阶段 0 的清单也没抓到。

新增 services/reaction.ts,口径逐条对齐旧实现:

- 按 (problem_id, type) 分组计数,取票数最高的那个;
- 并列时按 reactionKeySchema 的定义顺序取靠前的,与前端 REACTIONS 的顺序
  一致(旧后端是 TYPE_ORDER,已核对两边七个 key 的顺序与内容完全相同);
- 只返回有评价的题目,没有的题调用方兜 null。

比库里可能残留、但前端已经下掉的类型多了一道跳过:不跳的话一个不再展示的
类型会以并列最优的身份把真正的最高票挤掉。

只有公开题列表下发,比赛题列表不下发 —— 与旧后端一致(旧的 top_reaction
只出现在 ProblemAPI,ContestProblemAPI 没有),前端那一列的注释也是这么写的。

实测:造 3 票 learned + 1 票 too_easy 取到 learned/3;interesting 与
too_hard 各 1 票时取到 too_hard(定义顺序在前);插一条 obsolete_kind
不影响结果;无评价的题返回 null。探针数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 14:08:09 -06:00
parent 1198895b54
commit 25c6e99c54
3 changed files with 60 additions and 2 deletions

View File

@@ -26,6 +26,7 @@ import { packTestCaseZip, processTestCaseZip, readInfo, readSqlScripts, TestCase
import { config } from "../../config"
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { getTopReactions } from "../../services/reaction"
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>()
@@ -252,6 +253,8 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
])
// 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致
const topReactions = await getTopReactions(rows.map(({ problem }) => problem.id))
return success(c, adminProblemListSchema.parse({
results: await Promise.all(rows.map(async ({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({
@@ -266,7 +269,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
topReaction: topReactions.get(problem.id) ?? null,
}))),
total: totalRow[0]?.value ?? 0,
}))

View File

@@ -0,0 +1,55 @@
import { reactionKeySchema } from "@oj2/contract"
import { count, inArray } from "drizzle-orm"
import { db, schema } from "../db"
/**
* 并列票数时按 reactionKeySchema 的定义顺序取靠前的那个,与前端 REACTIONS
* 的顺序一致 —— 对齐旧后端 `reaction/services.py` 的 TYPE_ORDER。
* 改这里的顺序会让同票题目的展示结果跟着变。
*/
const TYPE_ORDER = new Map<string, number>(
reactionKeySchema.options.map((key, index) => [key, index]),
)
export interface TopReaction {
type: string
count: number
}
/**
* 批量取每道题得票最高的评价,返回 `Map<problemId, {type, count}>`
* **只包含有评价的题目**(没有的题不进 Map调用方自己兜 null
*
* 对齐旧后端 `reaction/services.py:get_top_reactions`。
*/
export async function getTopReactions(problemIds: number[]) {
const top = new Map<number, TopReaction>()
if (problemIds.length === 0) return top
const rows = await db
.select({
problemId: schema.reaction.problemId,
type: schema.reaction.type,
value: count(),
})
.from(schema.reaction)
.where(inArray(schema.reaction.problemId, problemIds))
.groupBy(schema.reaction.problemId, schema.reaction.type)
for (const row of rows) {
const order = TYPE_ORDER.get(row.type)
// 库里可能残留前端已经下掉的旧类型。跳过而不是当成并列最优 ——
// 否则一个已经不展示的类型会把真正的最高票挤掉。
if (order === undefined) continue
const current = top.get(row.problemId)
if (
!current ||
row.value > current.count ||
(row.value === current.count && order < TYPE_ORDER.get(current.type)!)
) {
top.set(row.problemId, { type: row.type, count: row.value })
}
}
return top
}

View File

@@ -137,7 +137,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
},
{
title: "反馈",
key: "top_reaction",
key: "topReaction",
width: 60,
render: (row) => {
const top = row.topReaction