refactor(契约): samples / answers / testCaseScore 补回旧后端的校验
Some checks failed
Deploy / deploy (push) Has been cancelled

旧后端这三个字段都是逐字段校验的(CreateSampleSerializer /
CreateAnswerSerializer / CreateTestCaseScoreSerializer),新后端一路写成
`z.record(z.string(), z.unknown())`,**比旧的松**。松出来的不只是少拦几个
错误请求,还有一处静默的落库形状漂移。

## test_case_score 会越存越胖,而且 score 变成字符串

上传接口的响应有六个键,前端 `{...entry, score}` 原样往回传。旧后端的 DRF
serializer 只认 input_name / output_name / score,多的直接丢,所以生产库
956 行 test_case_score **全部只有三个键**、7990 条 score 全部是 int
(前端算出来是 `(100/n).toFixed(0)` 这种字符串,IntegerField 收下时转了)。

新后端不做这件事,往后在 OJ2 上新建或编辑的题会存六个键、score 是字符串 ——
和旧后端写出来的不是一个形状。这列判题机不读,不影响判题,但它是回滚要
原样交回去的持久化数据,不该在这上面分叉。

现在三个精确 schema 顶上:problemSampleSchema / problemAnswerSchema /
problemTestCaseScoreSchema。zod 的 object 默认剥未知键,和 DRF 同一个行为;
score 用 `z.coerce.number().int().min(0)`,对齐 IntegerField 的收字符串转整数。

前端跟着改:`Testcase` 从 `TestCaseEntry & { score: string }`(六键+字符串分数)
换成契约的 ProblemTestCaseScore(三键+整数),三处构造点只挑落库要的键。
detail.vue 那处写成 `Number((100 / n).toFixed(0))` —— 取值和原来逐字相同,
只是不再包成字符串,分数算法一个字没动。AdminProblem 的 Omit 列表也短了两项
(samples / testCaseScore 现在契约里就是准的),只剩 answers 要把 language
收窄成 LANGUAGE。

## 顺带:一段被 @ts-ignore 压着的死代码

admin/problem/detail.vue 上传测试点那里有

    // @ts-ignore
    if (res.error) { ... }

—— 拿 { error, data } 信封当返回值判。上一个 commit 拆信封时正是因为
@ts-ignore 压着,vue-tsc 没报出来。res 现在是 UploadTestCaseResponse,
没有 error 这个键,这个分支永远进不去。失败本来就走 catch。

(全仓另外两处 @ts-ignore 查过了,是 skulpt 和 wangeditor 没类型定义,正常。)

## 生产数据依据

956 道题逐条扫过备份:samples 947 条 {input,output} + 9 条空数组(SQL 题没
样例);answers 268 条 {code,language} + 633 null + 55 空数组;
test_case_score 956 条全是三键,score 全 int。收紧不会打到任何存量行。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打了写路径:

- 后台题目详情 200,三个 schema 都 parse 得过存量数据。
- 故意造脏 PUT:score 传字符串 "20" + 塞进 stripped_output_md5 / input_size /
  output_size,落库是干净的 `{input_name, output_name, score: 20(int)}`;
  samples 和 answers 里塞的多余键同样被剥掉。
- 三个错误载荷都按预期 400:samples 缺 output、answers 缺 code、score 传负数。
- 浏览器里打开后台题目编辑页,点提交 → PUT 200 → 跳回列表,库里形状正确。

(顺带发现 tags 为空的题在编辑页点提交会被 `tags.min(1)` 挡下 400 —— 旧后端
`allow_empty=False` 也是这个行为,不在本次范围。)

冒烟改动已还原:problem 2 的 answers 复位成 [],测试用的「冒烟」标签删掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:04:41 -06:00
parent 5c772319e9
commit 3f55d231c3
5 changed files with 63 additions and 34 deletions

View File

@@ -185,11 +185,12 @@ async function upload() {
const entries = res.info const entries = res.info
const baseScore = Math.floor(100 / entries.length) const baseScore = Math.floor(100 / entries.length)
const remainder = 100 - baseScore * entries.length const remainder = 100 - baseScore * entries.length
// 只留落库的三个键:上传响应里的 stripped_output_md5 / input_size /
// output_size 保存时会被剥掉,见契约 problemTestCaseScoreSchema
const testcases: Testcase[] = entries.map((entry, i) => ({ const testcases: Testcase[] = entries.map((entry, i) => ({
...entry, input_name: entry.input_name,
score: String( output_name: entry.output_name,
i === entries.length - 1 ? baseScore + remainder : baseScore, score: i === entries.length - 1 ? baseScore + remainder : baseScore,
),
})) }))
emit("uploaded", res.id, testcases) emit("uploaded", res.id, testcases)

View File

@@ -174,10 +174,9 @@ async function upload() {
const baseScore = Math.floor(100 / entries.length) const baseScore = Math.floor(100 / entries.length)
const remainder = 100 - baseScore * entries.length const remainder = 100 - baseScore * entries.length
const testcases: Testcase[] = entries.map((entry, i) => ({ const testcases: Testcase[] = entries.map((entry, i) => ({
...entry, input_name: entry.input_name,
score: String( output_name: entry.output_name,
i === entries.length - 1 ? baseScore + remainder : baseScore, score: i === entries.length - 1 ? baseScore + remainder : baseScore,
),
})) }))
emit("uploaded", res.id, testcases) emit("uploaded", res.id, testcases)

View File

@@ -288,17 +288,16 @@ function resetTemplate(language: LANGUAGE) {
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) { async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
try { try {
// 失败走 catch —— 原来这里还有一句 `if (res.error)`(拿 { error, data }
// 信封当返回值),被 @ts-ignore 压着,信封拆掉之后就是一段死代码了
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value }) const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
// @ts-ignore
if (res.error) {
message.error("上传测试用例失败")
return
}
// score 不在上传响应里,前端按测试点数量平分补上 // score 不在上传响应里,前端按测试点数量平分补上
const entries = res.info const entries = res.info
const testcases: Testcase[] = entries.map((entry) => ({ const testcases: Testcase[] = entries.map((entry) => ({
...entry, input_name: entry.input_name,
score: (100 / entries.length).toFixed(0), output_name: entry.output_name,
// 取值与原来的 `.toFixed(0)` 逐字相同,只是不再包成字符串
score: Number((100 / entries.length).toFixed(0)),
})) }))
problem.value.testCaseScore = testcases problem.value.testCaseScore = testcases
problem.value.testCaseId = res.id problem.value.testCaseId = res.id

View File

@@ -101,10 +101,12 @@ export type {
export type { UploadTestCaseResponse } from "@oj2/contract" export type { UploadTestCaseResponse } from "@oj2/contract"
/** /**
* 题目表单里的测试点:上传返回的条目 + 前端本地算出的分值。 * 题目表单里的测试点。落库的只有 {input_name, output_name, score} 三个键 ——
* `score` **不在响应里** —— 是上传完成后按测试点数量平分补上去的。 * 上传响应里多出来的 stripped_output_md5 / input_size / output_size 在保存时
* 被剥掉(旧后端的 serializer 也是这么干的,见契约 problemTestCaseScoreSchema
* score 不在上传响应里,是上传完成后按测试点数量平分补上去的。
*/ */
export type Testcase = TestCaseEntry & { score: string } export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
/** /**
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化: * 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
@@ -140,18 +142,11 @@ export type {
/** 后台题目详情:比 oj 侧多 answers / testCase* / astRules */ /** 后台题目详情:比 oj 侧多 answers / testCase* / astRules */
export type AdminProblem = Omit< export type AdminProblem = Omit<
ContractAdminProblem, ContractAdminProblem,
| "languages" "languages" | "template" | "answers" | "astRules"
| "template"
| "testCaseScore"
| "samples"
| "answers"
| "astRules"
> & { > & {
languages: LANGUAGE[] languages: LANGUAGE[]
template: { [key in LANGUAGE]?: string } template: { [key in LANGUAGE]?: string }
// 测试点条目的键名由判题沙箱定,保持 snake_case // 契约里 answers[].language 是 string这里收窄成 LANGUAGE
testCaseScore: Testcase[]
samples: { input: string; output: string }[]
answers: { language: LANGUAGE; code: string }[] answers: { language: LANGUAGE; code: string }[]
astRules?: AstRules | null astRules?: AstRules | null
} }
@@ -470,7 +465,6 @@ import type {
AdminExercise, AdminExercise,
AdminTutorial, AdminTutorial,
CreateSubmissionRequest, CreateSubmissionRequest,
TestCaseEntry,
} from "@oj2/contract" } from "@oj2/contract"
/** /**

View File

@@ -43,6 +43,9 @@ export const createAnnouncementRequestSchema = z.object({
export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema
export type ProblemSample = z.infer<typeof problemSampleSchema>
export type ProblemAnswer = z.infer<typeof problemAnswerSchema>
export type ProblemTestCaseScore = z.infer<typeof problemTestCaseScoreSchema>
export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema> export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema>
export type AdminAnnouncementListItem = z.infer< export type AdminAnnouncementListItem = z.infer<
typeof adminAnnouncementListItemSchema typeof adminAnnouncementListItemSchema
@@ -530,6 +533,39 @@ export const adminProblemListItemSchema = z.object({
export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema) export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema)
/**
* 题面样例 / 标准答案 / 测试点分值。这三个旧后端都是逐字段校验的
* CreateSampleSerializer / CreateAnswerSerializer / CreateTestCaseScoreSerializer
* 新后端一路写成 `z.record(z.unknown())` **反而比旧的松**。
*
* 生产库 956 道题逐条比对过samples 947 条是 {input,output}(另外 9 条是空数组,
* SQL 题没样例、answers 268 条是 {language,code}633 条 null、55 条空数组)。
*/
export const problemSampleSchema = z.object({
input: z.string(),
output: z.string(),
})
export const problemAnswerSchema = z.object({
language: z.string(),
code: z.string(),
})
/**
* 测试点分值。**只有这三个键** —— 上传接口的响应比这多三个
* stripped_output_md5 / input_size / output_size旧后端的 DRF serializer
* 收下时把多的丢掉,生产库 956 行全是三键。zod 的 object 默认也剥未知键,
* 行为一致。
*
* score 前端算出来是字符串(`(100 / n).toFixed(0)`),旧后端 IntegerField 转成
* 整数落库7990 条全是 int —— 这里用 coerce 保持同一个落库形状。
*/
export const problemTestCaseScoreSchema = z.object({
input_name: z.string().max(32),
output_name: z.string().max(32),
score: z.coerce.number().int().min(0),
})
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */ /** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
export const adminProblemSchema = z.object({ export const adminProblemSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -538,9 +574,9 @@ export const adminProblemSchema = z.object({
description: z.string(), description: z.string(),
inputDescription: z.string(), inputDescription: z.string(),
outputDescription: z.string(), outputDescription: z.string(),
samples: z.array(z.record(z.string(), z.unknown())), samples: z.array(problemSampleSchema),
testCaseId: z.string(), testCaseId: z.string(),
testCaseScore: z.array(z.record(z.string(), z.unknown())), testCaseScore: z.array(problemTestCaseScoreSchema),
hint: z.string().nullable(), hint: z.string().nullable(),
languages: z.array(z.string()), languages: z.array(z.string()),
template: z.record(z.string(), z.string()), template: z.record(z.string(), z.string()),
@@ -564,7 +600,7 @@ export const adminProblemSchema = z.object({
mermaidCode: z.string().nullable(), mermaidCode: z.string().nullable(),
flowchartHint: z.string().nullable(), flowchartHint: z.string().nullable(),
astRules: z.unknown(), astRules: z.unknown(),
answers: z.array(z.record(z.string(), z.unknown())), answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(), prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(), sqlConfig: sqlConfigSchema.nullable(),
sqlDisplay: sqlDisplaySchema.nullable(), sqlDisplay: sqlDisplaySchema.nullable(),
@@ -576,9 +612,9 @@ export const createProblemRequestSchema = z.object({
description: z.string(), description: z.string(),
inputDescription: z.string(), inputDescription: z.string(),
outputDescription: z.string(), outputDescription: z.string(),
samples: z.array(z.record(z.string(), z.unknown())), samples: z.array(problemSampleSchema),
testCaseId: z.string().regex(/^[a-zA-Z0-9]+$/).max(32), testCaseId: z.string().regex(/^[a-zA-Z0-9]+$/).max(32),
testCaseScore: z.array(z.record(z.string(), z.unknown())), testCaseScore: z.array(problemTestCaseScoreSchema),
timeLimit: z.number().int().min(1).max(1000 * 60), timeLimit: z.number().int().min(1).max(1000 * 60),
memoryLimit: z.number().int().min(1).max(1024), memoryLimit: z.number().int().min(1).max(1024),
languages: z.array(z.string()).min(1), languages: z.array(z.string()).min(1),
@@ -589,7 +625,7 @@ export const createProblemRequestSchema = z.object({
hint: z.string().nullable().default(null), hint: z.string().nullable().default(null),
source: z.string().max(256).nullable().default(null), source: z.string().max(256).nullable().default(null),
prompt: z.string().nullable().default(null), prompt: z.string().nullable().default(null),
answers: z.array(z.record(z.string(), z.unknown())).default([]), answers: z.array(problemAnswerSchema).default([]),
shareSubmission: z.boolean(), shareSubmission: z.boolean(),
allowFlowchart: z.boolean().default(false), allowFlowchart: z.boolean().default(false),
showFlowchart: z.boolean().default(false), showFlowchart: z.boolean().default(false),