Commit Graph

13 Commits

Author SHA1 Message Date
f38444c97a fix(代码规则): 给 C 题配的规则一半是哑弹,C++ 题根本配不了
Some checks failed
Deploy / deploy (push) Has been cancelled
后台的节点下拉是一张 C/Python 混在一起的 15 条表,整份铺给每种语言。给 C 题选到
只有 Python 有的 list_comprehension、f-string,规则存得进去,判题时拿裸名去比节点
类型,C 的语法树里永远不存在它——「必须使用列表推导式」永远失败、「不能使用
f-string」永远通过,两头都不报错,只有学生受着。反过来 mappings 支持的 do_while、
switch、struct、include 在表里没有,编辑器根本选不到。

标签表改成按语言分组,和判题机 mappings 的键集逐条对齐;保存时校验规则与语言是否
匹配,不匹配给中文提示。C 的 target 从 8 个可用变成 14 个。

C++ 接上了 tree-sitter-cpp,386 道 C++ 题从此能配规则。它继承 tree-sitter-c 的语法,
C 那 14 个 target 实测全部通用,另加范围 for、类定义、try-catch、throw、namespace、
模板、lambda、using 共 22 个。调用形态和 C/Python 都不同,一并处理:a.push_back()
和 p->push_back() 是 call_expression + field_expression,不是 Python 的 attribute;
std::sort(...) 的 function 是 qualified_identifier 而不是 identifier,所以额外比一次
:: 末段,否则学生写没写 using namespace std 会得到不同判定。

一起收掉的几处:

- Java/Golang/JavaScript 配的规则一条都不会跑,题目页却照常把它们渲染成「要求」
  挂给学生看。现在后台不给这些语言开 tab,下发给学生的要求也按语言过滤。
- 「出现次数」不填数字存下来是一条恒真规则,描述还退化成光秃秃一个「for 循环」。
  切换引擎时给默认值,保存时拦下,读取时整条丢弃。
- 次数规则失败只说「if 条件 出现 2 次 ✗」,学生不知道自己写了几次,补上「当前 N 次」。
  旧栈的引擎其实算了这个数,但 checker 只取 describe,算完就扔。
- must_have_nesting 的文案没走标签表,学生看到的是「必须使用 for_loop 嵌套」。
- 运算符文案给的是逻辑名,C 题的学生看到「必须使用 and 运算符」,而 C 里写的是 &&。

语义校验放在 astRulesError() 而不是 zod 的 refine 上:astRulesSchema 同时用于读后台
题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。保存前先 pickAstRules()
剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死——tab 里看不到那组
规则,保存却被拦下。

生产库那 17 条规则(全是 Python3 的 must_exist_node / count_node)行为不变,逐条实跑
核对过。C++ 的 22 个节点 target、25 个运算符也逐个跑了,没有恒假的哑弹。改了带 wasm
内嵌的 ast.ts,dev 和编译两种形态都验过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:53:37 -06:00
9e41855610 fix(流程图): 点进去空白的 tab、以及「渲染成功」这个只进不出的开关
## 两个开关同时打开会做出一个空 tab

后端在 `allowFlowchart` 为真时把 `mermaidCode` 置成 null(不能把标准答案下发给
正要自己画图的学生,见 routes/problem.ts)。而前端 `tabOptions` 只看
`showFlowchart` 就把 "flowchart" 加进选项,面板那边却要求
`showFlowchart && mermaidCode` —— 两个开关都打开时,选项存在、面板不存在,
URL 里带 `?tab=flowchart` 就会选中一个渲染不出任何东西的页签。

三个断点条件还各不相同(两处要求两者都有,第三处只看 showFlowchart,那处会拿
null 去渲染 ProblemFlowchart)。统一成一个 `canShowFlowchart`。

后台那边把「显示标准流程图」在允许提交流程图时置灰并说明原因,再补一个 watch
把存量数据里两个都开着的情况纠正掉 —— 它们本来就是互斥的。

## 「渲染成功」只进不出

保存前的校验靠 `mermaidRenderSuccess`,而 MermaidEditor 只在成功时 emit、
这个 ref 也就只会从 false 变 true,永不复位。**先写对、再改坏,照样能存进库。**

