Files
OJ2/apps/api/src/services/reaction.ts
T
xuyueandClaude Opus 5 9653f1541a refactor(契约): SQL 展示数据进契约,topReaction 收成枚举
d3b05b8 把 SQLDisplay* 列进「保留不动的窄化」,理由是契约那边就是
Record<string, unknown>。这次把那个碗底扫了 —— 因为「契约说不知道形状、
前端手抄一份来渲染」正是那个 commit 修的五处分歧的同一个模子。

SQL 题两个 JSONB 列现在有精确 schema:sqlConfigSchema、sqlDisplaySchema、
sqlDisplayTableSchema、sqlDisplayColumnSchema。键名保持 snake_case,是
problem.sql_config / sql_display 的原文,回滚时旧后端要读同一份。前端
utils/types.ts 里手抄的 SQLConfig / SQLDisplay / SQLDisplayTable /
SQLDisplayColumn 共 30 行删掉改成 re-export,Problem 和 AdminProblem 的
Omit 列表各短两项。手抄那份把 SQLDisplayColumn.type 写成了可选,后端一直
是必有的空串。

后端跟着收紧:commonChecks / generateSqlDisplay 的 Record<string, unknown>
换成 SqlConfig,`sqlConfig.mode === "modify" ? "modify" : "query"` 这句防御
删了 —— 现在类型上就只有那两个值。请求侧 createProblemRequestSchema.sqlConfig
也不再是 record:mode 是枚举、order_sensitive 缺省补 false,正好是旧后端
SQLConfigSerializer 的口径(新后端之前反而比旧的松,什么都收)。

顺带修 adminProblemListItemSchema.topReaction 的注释:写着「当前后端恒传
null,get_top_reactions 没跟着迁过来」,但 admin/problem.ts:257 早就在调了,
只有比赛题列表恒 null。这条注释会骗人去补一个已经存在的实现。类型同时从
z.string() 收成 reactionKeySchema —— getTopReactions 本来就把库里认不出的
类型滤掉了,只是类型上没体现,前端因此得在 transforms.ts 写
`as AdminProblemFiltered["topReaction"]`,现在那个强转和 `?? null` 一起没了。
服务层里的 `as ReactionKey` 换成 isReactionKey 类型守卫。

**收紧 JSONB 的 schema 会把「存量数据形状不对」从静默降级变成 500,所以实打了:**

- 从生产库备份捞出 9 道 SQL 题的 sql_config / sql_display 原文,逐条过新
  schema,9/9 通过;键集与旧后端 judge/sql_runner.py:build_display 的产出
  逐字一致(columns/name/rows/total_rows/truncated,expected 两形态)。
- 把其中 query 形态、modify 形态各一条种进本地库,起 API 打 oj 详情和后台
  详情共四个端点,全 200,expected 两种分支都正确解析。
- topReaction:插两条并列票,按 reactionKeySchema 顺序正确取到 confusing;
  再插一条库里已下掉的类型,被守卫滤掉返回 null。

另:`bunx vue-tsc --noEmit` 不带 -p 是**无效的**,根 tsconfig.json 是
"files": [],塞个类型错误进去照样 exit 0。要跑 `bun run type-check`
(-p tsconfig.app.json),这次的结论出自它。tsc(apps/api) 0 error、
check:routes 168 条无遮蔽、vite build 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:02:25 -06:00

61 lines
2.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { reactionKeySchema, type ReactionKey } 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]),
)
/** 库里的 type 是裸字符串;在 TYPE_ORDER 里就等价于「契约认得的类型」。 */
function isReactionKey(value: string): value is ReactionKey {
return TYPE_ORDER.has(value)
}
export interface TopReaction {
type: ReactionKey
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) {
// 库里可能残留前端已经下掉的旧类型。跳过而不是当成并列最优 ——
// 否则一个已经不展示的类型会把真正的最高票挤掉。
if (!isReactionKey(row.type)) continue
const order = TYPE_ORDER.get(row.type)!
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
}