Compare commits

...

2 Commits

Author SHA1 Message Date
3f6b4102c9 feat(题目): 学生题目页恢复「要求」展示,走新的 astRequirements 字段
Some checks failed
Deploy / deploy (push) Has been cancelled
旧后端的 ProblemSerializer.Meta.exclude 没排掉 ast_rules,学生拿到的是规则原文,
题目页上那块「要求」(「if 条件 出现 2 次」之类)是显示的。阶段 3 泄露评审刻意
收掉了它,只留 hasAstRules 布尔值 —— 报告里当时就写了:

  「新的更安全,但如果 ojnext 有地方读 ast_rules 的具体内容(比如提示"必须用
    for 循环"),需要补个专门的字段。」

前端确实有(ProblemContent.vue 的 astRulesForDisplay + 整块渲染),但那个字段
一直没补。结果是这块在 OJ2 上**永远拿不到数据**,代码还在、没人报错。12 道题
受影响;学生只有提交失败后才能从 statistic_info.ast_results 里看到要求。

现在按评审自己的建议补上:oj 侧题目详情(含比赛题)多一个 astRequirements,
**只有渲染要用的两个字段**:

    { description: "if 条件 出现 2 次", kind: "count" }

文案由后端生成,engine / target 这些内部字段不出现在响应里 —— 收紧保留,
展示恢复。实测响应里搜不到 "engine" 也搜不到 "if_statement"。

## 顺带合掉一堆重复

描述文案原来有两份几乎一样的实现:判题机 ast.ts 里一份(写进 ast_results)、
ProblemContent.vue 里一份(题目页用)。现在统一成 ast.ts 的 describeAstRule,
两边共用。差异只在 min/max 同时给出时的措辞(生产库里没有这种规则,实际输出
逐字不变),另外判题机现在也能用上节点类型的中文名 —— 没写 label 的规则以前
判题结果里显示 `必须使用 function_definition`,现在是 `必须使用 函数定义`。

节点类型中文名那 15 条原来在 AstRulesEditor.vue(下拉 options)和
ProblemContent.vue(NODE_TARGET_LABELS)各手抄一份,收进契约的
AST_NODE_TARGET_LABELS,编辑器的下拉现在从它生成。

前端删掉 NODE_TARGET_LABELS / ruleDescription / ruleTagType 共 ~80 行,
`Problem` 类型里那个 oj 侧根本不下发的 astRules 幽灵字段也去掉了。顺带修掉
一处潜在重复渲染:原来 message 非空时 ruleDescription 返回 message、模板里
又单独渲染一次 message(生产库 message 全是空串,所以没露出来)。

## 一个坑:契约里差点搞出循环引用

astRequirements 一开始放在 admin.ts,problem.ts 去 import 它 —— 而 admin.ts
本来就 import problem.ts。**tsc 一声不吭地过了**,运行时才炸:

    ReferenceError: Cannot access 'astRequirementsSchema' before initialization

所以整块 AST schema 从 admin.ts 挪到了 problem.ts(本来也是题目域的东西),
admin.ts 反过来从那边取。这类环 tsc 抓不到,加跨文件 schema 引用时得实跑一次。

## 验证

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

- 把生产库那条规则种进本地库,匿名请求 /api/problems/1001:astRequirements 是
  `{"Python3":[{"description":"if 条件 出现 2 次","kind":"count"}, ...]}`,
  响应里没有 astRules、没有 engine、没有 if_statement。
- 浏览器打开题目页,「要求」那块活了:`要求 | if 条件 出现 2 次 | else 子句 出现 2 次`。
- 后台编辑页展开「代码规则检查」,两条规则正常渲染成
  `出现次数 | if 条件 | 精确`,下拉选项(现在从契约生成)正确。
- 直接调 describeAstRule / checkAst / astRequirements:三种 kind 分类正确,
  engine 认不出的规则被丢掉,null 和非对象都回落成 null。
- oj 侧 3 页 + 后台 4 页走查无重定向、console 无报错。

冒烟改动已还原(problem 2 的 ast_rules 复位成 null)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:27:40 -06:00
f919d41199 refactor(契约): AST 代码要求收进契约,三份各写各的合成一份
同一个形状原来在三个地方各定义了一份,三份都不一样:

  apps/api/src/judge/ast.ts        engine target outer inner label message exact min max
  apps/web/src/utils/types.ts      engine target                  message       min max
  AstRulesEditor.vue(本地)        engine target       label message exact min max
  ProblemContent.vue(本地)        engine target       label message exact min max

判题机那份是真的(evaluateRule 按 engine 分支读哪几个字段),契约里则压根没有,
`astRules: z.unknown()`。后果是后台编辑器写得出 label 和 exact、判题机也认,
但 utils/types.ts 那个类型描述不了它们 —— 生产库 12 道带 AST 规则的题里,
15 条规则带 label、6 条带 exact,全都在类型之外。

现在契约里一份 astRuleSchema,四处都指向它。engine 收成枚举,列全判题机
实现的十种。**存量数据核过**:12 道题逐条过新 schema,12/12 通过。

顺带三件:

- `astRules` 的响应和请求 schema 从 z.unknown() 换成 astRulesSchema。之前
  engine 写错一个字母能存进去,判题机 evaluateRule 走 `default: return null`
  静默跳过 —— 老师设了规则、规则不生效、没有任何提示。现在保存时就 400,
  错误信息把十个合法值列出来。
- judge/run.ts 的 astRulesForLanguage 原来是整片 `rules as AstRule[]` 硬转,
  改成逐条 safeParse:认不出的丢掉那一条,行为和 evaluateRule 的 default 分支
  一致,只是提前到读取处,也不再骗类型系统。
- 判题机实现了 must_have_nesting,但后台编辑器没有对应选项,目前只能手工造
  数据才用得上。枚举里留着并加了注释,没有顺手去补 UI(那是加功能不是清理)。

## 验证

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

- 把生产库那条带 label/exact 的规则种进本地库,后台题目详情 200、字段齐全;
  原样 PUT 回去 200,库里 label 和 exact 都在(旧类型描述不了的那两个)。
- engine 传 "must_do_magic" → 400,错误信息列出十个合法值。
- 直接调 checkAst:三条规则(两条合法 + 一条 engine 认不出)进去,判题机收下
  两条;两个 if/else 的代码 passed=true,一个的 passed=false,描述文案
  「if 条件 出现 2 次」正确用上了 label。

冒烟改动已还原(problem 2 的 ast_rules 复位成 null,测试标签删掉)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:15:52 -06:00
9 changed files with 251 additions and 179 deletions

View File

@@ -1,3 +1,10 @@
import {
astRuleSchema,
AST_NODE_TARGET_LABELS,
type AstRequirement,
type AstRequirements,
type AstRule,
} from "@oj2/contract"
import { Language, Parser, type Node } from "web-tree-sitter"
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root解析不到 node_modules。见 vendor/jieba.ts
@@ -6,17 +13,9 @@ import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { t
// web-tree-sitter 自己的运行时 wasmParser.init() 要用
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
export interface AstRule {
engine?: string
target?: string
outer?: string
inner?: string
label?: string
message?: string
exact?: number
min?: number
max?: number
}
// AstRule 的形状在契约里astRuleSchema—— 原来这份和前端两份各写各的。
// 这里 re-export判题机的调用方不用再去 import 契约。
export type { AstRule } from "@oj2/contract"
export interface AstResult {
description: string
@@ -97,16 +96,87 @@ function hasNode(root: Node, type: string): boolean {
}
function targetName(rule: AstRule) {
return rule.label || rule.target || "指定语法"
const target = rule.target ?? ""
return rule.label || AST_NODE_TARGET_LABELS[target] || target || "指定语法"
}
function rangeDescription(subject: string, rule: AstRule) {
function countPhrase(verb: string, rule: AstRule) {
if (rule.exact !== undefined) return `${verb} ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `${verb} ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少${verb} ${rule.min}`
if (rule.max !== undefined) return `至多${verb} ${rule.max}`
return ""
}
/**
* 一条规则的中文描述。判题结果statistic_info.ast_results和题目页的「要求」
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
*/
export function describeAstRule(rule: AstRule): string {
if (rule.message) return rule.message
if (rule.exact !== undefined) return `${subject} 出现 ${rule.exact}`
const parts: string[] = []
if (rule.min !== undefined) parts.push(`至少 ${rule.min}`)
if (rule.max !== undefined) parts.push(`至多 ${rule.max}`)
return `${subject} ${parts.join("、")}`.trim()
const name = targetName(rule)
const target = rule.target ?? ""
switch (rule.engine) {
case "must_exist_node":
return `必须使用 ${name}`
case "must_not_exist_node":
return `不能使用 ${name}`
case "count_node":
return `${name} ${countPhrase("出现", rule)}`.trim()
case "must_call_function":
return `必须调用 ${target}()`
case "must_not_call_function":
return `不能调用 ${target}()`
case "count_function_call":
return `${target}() ${countPhrase("调用", rule)}`.trim()
case "must_call_method":
return `必须调用 .${target}()`
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${target} 运算符`
case "must_have_nesting": {
const outer = rule.outer ?? ""
const inner = rule.inner ?? ""
return outer === inner
? `必须使用 ${outer} 嵌套`
: `必须在 ${outer} 中嵌套使用 ${inner}`
}
}
}
/** 标签配色用的粗分类,见契约 astRequirementSchema */
function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
if (engine.startsWith("must_not")) return "forbid"
if (engine.startsWith("count")) return "count"
return "require"
}
/**
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
*/
export function astRequirements(value: unknown): AstRequirements | null {
const grouped = value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
if (!grouped) return null
const out: AstRequirements = {}
for (const [language, rules] of Object.entries(grouped)) {
if (!Array.isArray(rules)) continue
const items = rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule)
if (!parsed.success) return []
return [{
description: describeAstRule(parsed.data),
kind: requirementKind(parsed.data.engine),
}]
})
if (items.length > 0) out[language] = items
}
return Object.keys(out).length > 0 ? out : null
}
function rangePassed(count: number, rule: AstRule) {
@@ -147,51 +217,51 @@ function evaluateRule(
switch (rule.engine) {
case "must_exist_node":
return {
description: rule.message || `必须使用 ${targetName(rule)}`,
description: describeAstRule(rule),
passed: hasNode(root, nodeType),
}
case "must_not_exist_node":
return {
description: rule.message || `不能使用 ${targetName(rule)}`,
description: describeAstRule(rule),
passed: !hasNode(root, nodeType),
}
case "count_node": {
const count = collectNodes(root, nodeType).length
return {
description: rangeDescription(targetName(rule), rule),
description: describeAstRule(rule),
passed: rangePassed(count, rule),
}
}
case "must_call_function":
return {
description: rule.message || `必须调用 ${target}()`,
description: describeAstRule(rule),
passed: functionCalls(root, target, language).length > 0,
}
case "must_not_call_function":
return {
description: rule.message || `不能调用 ${target}()`,
description: describeAstRule(rule),
passed: functionCalls(root, target, language).length === 0,
}
case "count_function_call": {
const count = functionCalls(root, target, language).length
return {
description: rangeDescription(`${target}()`, rule),
description: describeAstRule(rule),
passed: rangePassed(count, rule),
}
}
case "must_call_method":
return {
description: rule.message || `必须调用 .${target}()`,
description: describeAstRule(rule),
passed: methodCalls(root, target, language).length > 0,
}
case "must_not_call_method":
return {
description: rule.message || `不能调用 .${target}()`,
description: describeAstRule(rule),
passed: methodCalls(root, target, language).length === 0,
}
case "must_use_operator":
return {
description: rule.message || `必须使用 ${target} 运算符`,
description: describeAstRule(rule),
passed: hasNode(root, nodeType),
}
case "must_have_nesting": {
@@ -202,14 +272,7 @@ function evaluateRule(
const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)),
)
return {
description:
rule.message ||
(outer === inner
? `必须使用 ${outer} 嵌套`
: `必须在 ${outer} 中嵌套使用 ${inner}`),
passed,
}
return { description: describeAstRule(rule), passed }
}
default:
return null

View File

@@ -1,5 +1,6 @@
import { createHash } from "node:crypto"
import { astRuleSchema } from "@oj2/contract"
import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
@@ -54,9 +55,19 @@ function templateForLanguage(value: unknown, language: string) {
return typeof template === "string" ? template : null
}
/**
* 取某个语言的 AST 规则。逐条过 astRuleSchema 而不是整片 `as AstRule[]` 硬转 ——
* 这是从库里读出来的 JSONB形状不对比如 engine 是个 evaluateRule 不认识的值)
* 时丢掉那一条,行为和 evaluateRule 的 `default: return null` 一致,只是提前到了
* 这里、并且不再骗类型系统。
*/
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = objectValue(value)[language]
return Array.isArray(rules) ? (rules as AstRule[]) : []
if (!Array.isArray(rules)) return []
return rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule)
return parsed.success ? [parsed.data] : []
})
}
async function requestJudge(

View File

@@ -14,6 +14,7 @@ import { Hono } from "hono"
import { optionalAuth, requireAuth } from "../auth/middleware"
import { setContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import {
canAccessContest,
@@ -180,6 +181,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
}))
})

