## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
(error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
problemDisplayId 收到 undefined,改动后同样两个响应均通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
+28
-27
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
HINT_MIN_FAILURES,
|
||||
aiAnalysisRecordSchema,
|
||||
aiAnalysisRequestSchema,
|
||||
aiDetailSchema,
|
||||
aiHintRequestSchema,
|
||||
classAnalysisRequestSchema,
|
||||
classPkAnalysisRequestSchema,
|
||||
durationDataSchema,
|
||||
heatmapItemSchema,
|
||||
loginSummarySchema,
|
||||
solvedListSchema,
|
||||
solvedProblemSchema,
|
||||
HINT_MIN_FAILURES,
|
||||
type AiAnalysisRecord,
|
||||
type AiDetail,
|
||||
type DurationData,
|
||||
type Grade,
|
||||
type HeatmapItem,
|
||||
type LoginSummary,
|
||||
type SolvedList,
|
||||
type SolvedProblem,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm"
|
||||
import { Hono, type Context } from "hono"
|
||||
@@ -19,7 +20,7 @@ import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { getPreviousLogin, type AuthUser } from "../auth/session"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus, judgeStatusName } from "../judge/status"
|
||||
import { JudgeStatus, judgeStatusName, type JudgeStatusValue } from "../judge/status"
|
||||
import { failure, success } from "../http"
|
||||
import { completeChat, streamChat } from "../services/ai"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
@@ -27,7 +28,7 @@ import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, ro
|
||||
|
||||
export const aiRoutes = new Hono<AppEnv>()
|
||||
|
||||
const accepted = [0, 10]
|
||||
const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
|
||||
|
||||
/**
|
||||
@@ -61,15 +62,15 @@ const calendarDay = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit",
|
||||
})
|
||||
|
||||
function grade(rank: number | null, count: number, reference = count) {
|
||||
function grade(rank: number | null, count: number, reference = count): Grade {
|
||||
if (!rank || count <= 0) return "C"
|
||||
const percentile = (rank - 1) / count * 100
|
||||
let value = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
|
||||
return value
|
||||
}
|
||||
|
||||
function averageGrade(grades: string[]) {
|
||||
function averageGrade(grades: Grade[]): Grade {
|
||||
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
|
||||
const values = grades.flatMap((item) => weights[item] ?? [])
|
||||
if (!values.length) return ""
|
||||
@@ -149,12 +150,12 @@ async function buildSolved(user: AuthUser, start: string, end: string, firstAc:
|
||||
const period = ranks(periodRows, item.problemId)
|
||||
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
return solvedProblemSchema.parse({
|
||||
return {
|
||||
problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId },
|
||||
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
|
||||
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
|
||||
attempts: attemptsByProblem.get(item.problemId) ?? 1,
|
||||
})
|
||||
} satisfies SolvedProblem
|
||||
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
|
||||
return { solved, problems, scopeIds }
|
||||
}
|
||||
@@ -169,7 +170,7 @@ async function listSolved(user: AuthUser, start: string, end: string, limit: num
|
||||
)),
|
||||
])
|
||||
const { solved } = await buildSolved(user, start, end, firstAc)
|
||||
return solvedListSchema.parse({ results: solved, total: totalRows[0]?.value ?? 0 })
|
||||
return { results: solved, total: totalRows[0]?.value ?? 0 } satisfies SolvedList
|
||||
}
|
||||
|
||||
async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
@@ -195,7 +196,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, start), lte(schema.submission.createTime, end),
|
||||
))
|
||||
const settledFail = (result: number) =>
|
||||
const settledFail = (result: JudgeStatusValue) =>
|
||||
!accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING
|
||||
const errorCounts = new Map<number, number>()
|
||||
for (const row of submissions) {
|
||||
@@ -207,10 +208,10 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
.sort((a, b) => b.count - a.count || a.result - b.result)
|
||||
const firstAc = await firstAcQuery(user, start, end)
|
||||
const problemIds = firstAc.map((item) => item.problemId)
|
||||
if (!problemIds.length) return aiDetailSchema.parse({
|
||||
if (!problemIds.length) return {
|
||||
user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
|
||||
activity, errors, rankScope: "global",
|
||||
})
|
||||
} satisfies AiDetail
|
||||
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([
|
||||
buildSolved(user, start, end, firstAc),
|
||||
db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
|
||||
@@ -244,13 +245,13 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0),
|
||||
}
|
||||
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
|
||||
return aiDetailSchema.parse({
|
||||
return {
|
||||
user: user.username, className: user.className, start, end, flowcharts,
|
||||
solvedCount: solved.length, attempts: solved.map((item) => item.attempts),
|
||||
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
||||
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
||||
activity, errors, rankScope: scopeIds ? "class" : "global",
|
||||
})
|
||||
} satisfies AiDetail
|
||||
}
|
||||
|
||||
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
|
||||
@@ -357,7 +358,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
|
||||
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
|
||||
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
|
||||
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
|
||||
return durationDataSchema.parse({
|
||||
return {
|
||||
unit: config.unit,
|
||||
index: config.count - 1 - index,
|
||||
start: bucket.start.toISOString(),
|
||||
@@ -366,7 +367,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
|
||||
problemCount: solved.length,
|
||||
acceptedCount: acceptedRows.length,
|
||||
submissionCount: inRange.length,
|
||||
})
|
||||
} satisfies DurationData
|
||||
})
|
||||
}
|
||||
|
||||
@@ -407,7 +408,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
|
||||
const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset)
|
||||
value += counts.get(dateKey(day)) ?? 0
|
||||
}
|
||||
return heatmapItemSchema.parse({ timestamp: monday.getTime(), value })
|
||||
return { timestamp: monday.getTime(), value } satisfies HeatmapItem
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -442,7 +443,7 @@ aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
|
||||
analysisError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
return success(c, loginSummarySchema.parse({ summary, analysis, analysisError }))
|
||||
return success(c, { summary, analysis, analysisError } satisfies LoginSummary)
|
||||
})
|
||||
|
||||
aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
|
||||
@@ -450,10 +451,10 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
|
||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
||||
.where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1)
|
||||
if (!row) return success(c, null)
|
||||
return success(c, aiAnalysisRecordSchema.parse({
|
||||
return success(c, {
|
||||
id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data),
|
||||
analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username,
|
||||
}))
|
||||
} satisfies AiAnalysisRecord)
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user