改成上报渲染结果本身(`render-state`),并在 modelValue 一变就立刻打回
「未验证」,等防抖后的渲染真跑完再报结论 —— 只挂防抖那一支的话,改完 300ms 内
点保存读到的还是上一次的结论,刚改坏的代码会被当成校验通过。宁可让出题人多等
一下,也不能放脏数据进库。

实测:改动后 50ms 读到 false(此时保存会被拦),渲染完成后回到 true;
贴一段坏语法则一直是 false。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:27:02 -06:00
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
3f55d231c3 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>
2026-08-25 23:04:41 -06:00
0b7b08f2cc refactor(前端): 去掉 { error, data } 信封,api2 改名 api
Some checks failed
Deploy / deploy (push) Has been cancelled
信封是 Django 时代的形状:拦截器手工造一个**恒为 null** 的 error 字段,再把
真正的载荷塞进 data。后端 http.ts 的 success 其实只返回 { data },那个 error
从头到尾没人用 —— 全站成功路径读 res.error 的只有 admin/api.ts 的
resetPassword 一处,而它自己就是个把信封拆开再重新包一遍的 shim。

代价是每个调用点都要 .data 一次:47 个组件、3 个 api 层文件、200 多处。
现在拦截器直接返回 response.data.data,ApiResponse<T> 退化成 T,文件末尾那句
`as unknown as Api2Client` 的类型谎言也少了一层。失败路径不动,仍然 reject
`{ error: 错误码, data: 文案 }` —— 和成功路径不对称是故意的,成功没有错误码
可言,接口注释里写清楚了。

顺带把 api2 改回 api:utils/ 下早就没有 api.ts 了,"2" 是迁移期用来和旧
client 区分的,现在只剩下让人多想一秒的作用。

## 怎么改的

**没有全局 sed。** 先把客户端的返回类型从 Promise<ApiResponse<T>> 改成
Promise<T>,让 vue-tsc 把每一处报出来(210 条),再按它给的 file:line:col
精确删 `.data`(192 处),剩下的手工处理:

- 6 处 `const { data } = await ...` 解构 → `const data = await ...`
- 3 个 api 层函数(getProfile / getProblem / getSubmission)自己手工造信封,
  改成直接返回值;getProfile 的返回类型跟着从 ApiResponse<Profile|null>
  变成 Profile|null

**类型检查抓不到的,人工把剩下的每一处 `.data` 过了一遍** —— 载荷本身带
data 字段、或者载荷是索引签名时,`res.data` 照样过类型。这一遍捞出三条真 bug:

- `getTutorialList` 的载荷是 `{ [key: string]: TutorialListItem[] }`(按
  python / c 分组)。索引签名让 `res.data` 编译通过、运行时是 undefined ——
  改完信封之后教程列表会**两个 tab 全空且不报错**。实跑确认过修好了。
- `createExercise` / `updateExercise` 返回 `res.data`,而 Exercise 自己有
  data 字段(练习内容)。两个调用方都不看返回值,所以类型和运行时都不响。
- `getSimilarProblems` 的 `.then(r => ({ ...r, data: r.data.map(...) }))`
  删掉 .data 之后变成往对象里摊一个数组,能跑但形状是错的。

另外两处是**对的**,加了注释免得下次被"顺手清理"掉:
StatisticsPanel 的 `res.data` 是契约 submissionStatisticsSchema 自己的 data
字段(每个学生一行);download.ts 是独立 axios 实例,`res.data` 是 axios 的
响应体(zip 二进制,不走信封)。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、vite build
通过。**因为这改动碰的是每一个请求,静态检查不够,起了全套服务用浏览器实跑:**

- oj 侧 12 个页面 + 后台 13 个页面逐个打开,断言没有重定向、console 无报错。
- 关键页面进一步断言渲染出了真数据(后台用户列表 3 行、题目列表 10 行、
  站点配置表单三个输入框有值、教程列表分组正确)。
- 三条写路径实打:重置密码(库里 student123 → 531554,表格当场刷新)、
  公告可见性开关(走 getAnnouncement + editAnnouncement,就是手改解构那处,
  库里 visible t → f)、提交代码(POST → 判题机真跑出 -2 → 提交列表和详情页
  都正确渲染状态、语言、代码)。
- /rank 有一条 `{error: "class-missing"}` 的未捕获 reject,stash 掉本次改动
  复现同样报错,**是既有问题**,不在本次范围内。

本地 dev 库为了打通后台测试改了三处,都只影响本机:devadmin 补了 email 和
user_profile 行(原来缺这两样,getProfile 报 profile-not-found,AUTHED 存不
进去,所有 /admin 路由被守卫弹回首页)、密码重置成 devpass123。冒烟用的教程/
公告/提交三条测试数据已删干净,题目和用户的提交计数也回滚了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:42:20 -06:00
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
d3b05b8629 refactor(契约): 补齐 80 个类型导出,前端不再手抄形状
契约原来有 184 个 schema 但只导出了 104 个类型,缺的那 80 个前端只能照着
手抄一遍 —— 这是「表格列静默空白」那一类 bug 的根因(d3348f9、2edb8cf,
以及上一个 commit 修的 Top100 两列)。现在 186 个 schema 对 186 个类型,
一一对应,下次要用直接 import。

补导出是机械的(fooSchema → Foo),零命名冲突。真正有价值的是换的过程中
契约逼出来的 5 处分歧 —— 手抄那份在说谎,而 vue-tsc 拦不住,因为类型说它是对的:

- Tutorial.createdBy 手抄成了可选的 `User`(即 AdminUser,带 email、
  rawPassword),后端下发的是必有的 SampleUser。读 createdBy.email 会拿到
  undefined。列表页因此被迫写 `row.createdBy?.username` 和 `row.createdAt!`,
  换成契约类型后两处断言都不需要了。
- TutorialListItem 手抄成 `Omit<Tutorial, "content">`,但后端列表接口连 code
  一起省了 —— 类型声称 code 在。
- Testcase 手抄成 `{input_name, output_name, score}`:响应实际有 5 个字段,
  且**没有 score**。score 是上传完成后前端按测试点数量平分补上去的,手抄那份
  把本地字段说成了响应字段。现在写成 `TestCaseEntry & { score: string }`。
- Tag 手抄成 `{id, name}`,契约是 `{id, name, problemCount}` —— shared/api.ts
  只好用 `Tag & { problemCount: number }` 把丢掉的补回来。
- CreateMessage 是旧后端按名字投递的形状(sender/recipient/submission),
  契约要的是 recipientId/submissionId。全仓零引用,删掉。

同时删掉另外两个零引用的手写类型:LANGUAGE_SHOW_LABEL、UserAdminType;
本地重复的 SampleUser 换成契约的;oj/problem/list.vue 里本地第三份 Tag 改成
`ContractTag & { checked: boolean }`。

需要收窄的一律**从契约派生再收窄**,字段名跟着契约走,只有真正本地的那一两个
键是自己的:

    export type Exercise = Omit<AdminExercise, "data"> & { data: 七种题型的联合 }
    export type SubmitCodePayload =
      Omit<CreateSubmissionRequest, "language"> & { language: LANGUAGE }

保留不动的窄化:StatisticInfo / SubmissionInfo(判题 JSONB 原文,snake_case)、
SQLDisplay*、ProblemFiltered(视图模型)、Exercise*Data(契约里 data 就是
Record<string, unknown>,七种题型结构不同,后端本来也不校验)。
types.ts 的手写 interface 从 31 个降到 22 个。

顺带修的代码:admin/tutorial/detail.vue 新建教程的表单对象缺三个后端产出的
字段,加了 TutorialEdit(对齐 BlankProblem / BlankContest 的写法);三处测试点
上传原来是拿响应对象原地塞 score,改成 map 出新对象,分数算法一字未改。

验证:apps/api tsc(7.0.2) 0 error、check:routes 168 条无遮蔽、
apps/web vue-tsc 0 error、vite build 通过。改动绝大部分在类型层,运行时只有
测试点上传那三处(等价替换)—— **那条路径要传 zip 才能实跑,没有实打**,
只做了代码等价性核对。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:07:01 -06:00
25c6e99c54 feat(后台): 补回「最高票评价」列,重写时漏掉了
管理端题目列表的「反馈」列一直是空的:前端渲染逻辑还在,后端两处
硬编码 topReaction: null —— 旧后端的 reaction/services.py:get_top_reactions
没有跟着迁过来,阶段 0 的清单也没抓到。

新增 services/reaction.ts,口径逐条对齐旧实现:

- 按 (problem_id, type) 分组计数,取票数最高的那个;
- 并列时按 reactionKeySchema 的定义顺序取靠前的,与前端 REACTIONS 的顺序
  一致(旧后端是 TYPE_ORDER,已核对两边七个 key 的顺序与内容完全相同);
- 只返回有评价的题目,没有的题调用方兜 null。

比库里可能残留、但前端已经下掉的类型多了一道跳过:不跳的话一个不再展示的
类型会以并列最优的身份把真正的最高票挤掉。

只有公开题列表下发,比赛题列表不下发 —— 与旧后端一致(旧的 top_reaction
只出现在 ProblemAPI,ContestProblemAPI 没有),前端那一列的注释也是这么写的。

实测:造 3 票 learned + 1 票 too_easy 取到 learned/3;interesting 与
too_hard 各 1 票时取到 too_hard(定义顺序在前);插一条 obsolete_kind
不影响结果;无评价的题返回 null。探针数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:08:09 -06:00
c2a81209d7 refactor(前端): 拆掉 camelCase→snake_case 转换层,契约成为唯一真相
utils/legacy.ts 是迁移期的临时层:新后端一律 camelCase,而组件读的还是
旧 Django 的 snake_case,于是在 api 层做一次递归键名重写。它自己的注释就
写了「迁移完成后这一层应当整体拆掉」。现在拆了。

代价不只是那 96 处包装:每个响应都要递归遍历整个对象重写一遍键名,而且
utils/types.ts 和 packages/contract 是两份真相 —— 手抄的那份还抄歪了好几处。

做法是按域推进,每域都用 vue-tsc 相对基线做差,确认零新增错误后再往下走。
前端的类型现在一律以契约为准,只在必要处窄化(比如 languages/template 的键
窄化成 LANGUAGE),删掉的重复定义包括 WebsiteConfig、LoginSummary、
AchievementSummary、ProblemSet、Contest、User、Profile、AdminTag、
StuckProblem 等等,其中 ClassComparison 有两个组件各手抄了一份。

## 顺带修掉的真 bug

- 管理端公告列表的「可见」开关每次都 400:列表响应被契约 omit 掉了 content,
  而更新接口要求 content 必填,toggleVisible 把列表行原样回传。而且是乐观
  翻转、不 await 不 catch,管理员看到开关动了、实际没存也没有提示。
  改成先 GET 整条再 PUT,加失败提示。

- 删有提交的题时只显示笼统的「删除失败」:前端还在 match 旧 Django 的英文
  文案,而后端返回的是 problem-has-submissions + 中文。连同另外 8 处同类
  匹配一起改成判错误码 —— 文案是后端随时能改的,match 文案改一个字就静默失效。

- SubmissionStatus.time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
  memory_limit_exceeded 撞了同一个值。后端 judge/status.ts 里这是分开的
  两个码,按后端拆成 cpu_/real_ 两项。当前没有代码读这两个成员,但
  CLAUDE.md 明确要求判题状态码三处同步。

- 流程图历史翻到没有提交的那一页会直接抛:契约里 submission 是 nullable,
  被 any 掩盖成看起来非空。补了 null 分支。

## 契约里被逼出来的三处不诚实

- grade 写成 z.string(),但 averageGrade() 在没有可用数据时返回空串,
  前端三张图表拿它查 Record<Grade,...> 会查出 undefined。按实际收紧成
  z.enum([...,""]),四个查表点都补了「无评级」分支。

- difficulty 写成 z.string()。核对过生产库 dump:956 道题只有
  Low/Mid/High 三个值(761/149/46)。收紧成枚举。

- topReaction 写成 z.string(),既对不上前端渲染的 {type,count},也对不上
  旧后端 get_top_reactions 下发的形状。改成正确形状并注明当前恒传 null。

## 明确保留 snake_case 的 54 处

判题沙箱原始输出(cpu_time/exit_code/output_md5/compile_output)、
statistic_info 内容(err_info/time_cost/ast_results)、submission_info
JSONB(is_ac/ac_time/error_number,回滚时旧后端还要读)、SQL 判题引擎的
total_rows/order_sensitive/changed_tables、WebSocket 的 submission_id、
以及数据库选项键 enable_maxkb。每一处都在类型定义旁写了为什么不能改。

language 没有跟着收紧契约 —— 它是配置项、随时可能加语言,收紧会让新语言
在后端 parse 时直接抛。改在 api 边界一处窄化。

## 另外

- utils/http.ts 整个模块已是死代码(四处引用全是 import type),删除。
- profile 的 blog/github/school/major/language 五个字段全链路空转,没有
  任何组件读,从契约到类型一并摘除(数据库列不动)。
- admin/account.ts 往 user_profile 塞的 totalScore 是 OI 模式遗留,表里
  没这一列。Drizzle 按表定义拼列名会把它静默丢弃,所以没出过错,是死代码。

验证:vue-tsc 143 → 54 条且无新增,apps/api tsc、check:routes、web build
全通过;各域响应形状逐条打接口核对过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:44:24 -06:00
34ae9b032c fix(后台): SQL 测试点脚本读不出来时不能静默留一片空白编辑器
用浏览器把前后台走了一遍(这是第一次真的在界面上验证,之前全是脚本打 API)。
唯一找到的真问题在 SQLTestcaseEditor:

    onMounted(async () => {
      try { ... } catch {}     // ← 静默吞掉
    })

已有脚本读取失败时被 catch{} 吞掉,编辑器保持初始的 3 个空白项 ——
和「这题本来就没测试点」长得一模一样,教师完全看不出发生了什么。

改成:新题和旧格式测试点(404 problem-not-found / 409 not-sql-test-case)仍然
静默留空,那本来就是对的;其它失败挂一条 alert,并且把可操作的部分说全 ——
后端那句"测试点信息读取失败"只讲了现象,教师需要知道的是「下面是空模板、
直接存不会生效」。

保存本身是安全的,已实测:拿一道真实 SQL 题,在编辑器空白的状态下点提交,
test_case_id、测试点数(3)、sql_config 全部未变 —— 后端读不到测试点信息会
拒绝整个保存(400),不会把测试点清空。所以这条只是提示缺失,不是数据风险。

## 走查过程中的一次自我更正

中途我一度报告"保存返回 400 而界面没有任何错误提示",那是错的:提示是正常
显示的,我第一次等了 4 秒才检查,而 Naive UI 的提示 3 秒就自动消失了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 05:19:23 -06:00
cd5dd16f3b feat(阶段4): 测试用例压缩包上传与下载
POST  admin/test-cases
  GET   admin/problems/:id/test-cases   (返回 zip 二进制)

落盘格式必须与判题沙箱镜像的约定一致(沙箱直接读挂进去的目录),已用真判题验证:
上传 zip → 建题 → 提交 Python 解法 → 沙箱读到用例并判出 Accepted。

安全与健壮性上比旧后端多做的几件事:

- **zip slip 从设计上进不来**:不遍历压缩包条目,只按精确文件名(`N.in`/`N.out`/`N.sql`)
  取内容,条目名一律不参与路径拼接。实测带 `../../etc/passwd` 条目的包能正常处理,
  且只取到 1.in/1.out。
- 单文件 32MB、解压后总量 128MB、测试点数 500 的上限,防 zip bomb 与写满磁盘 ——
  旧后端一概没有,机房那台机器盘写满之后判题也会一起挂。
- 坏 zip 返回 400 而不是 500。

对齐旧后端的细节:CRLF→LF 归一;`stripped_output_md5` 按 Python `bytes.rstrip()`
的口径只剥尾部 ASCII 空白后再算(实测与 hashlib.md5 结果一致);编号从 1 起连续、
遇缺口即停;SQL 包至少 2 个测试点(题目页会展示测试点 1 的期望结果,只有一个时
学生可以对照着硬编码 AC);目录 0710、文件 0640。

## 顺带修掉一个只在判题时才暴露的路径 bug

config 里的相对路径(data/test_case、data/avatar、data/upload)原先按进程 cwd 解析,
而起服务的方式会把 cwd 切到 apps/api/,于是测试点落在 apps/api/data/ 下 ——
但 docker/compose.dev.yml 把**仓库根**的 data/test_case 挂进判题沙箱。两边不是同一个
目录,新传的测试点判题时会「找不到测试数据」,且只在真正判题时才暴露。
改成一律按仓库根解析,实测沙箱能看到新传的目录。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:42:54 -06:00
ae1fb329b5 feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码 2026-08-06 21:18:16 -06:00