View File

@@ -25,6 +25,7 @@ import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
@@ -319,6 +320,8 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
})
return success(c, data)

View File

@@ -1,24 +1,15 @@
<script setup lang="ts">
import type { LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
import { AST_NODE_TARGET_LABELS } from "@oj2/contract"
import type { AstRule, AstRules, LANGUAGE } from "utils/types"
interface Props {
modelValue: { [key: string]: AstRule[] } | null
modelValue: AstRules | null
languages: LANGUAGE[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
(e: "update:modelValue", value: AstRules | null): void
}>()
const activeTab = ref(props.languages[0] || "Python3")
@@ -61,23 +52,11 @@ const ENGINE_OPTIONS: SelectOption[] = [
},
]
const NODE_TARGET_OPTIONS: SelectOption[] = [
{ label: "for 循环", value: "for_loop" },
{ label: "while 循环", value: "while_loop" },
{ label: "if 条件", value: "if_statement" },
{ label: "else 子句", value: "else_clause" },
{ label: "函数定义", value: "function_definition" },
{ label: "return 语句", value: "return" },
{ label: "break 语句", value: "break" },
{ label: "continue 语句", value: "continue" },
{ label: "列表推导式", value: "list_comprehension" },
{ label: "列表", value: "list_literal" },
{ label: "字典", value: "dict_literal" },
{ label: "集合", value: "set_literal" },
{ label: "f-string", value: "f_string" },
{ label: "try-except", value: "try_except" },
{ label: "类定义", value: "class_definition" },
]
// 选项从契约的 AST_NODE_TARGET_LABELS 生成 —— 这 15 条原来在这里和
// ProblemContent.vue 各手抄一份
const NODE_TARGET_OPTIONS: SelectOption[] = Object.entries(
AST_NODE_TARGET_LABELS,
).map(([value, label]) => ({ label, value }))
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
{ label: "+", value: "+" },

View File

@@ -97,90 +97,18 @@ const samples = ref<Sample[]>(
})),
)
const NODE_TARGET_LABELS: Record<string, string> = {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
function_definition: "函数定义",
return: "return 语句",
break: "break 语句",
continue: "continue 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
try_except: "try-except",
class_definition: "类定义",
}
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
// ruleDescription + ruleTagType和判题机那份几乎一模一样见契约
// astRequirementSchema。规则原文engine / target不下发给学生。
const KIND_TAG_TYPE = {
require: "success",
forbid: "error",
count: "info",
} as const
type AstRule = {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
function ruleDescription(rule: AstRule): string {
if (rule.message) return rule.message
const target = rule.target || ""
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
const countDesc = () => {
if (rule.exact !== undefined) return `出现 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `出现 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少出现 ${rule.min}`
if (rule.max !== undefined) return `至多出现 ${rule.max}`
return ""
}
const callDesc = () => {
if (rule.exact !== undefined) return `调用 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `调用 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少调用 ${rule.min}`
if (rule.max !== undefined) return `至多调用 ${rule.max}`
return ""
}
switch (rule.engine) {
case "must_exist_node":
return `必须使用 ${targetLabel}`
case "must_not_exist_node":
return `不能使用 ${targetLabel}`
case "count_node":
return `${targetLabel} ${countDesc()}`
case "must_call_function":
return `必须调用 ${target}()`
case "must_not_call_function":
return `不能调用 ${target}()`
case "count_function_call":
return `${target}() ${callDesc()}`
case "must_call_method":
return `必须调用 .${target}()`
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${target} 运算符`
default:
return rule.engine
}
}
function ruleTagType(engine: string): "error" | "success" | "info" {
if (engine.startsWith("must_not")) return "error"
if (engine.startsWith("must")) return "success"
return "info"
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.astRules) return []
return Object.entries(problem.value.astRules).filter(
([, rules]) => rules.length > 0,
)
})
const astRequirements = computed(() =>
Object.entries(problem.value?.astRequirements ?? {}),
)
async function test(sample: Sample, index: number) {
samples.value = samples.value.map((sample) => {
@@ -370,27 +298,20 @@ function type(status: ProblemStatus) {
</div>
<!-- 代码要求AST 规则 -->
<div v-if="astRulesForDisplay.length > 0">
<div v-if="astRequirements.length > 0">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
要求
</n-flex>
</p>
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
<div v-for="[lang, rules] in astRequirements" :key="lang">
<p v-if="astRequirements.length > 1" class="lang-label">
{{ lang }}
</p>
<n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i">
<n-flex align="center">
<n-tag :type="ruleTagType(rule.engine)">
{{ ruleDescription(rule) }}
</n-tag>
<span v-if="rule.message" class="rule-message">{{
rule.message
}}</span>
</n-flex>
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
</n-list-item>
</n-list>
</div>
@@ -530,11 +451,6 @@ function type(status: ProblemStatus) {
margin: 8px 0 4px;
}
.rule-message {
font-size: 13px;
opacity: 0.65;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;

View File

@@ -13,6 +13,7 @@ import type {
ProblemDetail,
ProblemDifficulty,
JudgeStatus,
AstRules,
CreateAnnouncementRequest,
} from "@oj2/contract"
@@ -111,26 +112,21 @@ export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
/**
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
* - `languages` / `template` 的键窄化成 LANGUAGE组件按语言查模板要靠它
* - `astRules` 契约里是 unknown这里给出组件实际读的形状
*/
export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
languages: LANGUAGE[]
template: { [key in LANGUAGE]?: string }
astRules?: AstRules | null
// oj 侧不下发 astRules 原文,只有 astRequirements契约里就有
hasAstRules?: boolean
visible?: boolean
answers?: { language: LANGUAGE; code: string }[]
}
export type AstRules = {
[key: string]: {
engine: string
target?: string
min?: number
max?: number
message: string
}[]
}
/**
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
* 编辑器写得出 label 和 exact判题机也认只有这个类型不认。形状在契约里。
*/
export type { AstRule, AstRules, AstRuleEngine } from "@oj2/contract"
export type {
ProblemDetail,

View File

@@ -4,7 +4,12 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content"
import { problemDifficultySchema, sqlConfigSchema, sqlDisplaySchema } from "./problem"
import {
astRulesSchema,
problemDifficultySchema,
sqlConfigSchema,
sqlDisplaySchema,
} from "./problem"
/**
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
@@ -566,6 +571,7 @@ export const problemTestCaseScoreSchema = z.object({
score: z.coerce.number().int().min(0),
})
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
export const adminProblemSchema = z.object({
id: z.number().int(),
@@ -599,7 +605,7 @@ export const adminProblemSchema = z.object({
showFlowchart: z.boolean(),
mermaidCode: z.string().nullable(),
flowchartHint: z.string().nullable(),
astRules: z.unknown(),
astRules: astRulesSchema.nullable(),
answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(),
@@ -631,7 +637,7 @@ export const createProblemRequestSchema = z.object({
showFlowchart: z.boolean().default(false),
mermaidCode: z.string().nullable().default(null),
flowchartHint: z.string().nullable().default(null),
astRules: z.unknown().default(null),
astRules: astRulesSchema.nullable().default(null),
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
// `BooleanField(default=False)` 一致
sqlConfig: sqlConfigSchema

View File

@@ -55,6 +55,94 @@ export const sqlDisplaySchema = z.object({
]),
})
/**
* AST 代码要求。同一个形状原来在**三个地方**各写了一份,三份都不一样:
* apps/api/src/judge/ast.ts 的 AstRule判题机真读的那份九个字段
* apps/web/src/utils/types.ts 的 AstRules少了 label / exact / outer / inner
* AstRulesEditor.vue 里的本地 AstRule少了 outer / inner
* 编辑器写得出 label / exact题目类型却描述不了它们。现在以这里为准。
*
* 除 engine 外全部可选:判题机每条规则只读自己那几个字段
* (见 ast.ts 的 evaluateRule缺了就走默认文案。
*/
export const astRuleEngineSchema = z.enum([
"must_exist_node",
"must_not_exist_node",
"count_node",
"must_call_function",
"must_not_call_function",
"count_function_call",
"must_call_method",
"must_not_call_method",
"must_use_operator",
// 判题机实现了,但后台编辑器还没有对应的选项,目前只能手工造数据用上
"must_have_nesting",
])
export const astRuleSchema = z.object({
engine: astRuleEngineSchema,
/** 检查目标:节点类型 / 函数名 / 方法名 / 运算符,按 engine 而定 */
target: z.string().optional(),
/** must_have_nesting 专用:外层、内层节点 */
outer: z.string().optional(),
inner: z.string().optional(),
/** 展示用的中文名,缺省回落到 target */
label: z.string().optional(),
/** 自定义提示。生产库里存的是空串而不是缺键,判题机按 `||` 回落到默认文案 */
message: z.string().optional(),
/** count_* 引擎的次数约束 */
exact: z.number().int().optional(),
min: z.number().int().optional(),
max: z.number().int().optional(),
})
/** 按语言分组:`{ Python3: [...], C: [...] }`,键是 languages 里的语言名 */
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/**
* 节点类型的中文名。后台编辑器的下拉选项、后端生成要求文案都要用它 ——
* 原来在 AstRulesEditor.vue下拉 options和 ProblemContent.vue
* NODE_TARGET_LABELS各存了一份同样的 15 条。
*/
export const AST_NODE_TARGET_LABELS: Record<string, string> = {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
function_definition: "函数定义",
return: "return 语句",
break: "break 语句",
continue: "continue 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
try_except: "try-except",
class_definition: "类定义",
}
/**
* 下发给**学生**的代码要求。只有渲染要用的两个字段 —— 文案由后端生成,
* engine / target 这些内部字段不出现在响应里。
*
* 旧后端的 ProblemSerializer 没排掉 ast_rules学生拿到的是规则原文阶段 3
* 泄露评审刻意收掉了它,同时写明「前端要读具体内容的话得补个专门的字段」——
* 就是这个。收紧保留,展示恢复。
*/
export const astRequirementSchema = z.object({
/** 已经渲染好的中文文案例如「if 条件 出现 2 次」 */
description: z.string(),
/** 标签配色:必须做 / 不能做 / 次数约束 */
kind: z.enum(["require", "forbid", "count"]),
})
/** 按语言分组,与 astRulesSchema 同一套键 */
export const astRequirementsSchema = z.record(
z.string(),
z.array(astRequirementSchema),
)
export const problemDetailSchema = z.object({
id: z.number().int(),
_id: z.string(),
@@ -98,6 +186,8 @@ export const problemDetailSchema = z.object({
flowchartHint: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(),
sqlDisplay: sqlDisplaySchema.nullable(),
// 代码要求AST 规则的展示投影)。规则原文不下发给学生,见 astRequirementSchema
astRequirements: astRequirementsSchema.nullable(),
})
export type ProblemDetail = z.infer<typeof problemDetailSchema>
@@ -138,6 +228,11 @@ export const yearlyAcSchema = z.object({
acRate: z.number(),
})
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
export type AstRule = z.infer<typeof astRuleSchema>
export type AstRules = z.infer<typeof astRulesSchema>
export type AstRequirement = z.infer<typeof astRequirementSchema>
export type AstRequirements = z.infer<typeof astRequirementsSchema>
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
export type ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema>