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>
61 lines
2.0 KiB
TypeScript
61 lines
2.0 KiB
TypeScript
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
|
||
}
|