feat(挑战): 做对之后可以加一条写法上的限制再做一遍,判题时记下谁做到了

借小丑牌的赌注档:同一道题加一条限制(「整首歌只用 1 次 print」)再做一遍,
给课上做得快的学生找事做(做完一节课的题,中位要空等 13 分钟)。
判定沿用 AST 规则,挑战配在 problem.challenges,做到的记在 challenge_success;
做对的提交顺手判一遍,挑战没做到也照样算做对。没过的规则带上是第几行。
0039 灌进第一批 56 道常用题的 73 条挑战,api 起来后拿历史上做对的代码补一遍谁已经做到了。
出题页能配挑战、从做对的代码里找候选;课堂看板的格子带上挑战状态。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 08:52:50 +08:00
1 parent cc1361e596
commit 0faf78ad8f
17 files changed
+9057 -37

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "challenge_success" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "challenge_success_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"problem_id" integer NOT NULL,
"challenge_id" text NOT NULL,
"submission_id" text NOT NULL,
"created_at" timestamp with time zone NOT NULL,
CONSTRAINT "challenge_success_user_challenge_unique" UNIQUE("user_id","problem_id","challenge_id")
);
--> statement-breakpoint
ALTER TABLE "problem" ADD COLUMN "challenges" jsonb;--> statement-breakpoint
ALTER TABLE "challenge_success" ADD CONSTRAINT "challenge_success_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "challenge_success" ADD CONSTRAINT "challenge_success_problem_id_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "challenge_success" ADD CONSTRAINT "challenge_success_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "challenge_success_problem_idx" ON "challenge_success" USING btree ("problem_id","challenge_id","created_at");
+58
View File
@@ -0,0 +1,58 @@
-- 2026-10-25 第一批挑战:课上被 4 个以上班布置过的题里,从做对的代码挖出、逐条看过例子定下的 73 条
-- (56 道题)。只填还没配过挑战的题;谁已经做到了由 api 启动后 services/challenge.ts 拿历史提交补。
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"d0b6eeb8","wording":"用 map() 读入两个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '1044' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b4844e67","wording":"用 f-string 拼出这句话","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1087' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"2a157045","wording":"用循环打印出旗帜","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"count_function_call","target":"print","max":2}]},{"id":"a72f48d6","wording":"整面旗只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1052' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b8f41dd4","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '4007' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"ea1be4b2","wording":"用 if-elif-else 写","rules":[{"engine":"must_exist_node","target":"elif_clause"}]},{"id":"d46a661a","wording":"每段台词只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":3}]}]}'::jsonb WHERE "_id" = '1047' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"fba3b158","wording":"全题只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1035' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"8df071eb","wording":"用 map() 一次读两个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '3018' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"c01df2d3","wording":"整首歌只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1080' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5409669d","wording":"用 f-string 保留四位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1046' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"192b2fda","wording":"用 f-string 套公式","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '2020' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"c91d60aa","wording":"三条规定只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1051' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"498feb32","wording":"只用 1 次 print 写完","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1083' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b9cb3d4e","wording":"用列表查出几转","rules":[{"engine":"must_exist_node","target":"list_literal"},{"engine":"count_function_call","target":"print","max":2}]},{"id":"822bf287","wording":"不用 if,只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1},{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1058' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"56dda53b","wording":"用 while 循环写 101 次","rules":[{"engine":"must_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1099' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"f9e79693","wording":"用 for 和 range 写完","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '2032' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5c67590d","wording":"只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1089' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b4003e97","wording":"用 for 循环再写一遍","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]},{"id":"3c2fc363","wording":"用 while 循环再写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '1084' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"6dd5a3cd","wording":"用字典查出教规","rules":[{"engine":"must_exist_node","target":"dict_literal"}]},{"id":"76fc381d","wording":"只用 2 次 print 写完","rules":[{"engine":"count_function_call","target":"print","max":2}]}]}'::jsonb WHERE "_id" = '1054' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"9e82648b","wording":"用 len() 判断几位数","rules":[{"engine":"must_call_function","target":"len"}]}]}'::jsonb WHERE "_id" = '2075' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"318c2371","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2073' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5cdb6fa8","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1022' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"35e8ebb4","wording":"不用循环,夸 n 遍","rules":[{"engine":"must_not_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]},{"id":"73f9af3b","wording":"用 while 循环写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '1016' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"210fb5da","wording":"用字典查出歌名","rules":[{"engine":"must_exist_node","target":"dict_literal"}]}]}'::jsonb WHERE "_id" = '5015' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"561c2021","wording":"用字典存五种形态","rules":[{"engine":"must_exist_node","target":"dict_literal"}]}]}'::jsonb WHERE "_id" = '9030' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"a0e03f18","wording":"只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '3031' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"62dd1388","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1025' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"e90bae2e","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '3026' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"c8d6ef22","wording":"用 count() 数出个数","rules":[{"engine":"must_call_method","target":"count"}]}]}'::jsonb WHERE "_id" = '2038' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"7d1f0479","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '9021' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"C":[{"id":"fbd744ac","wording":"用 for 循环再写一遍","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1059' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"edd54861","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2071' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5e3eb176","wording":"用 for 循环再写一遍","rules":[{"engine":"must_exist_node","target":"for_loop"}]},{"id":"60e19d6b","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1098' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"96900c74","wording":"用 while 循环再写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '7002' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"3578ecf6","wording":"用 .replace() 一步换好","rules":[{"engine":"must_call_method","target":"replace"}]}]}'::jsonb WHERE "_id" = '3070' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"07b26542","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]},{"id":"e1b569e2","wording":"用字典查出答案","rules":[{"engine":"must_exist_node","target":"dict_literal"},{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2039' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"C":[{"id":"87935a21","wording":"用 for 循环算出总和","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1041' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"40ed6ec6","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2066' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"50a17686","wording":"用列表推导式筛出来","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '3033' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"a652c5f8","wording":"用列表推导式筛出来","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '3034' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"2f1e618f","wording":"用 sum() 一步求和","rules":[{"engine":"must_call_function","target":"sum"},{"engine":"must_not_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1018' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"7f7a98be","wording":"用列表推导式转大写","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]},{"id":"4c5ecedc","wording":"用 replace() 换掉逗号","rules":[{"engine":"must_call_method","target":"replace"}]}]}'::jsonb WHERE "_id" = '3075' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"d9cb6be1","wording":"用 f-string 写算式","rules":[{"engine":"must_exist_node","target":"f_string"}]},{"id":"224c2ae6","wording":"4 行只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1007' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5b76324a","wording":"用 sum() 求总和","rules":[{"engine":"must_call_function","target":"sum"}]},{"id":"f61a47ae","wording":"用 map() 一次读三个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '3051' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"a5245180","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]},{"id":"f70990b6","wording":"用列表下标取出枪意","rules":[{"engine":"must_exist_node","target":"list_literal"},{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1086' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"84fa94c8","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]},{"id":"579e40c5","wording":"用列表推导式筛出来","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '3035' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5892a636","wording":"用列表推导式数偶数","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]},{"id":"b48b1336","wording":"用 sum() 数出奇偶个数","rules":[{"engine":"must_call_function","target":"sum"}]}]}'::jsonb WHERE "_id" = '3032' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"328d6a02","wording":"写一个函数算出打鱼还是晒网","rules":[{"engine":"must_exist_node","target":"function_definition"}]},{"id":"b583525a","wording":"只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1001' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"88d9a3b6","wording":"用 abs() 让负数也判对","rules":[{"engine":"must_call_function","target":"abs"}]}]}'::jsonb WHERE "_id" = '2074' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"e8394da9","wording":"不用循环,加上感叹号","rules":[{"engine":"must_not_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]},{"id":"d105cbe5","wording":"用列表推导式加感叹号","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '1085' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"dd4b7b1e","wording":"用自定义函数换算分钟","rules":[{"engine":"must_exist_node","target":"function_definition"}]}]}'::jsonb WHERE "_id" = '1003' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"7b46949e","wording":"四行只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '3028' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"e5b04d3b","wording":"用 map() 读入两个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '2044' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"0b108eaa","wording":"用 for 循环加各位","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"count_function_call","target":"int","max":1}]}]}'::jsonb WHERE "_id" = '3061' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"0774ee1c","wording":"用 sum() 算总分","rules":[{"engine":"must_call_function","target":"sum"}]},{"id":"150a618a","wording":"用 map() 一次读四门","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '3055' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"8d814fb4","wording":"用 while 循环再写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '2078' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"29baa412","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1045' AND "contest_id" IS NULL AND "challenges" IS NULL;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -267,6 +267,20 @@
"when": 1792494707274, "when": 1792494707274,
"tag": "0037_add_user_token_version", "tag": "0037_add_user_token_version",
"breakpoints": true "breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1792930010361,
"tag": "0038_add_challenges",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1792930018504,
"tag": "0039_seed_challenges",
"breakpoints": true
} }
] ]
} }
+59
View File
@@ -45,6 +45,7 @@ import type {
HintDiagnosis, HintDiagnosis,
JudgeStatus, JudgeStatus,
LessonLanguage, LessonLanguage,
ProblemChallenges,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
ProblemPermission, ProblemPermission,
@@ -570,6 +571,8 @@ export const problem = pgTable(
mermaidCode: text("mermaid_code"), mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(), showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules").$type<AstRules>(), astRules: jsonb("ast_rules").$type<AstRules>(),
/** 做对之后的挑战,按语言分组。写入侧在 admin/problem.ts 校验,见契约 challengeSchema */
challenges: jsonb().$type<ProblemChallenges>(),
sqlConfig: jsonb("sql_config").$type<SqlConfig>(), sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(), sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
}, },
@@ -1467,3 +1470,59 @@ export const classLesson = pgTable(
}).onDelete("cascade"), }).onDelete("cascade"),
], ],
) )
/**
* 谁做到了哪条挑战(problem.challenges 里的 id)。一人一条挑战一行,记第一次做到的那份提交。
*
* 两条路写进来:判题时做对的提交顺手判一遍(judge/run.ts),以及新加一条挑战时拿历史上
* 做对的代码补一遍(services/challenge.ts)—— 后者的 created_at 也是那份提交的时刻,
* 所以「本班第几个、全站第几个做到」按它排,补的和新做的一个口径。
*
* 外键:提交、题目没了,从它算出来的这一行也就没意义了,CASCADE;
* user 跟着 submission 走(submission.user_id 是 NO ACTION,删不掉有提交的用户)。
*/
export const challengeSuccess = pgTable(
"challenge_success",
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: "challenge_success_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
userId: integer("user_id").notNull(),
problemId: integer("problem_id").notNull(),
challengeId: text("challenge_id").notNull(),
submissionId: text("submission_id").notNull(),
/** 做到的那份提交的时刻(不是写这一行的时刻) */
createdAt: timestamp("created_at", {
withTimezone: true,
mode: "string",
}).notNull(),
},
(table) => [
unique("challenge_success_user_challenge_unique").on(
table.userId,
table.problemId,
table.challengeId,
),
index("challenge_success_problem_idx").on(table.problemId, table.challengeId, table.createdAt),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "challenge_success_user_id_fk_user_id",
}),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "challenge_success_problem_id_fk_problem_id",
}).onDelete("cascade"),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "challenge_success_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
+4
View File
@@ -22,6 +22,7 @@ import { problemStatsRoutes } from "./routes/problem-stats"
import { submissionRoutes } from "./routes/submission" import { submissionRoutes } from "./routes/submission"
import { trialRunRoutes } from "./routes/trial-run" import { trialRunRoutes } from "./routes/trial-run"
import { siteRoutes } from "./routes/site" import { siteRoutes } from "./routes/site"
import { scanPendingChallenges } from "./services/challenge"
import { import {
bridgeSubmissionEvents, bridgeSubmissionEvents,
isAllowedWebSocketOrigin, isAllowedWebSocketOrigin,
@@ -165,3 +166,6 @@ const server = Bun.serve<SubmissionSocketData>({
await bridgeSubmissionEvents(server) await bridgeSubmissionEvents(server)
startSessionSweep() startSessionSweep()
console.log(`OJ2 API listening on http://localhost:${server.port}`) console.log(`OJ2 API listening on http://localhost:${server.port}`)
// 迁移刚灌进来、或者上次没补完的挑战:拿历史上做对的代码补一遍谁已经做到了。
// 晚一点再跑,别和刚起来时涌进来的请求抢那两个核
setTimeout(() => void scanPendingChallenges(), 20_000)
+89 -27
View File
@@ -30,6 +30,18 @@ export interface AstResult {
passed: boolean passed: boolean
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */ /** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
actual?: number actual?: number
/** 没过的「不能用 X」「X 太多」:X 在第几行(1 起),挑战没做到时编辑器里标出来 */
lines?: number[]
}
/** 节点所在的行,去重排序。submission.code 只是学生写的那段,不含题目模板,行号不用再扣 */
function linesOf(nodes: Node[]) {
return [...new Set(nodes.map((node) => node.startPosition.row + 1))].sort((a, b) => a - b)
}
/** 没过才带行号:过了的规则不用标 */
function withLines(result: AstResult, nodes: Node[]): AstResult {
return result.passed || nodes.length === 0 ? result : { ...result, lines: linesOf(nodes) }
} }
let initPromise: Promise<void> | undefined let initPromise: Promise<void> | undefined
@@ -266,47 +278,60 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, nodeType),
} }
case "must_not_exist_node": case "must_not_exist_node": {
return { const nodes = collectNodes(root, nodeType)
description: describeAstRule(rule, language), return withLines(
passed: !hasNode(root, nodeType), { description: describeAstRule(rule, language), passed: nodes.length === 0 },
} nodes,
)
}
case "count_node": { case "count_node": {
const count = collectNodes(root, nodeType).length const nodes = collectNodes(root, nodeType)
return { return withLines(
description: describeAstRule(rule, language), {
passed: rangePassed(count, rule), description: describeAstRule(rule, language),
actual: count, passed: rangePassed(nodes.length, rule),
} actual: nodes.length,
},
// 次数太少时没有「哪几行」可标
rule.max !== undefined || rule.exact !== undefined ? nodes : [],
)
} }
case "must_call_function": case "must_call_function":
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length > 0, passed: functionCalls(root, target, language).length > 0,
} }
case "must_not_call_function": case "must_not_call_function": {
return { const calls = functionCalls(root, target, language)
description: describeAstRule(rule, language), return withLines(
passed: functionCalls(root, target, language).length === 0, { description: describeAstRule(rule, language), passed: calls.length === 0 },
} calls,
)
}
case "count_function_call": { case "count_function_call": {
const count = functionCalls(root, target, language).length const calls = functionCalls(root, target, language)
return { return withLines(
description: describeAstRule(rule, language), {
passed: rangePassed(count, rule), description: describeAstRule(rule, language),
actual: count, passed: rangePassed(calls.length, rule),
} actual: calls.length,
},
rule.max !== undefined || rule.exact !== undefined ? calls : [],
)
} }
case "must_call_method": case "must_call_method":
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length > 0, passed: methodCalls(root, target, language).length > 0,
} }
case "must_not_call_method": case "must_not_call_method": {
return { const calls = methodCalls(root, target, language)
description: describeAstRule(rule, language), return withLines(
passed: methodCalls(root, target, language).length === 0, { description: describeAstRule(rule, language), passed: calls.length === 0 },
} calls,
)
}
case "must_use_operator": case "must_use_operator":
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
@@ -357,3 +382,40 @@ export async function checkAst(
parser.delete() parser.delete()
} }
} }
/**
* 一份代码对着几组规则各判一遍,只解析一次。挑战用:一道题几条挑战各是一组,补历史记录时
* 一份代码要判全部挑战,每组 checkAst 一次就是把同一份代码解析好几遍。
*
* 和 checkAst 不同,**判不了时返回 null 而不是「通过」**:checkAst 是题目的硬要求,
* 判不了就放行、别冤枉学生;挑战判不了就是没做到,放行等于白送。
*/
export async function checkAstGroups(
code: string,
language: string,
groups: AstRule[][],
): Promise<{ passed: boolean; results: AstResult[] }[] | null> {
const treeSitterLanguage = await loadLanguage(language)
if (!treeSitterLanguage) return null
const parser = new Parser()
try {
parser.setLanguage(treeSitterLanguage)
const tree = parser.parse(code)
if (!tree) return null
try {
return groups.map((rules) => {
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language))
.filter((result): result is AstResult => result !== null)
return { passed: results.length > 0 && results.every((r) => r.passed), results }
})
} finally {
tree.delete()
}
} catch {
return null
} finally {
parser.delete()
}
}
+35
View File
@@ -7,6 +7,12 @@ import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events" import { publishAchievementNotification } from "../events"
import { updateAchievementsForSubmission } from "../services/achievements" import { updateAchievementsForSubmission } from "../services/achievements"
import {
challengesFor,
judgeChallenges,
recordChallengeSuccesses,
type ChallengeVerdict,
} from "../services/challenge"
import { recordSolvedAndNotify } from "../services/problemset" import { recordSolvedAndNotify } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
@@ -696,6 +702,19 @@ export async function judgeSubmission(job: JudgeJobData) {
} }
} }
} }
// 挑战:做对了就拿这道题的每条挑战各判一遍(比赛不判,同上)。结果全存下来,
// 前端在挑战中时按 id 取它正在挑战的那条;做到的下面落库
if (result === JudgeStatus.ACCEPTED && row.submission.contestId === null) {
const challenges = challengesFor(row.problem.challenges, row.submission.language)
if (challenges.length > 0) {
statisticInfo.challenges = await judgeChallenges(
row.submission.code,
row.submission.language,
challenges,
)
}
}
} }
const counted = countedBefore(job.rejudgedFrom) const counted = countedBefore(job.rejudgedFrom)
@@ -722,6 +741,22 @@ export async function judgeSubmission(job: JudgeJobData) {
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime) await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
} }
if (Array.isArray(statisticInfo.challenges)) {
await recordChallengeSuccesses(
(statisticInfo.challenges as ChallengeVerdict[])
.filter((verdict) => verdict.passed)
.map((verdict) => ({
userId: row.submission.userId,
problemId: row.problem.id,
challengeId: verdict.id,
submissionId: row.submission.id,
createdAt: row.submission.createTime,
})),
).catch((error: unknown) =>
console.error(`[judge] 挑战记录失败 ${row.submission.id}:`, error),
)
}
try { try {
const unlocked = await updateAchievementsForSubmission(row.submission.id, counted) const unlocked = await updateAchievementsForSubmission(row.submission.id, counted)
await publishAchievementNotification( await publishAchievementNotification(
+61 -5
View File
@@ -4,6 +4,7 @@ import {
addContestProblemRequestSchema, addContestProblemRequestSchema,
reorderContestProblemsRequestSchema, reorderContestProblemsRequestSchema,
astCheckRequestSchema, astCheckRequestSchema,
challengeSuggestionsRequestSchema,
generateTestInputsRequestSchema, generateTestInputsRequestSchema,
isSqlProblem, isSqlProblem,
TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_CASES,
@@ -18,8 +19,10 @@ import {
type AdminProblemListItem, type AdminProblemListItem,
type AstCheckResponse, type AstCheckResponse,
type AstRules, type AstRules,
type ChallengeSuggestionsResponse,
type GenerateTestInputsResponse, type GenerateTestInputsResponse,
type GenerateSqlTestCaseResponse, type GenerateSqlTestCaseResponse,
type ProblemChallenges,
type SqlConfig, type SqlConfig,
type SqlDisplay, type SqlDisplay,
type SqlTestCaseScript, type SqlTestCaseScript,
@@ -35,6 +38,12 @@ import { failure, parseBody, success } from "../../http"
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast" import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
import { buildSqlDisplay } from "../../judge/sql" import { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai" import { completeChat } from "../../services/ai"
import {
challengesFor,
challengeSuggestions,
normalizeChallenges,
scanPendingChallenges,
} from "../../services/challenge"
import { plainText } from "../../services/hint-diagnosis" import { plainText } from "../../services/hint-diagnosis"
import { contestStatus } from "../../services/contest" import { contestStatus } from "../../services/contest"
import { import {
@@ -152,6 +161,7 @@ async function serialize(row: ProblemRow) {
flowchartData: asRecord(row.flowchartData), flowchartData: asRecord(row.flowchartData),
flowchartHint: row.flowchartHint, flowchartHint: row.flowchartHint,
astRules: row.astRules, astRules: row.astRules,
challenges: row.challenges,
answers: Array.isArray(row.answers) ? row.answers : [], answers: Array.isArray(row.answers) ? row.answers : [],
prompt: row.prompt, prompt: row.prompt,
sqlConfig: row.sqlConfig, sqlConfig: row.sqlConfig,
@@ -271,19 +281,34 @@ type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
*/ */
async function validateProblem( async function validateProblem(
data: ProblemInput, data: ProblemInput,
): Promise<{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }> { previousChallenges: ProblemChallenges | null = null,
): Promise<{ error: string } | Validated> {
const checked = commonChecks(data) const checked = commonChecks(data)
if ("error" in checked) return checked if ("error" in checked) return checked
if (!checked.sql) return { sql: false, sqlDisplay: null } // 不带挑战(旧版出题页)就原样留着,见契约 createProblemRequestSchema.challenges
const normalized =
data.challenges === undefined
? { challenges: previousChallenges }
: normalizeChallenges(data.challenges, previousChallenges, data.languages)
if ("error" in normalized) return normalized
const { challenges } = normalized
if (!checked.sql) return { sql: false, sqlDisplay: null, challenges }
const built = await generateSqlDisplay(data.testCaseId, data.answers, data.sqlConfig!) const built = await generateSqlDisplay(data.testCaseId, data.answers, data.sqlConfig!)
if ("error" in built) return built if ("error" in built) return built
return { sql: true, sqlDisplay: built.display } return { sql: true, sqlDisplay: built.display, challenges }
}
interface Validated {
sql: boolean
sqlDisplay: SqlDisplay | null
/** 规则没变的沿用原来的 id,见 services/challenge.ts 的 normalizeChallenges */
challenges: ProblemChallenges | null
} }
/** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */ /** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */
async function insertProblem( async function insertProblem(
data: ProblemInput, data: ProblemInput,
validated: { sql: boolean; sqlDisplay: SqlDisplay | null }, validated: Validated,
owner: { contestId: number | null; createdById: number }, owner: { contestId: number | null; createdById: number },
) { ) {
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -292,6 +317,7 @@ async function insertProblem(
.insert(schema.problem) .insert(schema.problem)
.values({ .values({
...problemValues(data, validated.sql), ...problemValues(data, validated.sql),
challenges: validated.challenges,
...owner, ...owner,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
@@ -489,6 +515,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
contestId: null, contestId: null,
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
}) })
void scanPendingChallenges()
return success(c, await serialize(created), 201) return success(c, await serialize(created), 201)
}) })
@@ -505,7 +532,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
if (!(await canEditProblem(c.get("user")!, existing))) { if (!(await canEditProblem(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist") return failure(c, 404, "problem-not-found", "Problem does not exist")
} }
const validated = await validateProblem(parsed.data) const validated = await validateProblem(parsed.data, existing.challenges)
if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error) if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error)
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一 // 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
@@ -529,6 +556,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
.update(schema.problem) .update(schema.problem)
.set({ .set({
...problemValues(parsed.data, validated.sql), ...problemValues(parsed.data, validated.sql),
challenges: validated.challenges,
sqlDisplay: validated.sqlDisplay, sqlDisplay: validated.sqlDisplay,
lastUpdateTime: new Date().toISOString(), lastUpdateTime: new Date().toISOString(),
}) })
@@ -537,6 +565,8 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
await setTags(tx, id, parsed.data.tags) await setTags(tx, id, parsed.data.tags)
return row! return row!
}) })
// 新加、改过的挑战拿历史上做对的代码补一遍谁已经做到了,不等它
void scanPendingChallenges()
return success(c, await serialize(updated)) return success(c, await serialize(updated))
}) })
@@ -962,6 +992,32 @@ adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => {
return success(c, { rules: checked } satisfies AstCheckResponse) return success(c, { rules: checked } satisfies AstCheckResponse)
}) })
/** 出题页「挑战」的候选:拿这道题历史上做对的代码挖,见 services/challenge.ts */
adminProblemRoutes.post(
"/problems/:id/challenge-suggestions",
requireProblemPermission,
async (c) => {
const parsed = await parseBody(c, challengeSuggestionsRequestSchema)
if (!parsed.success) return parsed.response
const [problem] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEditProblem(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const { language } = parsed.data
const result = await challengeSuggestions(
problem.id,
language,
challengesFor(problem.challenges, language),
)
return success(c, result satisfies ChallengeSuggestionsResponse)
},
)
/** /**
* AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑, * AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑,
* AI 编的输出不可信。 * AI 编的输出不可信。
+31
View File
@@ -20,6 +20,7 @@ import { seenToday } from "../auth/presence"
import { getLoginWindow } from "../auth/session" import { getLoginWindow } from "../auth/session"
import { presenceOf } from "../collab/state" import { presenceOf } from "../collab/state"
import { db, schema } from "../db" import { db, schema } from "../db"
import { challengeSuccessesOf } from "../services/challenge"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { isAccepted } from "../judge/status" import { isAccepted } from "../judge/status"
import { import {
@@ -465,6 +466,27 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径 // 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
const recentActivity = activityRows({ since: windowStart, users: userIds }) const recentActivity = activityRows({ since: windowStart, users: userIds })
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')` const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
const [challengeConfigs, challengeDone] = await Promise.all([
ids.length
? db
.select({ id: schema.problem.id, challenges: schema.problem.challenges })
.from(schema.problem)
.where(inArray(schema.problem.id, ids))
: [],
challengeSuccessesOf(userIds, ids),
])
// 只认现在还配着的挑战:规则改过的旧 id 不算
const liveChallenges = new Map(
challengeConfigs.map((row) => [
row.id,
new Set(Object.values(row.challenges ?? {}).flatMap((list) => list.map((c) => c.id))),
]),
)
const challengeDoneKeys = new Set(
challengeDone
.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId))
.map((row) => `${row.userId}:${row.problemId}`),
)
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([ const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -619,6 +641,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
title: problem.title, title: problem.title,
isSql: problem.isSql, isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
})), })),
students: roster.map( students: roster.map(
(student) => (student) =>
@@ -659,6 +682,14 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
})) }))
: [], : [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null, acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
challenge:
!acceptedAt || !liveChallenges.get(id)?.size
? null
: challengeDoneKeys.has(key)
? "done"
: today.some((row) => Date.parse(row.createTime) > Date.parse(acceptedAt))
? "doing"
: null,
} }
}), }),
lastSubmitAt: lastByUser.get(student.userId) ?? null, lastSubmitAt: lastByUser.get(student.userId) ?? null,
+19 -1
View File
@@ -1,8 +1,10 @@
import { import {
AST_SUPPORTED_LANGUAGES,
isSqlProblem, isSqlProblem,
normalizeLanguage, normalizeLanguage,
problemListSortSchema, problemListSortSchema,
problemTypeFilterSchema, problemTypeFilterSchema,
type ChallengeList,
type ProblemAuthor, type ProblemAuthor,
type ProblemDetail, type ProblemDetail,
type ProblemList, type ProblemList,
@@ -28,9 +30,10 @@ import {
} from "drizzle-orm" } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { astRequirements } from "../judge/ast" import { astRequirements } from "../judge/ast"
import { challengeList } from "../services/challenge"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { import {
@@ -340,6 +343,21 @@ problemRoutes.get("/problem-authors", async (c) => {
return success(c, rows satisfies ProblemAuthor[]) return success(c, rows satisfies ProblemAuthor[])
}) })
/**
* 几道题的挑战:`?problems=1080,2020,4077&language=Python`。课堂条「这节课都做完了」一次要
* 一节课的几道,题目页只要当前这道。挑战按语言分,语言是学生编辑器里选的那种
*/
problemRoutes.get("/challenges", requireAuth, async (c) => {
const language = c.req.query("language") ?? ""
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return success(c, [] satisfies ChallengeList)
const ids = (c.req.query("problems") ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean)
.slice(0, 20)
return success(c, (await challengeList(c.get("user")!, ids, language)) satisfies ChallengeList)
})
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
const [target] = await db const [target] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
+560
View File
@@ -0,0 +1,560 @@
import { randomBytes } from "node:crypto"
import {
AST_SUPPORTED_LANGUAGES,
challengeSchema,
type AstRule,
type Challenge,
type ChallengeInput,
type ChallengeList,
type ChallengeSuggestion,
type ProblemChallenges,
type ProblemLanguage,
} from "@oj2/contract"
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { astRulesError, checkAstGroups, type AstResult } from "../judge/ast"
import { JudgeStatus } from "../judge/status"
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍。规则存在 problem.challenges,
* 谁做到了记在 challenge_success。形状和口径见契约 challengeSchema。
*
* 这里管四件事:判(judge/run.ts 在做对的提交上调)、补(新加的挑战拿历史上做对的
* 代码补一遍谁已经做到了)、给学生的列表、给出题页的候选。
*/
/** problem.challenges 里某种语言的挑战。逐条过 schema,一条坏的不连累别的(同 run.ts 的 astRulesForLanguage) */
export function challengesFor(value: unknown, language: string): Challenge[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return []
const list = (value as Record<string, unknown>)[language]
if (!Array.isArray(list)) return []
return list.flatMap((item) => {
const parsed = challengeSchema.safeParse(item)
return parsed.success && parsed.data.rules.length > 0 ? [parsed.data] : []
})
}
export interface ChallengeVerdict {
id: string
passed: boolean
results: AstResult[]
}
/** 一份代码对着几条挑战各判一遍(只解析一次)。判不了就当一条都没做到 */
export async function judgeChallenges(
code: string,
language: string,
challenges: Challenge[],
): Promise<ChallengeVerdict[]> {
if (challenges.length === 0) return []
const groups = await checkAstGroups(
code,
language,
challenges.map((challenge) => challenge.rules),
)
if (!groups) return []
return challenges.map((challenge, i) => ({
id: challenge.id,
passed: groups[i]!.passed,
results: groups[i]!.results,
}))
}
interface SuccessRow {
userId: number
problemId: number
challengeId: string
submissionId: string
createdAt: string
}
/** 记下做到了。已经做到过的不动 —— 留的是第一次做到的那份提交 */
export async function recordChallengeSuccesses(rows: SuccessRow[]) {
if (rows.length === 0) return
await db.insert(schema.challengeSuccess).values(rows).onConflictDoNothing()
}
// ---------------------------------------------------------------- 补历史记录
let scanning: Promise<void> | null = null
/**
* 新加的挑战(没有 scannedAt 的)拿历史上做对的代码补一遍:一批人早就这么写过,
* 不补的话「全站 N 人做到」从 0 开始,那些人打开挑战还得再交一次一模一样的代码。
*
* api 起来之后跑一次(迁移灌进来的那批在这里补),出题页保存之后再跑一次。
* 两个站点共库、各起一个 api,可能同时补同一批 —— 插入是 on conflict do nothing,
* 重复补只是白算一遍。
*
* 服务器只有 2 核:一道题几百份代码,每 50 份让出一次事件循环,别把请求卡住。
*/
export function scanPendingChallenges(): Promise<void> {
if (!scanning) {
scanning = scanAll()
.catch((error: unknown) => console.error("[challenge] 补做到记录失败", error))
.finally(() => {
scanning = null
})
}
return scanning
}
async function scanAll() {
const problems = await db
.select({ id: schema.problem.id, challenges: schema.problem.challenges })
.from(schema.problem)
.where(sql`${schema.problem.challenges} is not null`)
for (const problem of problems) {
for (const language of Object.keys(problem.challenges ?? {})) {
const pending = challengesFor(problem.challenges, language).filter((c) => !c.scannedAt)
if (pending.length === 0) continue
const started = Date.now()
const found = await scanProblem(problem.id, language, pending)
await markScanned(
problem.id,
language,
pending.map((c) => c.id),
)
console.log(
`[challenge] 题 ${problem.id} ${language} 补了 ${pending.length} 条挑战、${found} 条做到,用时 ${Date.now() - started}ms`,
)
}
}
}
async function scanProblem(problemId: number, language: string, pending: Challenge[]) {
const subs = await db
.select({
id: schema.submission.id,
userId: schema.submission.userId,
code: schema.submission.code,
createTime: schema.submission.createTime,
})
.from(schema.submission)
// 库里有一批孤儿提交(用户删了、提交还在),记不进 challenge_success 的外键
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
eq(schema.submission.problemId, problemId),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
eq(schema.submission.language, language as ProblemLanguage),
isNull(schema.submission.contestId),
),
)
// 按时间顺着判:同一个人第一次做到的那份先遇到
.orderBy(asc(schema.submission.createTime))
const seen = pending.map(() => new Set<number>())
let rows: SuccessRow[] = []
let found = 0
for (const [index, sub] of subs.entries()) {
if (pending.every((_, i) => seen[i]!.has(sub.userId))) continue
const verdicts = await judgeChallenges(sub.code, language, pending)
for (const [i, verdict] of verdicts.entries()) {
if (!verdict.passed || seen[i]!.has(sub.userId)) continue
seen[i]!.add(sub.userId)
rows.push({
userId: sub.userId,
problemId,
challengeId: verdict.id,
submissionId: sub.id,
createdAt: sub.createTime,
})
}
if (rows.length >= 500) {
found += rows.length
await recordChallengeSuccesses(rows)
rows = []
}
if (index % 50 === 49) await Bun.sleep(0)
}
found += rows.length
await recordChallengeSuccesses(rows)
return found
}
/** 补完记一笔。重新读一遍再写:补的这会儿老师可能刚存过,只给还在、id 没变的那几条打标记 */
async function markScanned(problemId: number, language: string, ids: string[]) {
const [row] = await db
.select({ challenges: schema.problem.challenges })
.from(schema.problem)
.where(eq(schema.problem.id, problemId))
.limit(1)
const current = row?.challenges
if (!current?.[language]) return
const now = new Date().toISOString()
const next: ProblemChallenges = {
...current,
[language]: current[language]!.map((c) => (ids.includes(c.id) ? { ...c, scannedAt: now } : c)),
}
await db.update(schema.problem).set({ challenges: next }).where(eq(schema.problem.id, problemId))
}
// ---------------------------------------------------------------- 出题页保存
function sameRules(a: AstRule[], b: AstRule[]) {
return JSON.stringify(a) === JSON.stringify(b)
}
/**
* 出题页提交的挑战 → 落库的形状。只留题目收的、判题机判得了的语言(同 pickAstRules);
* 规则没变的沿用原来的 id 和 scannedAt,改过的、新加的发新 id —— 做到记录跟着 id 走,
* 规则一变旧记录就不作数了。
*/
export function normalizeChallenges(
input: Record<string, ChallengeInput[]> | null,
previous: ProblemChallenges | null,
languages: string[],
): { error: string } | { challenges: ProblemChallenges | null } {
if (!input) return { challenges: null }
const out: ProblemChallenges = {}
for (const [language, list] of Object.entries(input)) {
if (!languages.includes(language) || !AST_SUPPORTED_LANGUAGES.includes(language)) continue
const before = previous?.[language] ?? []
const items: Challenge[] = []
for (const [index, item] of list.entries()) {
const error = astRulesError({ [language]: item.rules })
if (error)
return {
error: `挑战 ${language} 第 ${index + 1} 条:${error.replace(/^代码规则 \S+ /, "")}`,
}
const kept = before.find((c) => c.id === item.id && sameRules(c.rules, item.rules))
items.push({
id: kept?.id ?? randomBytes(4).toString("hex"),
wording: item.wording,
rules: item.rules,
...(kept?.scannedAt ? { scannedAt: kept.scannedAt } : {}),
})
}
if (items.length > 0) out[language] = items
}
return { challenges: Object.keys(out).length > 0 ? out : null }
}
// ---------------------------------------------------------------- 给学生的列表
/**
* 几道题、某种语言下的挑战:说法、全站几人做到、我做到没有(做到了是本班、全站第几个)。
* 规则原文不下发,同 astRequirements。题号按传进来的顺序返回,没配挑战的题不返回。
*/
export async function challengeList(
user: { id: number; className: string | null },
displayIds: string[],
language: string,
): Promise<ChallengeList> {
if (displayIds.length === 0) return []
const problems = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
challenges: schema.problem.challenges,
})
.from(schema.problem)
.where(
and(
inArray(schema.problem.displayId, displayIds),
isNull(schema.problem.contestId),
sql`${schema.problem.challenges} is not null`,
),
)
const configured = problems
.map((p) => ({ ...p, list: challengesFor(p.challenges, language) }))
.filter((p) => p.list.length > 0)
if (configured.length === 0) return []
const ids = configured.map((p) => p.id)
const acceptedBy = and(
inArray(schema.submission.problemId, ids),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
eq(schema.submission.language, language as ProblemLanguage),
isNull(schema.submission.contestId),
)
const [acRows, solvedRows, doneRows, mineRows] = await Promise.all([
db
.select({
problemId: schema.submission.problemId,
count: sql<number>`count(distinct ${schema.submission.userId})::int`,
})
.from(schema.submission)
.where(acceptedBy)
.groupBy(schema.submission.problemId),
db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(and(acceptedBy, eq(schema.submission.userId, user.id))),
db
.select({
problemId: schema.challengeSuccess.problemId,
challengeId: schema.challengeSuccess.challengeId,
count: sql<number>`count(*)::int`,
})
.from(schema.challengeSuccess)
.where(inArray(schema.challengeSuccess.problemId, ids))
.groupBy(schema.challengeSuccess.problemId, schema.challengeSuccess.challengeId),
db
.select()
.from(schema.challengeSuccess)
.where(
and(
inArray(schema.challengeSuccess.problemId, ids),
eq(schema.challengeSuccess.userId, user.id),
),
),
])
// 第几个做到:比我早的有几个。同一时刻的按 id 排(补记录时同一批插入的 id 也是按时间顺的)
const ranks = await Promise.all(
mineRows.map(async (mine) => {
const earlier = and(
eq(schema.challengeSuccess.problemId, mine.problemId),
eq(schema.challengeSuccess.challengeId, mine.challengeId),
sql`(${schema.challengeSuccess.createdAt}, ${schema.challengeSuccess.id}) < (${mine.createdAt}, ${mine.id})`,
)
const [site] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.challengeSuccess)
.where(earlier)
const [cls] = user.className
? await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.challengeSuccess)
.innerJoin(schema.user, eq(schema.user.id, schema.challengeSuccess.userId))
.where(and(earlier, eq(schema.user.className, user.className)))
: [null]
return {
key: `${mine.problemId}:${mine.challengeId}`,
mine: {
at: mine.createdAt,
siteRank: (site?.count ?? 0) + 1,
classRank: cls ? cls.count + 1 : null,
},
}
}),
)
const mineByKey = new Map(ranks.map((r) => [r.key, r.mine]))
const acByProblem = new Map(acRows.map((r) => [r.problemId, r.count]))
const solved = new Set(solvedRows.map((r) => r.problemId))
const doneByKey = new Map(doneRows.map((r) => [`${r.problemId}:${r.challengeId}`, r.count]))
const byDisplayId = new Map(configured.map((p) => [p.displayId, p]))
return displayIds.flatMap((displayId) => {
const p = byDisplayId.get(displayId)
if (!p) return []
return [
{
problemId: p.displayId,
title: p.title,
acCount: acByProblem.get(p.id) ?? 0,
solved: solved.has(p.id),
challenges: p.list.map((c) => ({
id: c.id,
wording: c.wording,
doneCount: doneByKey.get(`${p.id}:${c.id}`) ?? 0,
mine: mineByKey.get(`${p.id}:${c.id}`) ?? null,
})),
},
]
})
}
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
if (userIds.length === 0 || problemIds.length === 0) return []
return db
.select({
userId: schema.challengeSuccess.userId,
problemId: schema.challengeSuccess.problemId,
challengeId: schema.challengeSuccess.challengeId,
})
.from(schema.challengeSuccess)
.where(
and(
inArray(schema.challengeSuccess.userId, userIds),
inArray(schema.challengeSuccess.problemId, problemIds),
),
)
}
// ---------------------------------------------------------------- 出题页的候选
interface Feature {
rule: AstRule
/** 用了它时的说法、没用它时的说法 */
use: string
avoid: string
}
/** 中文里夹的英文词两边留空格:「用 f-string 再写一遍」「用列表再写一遍」 */
function phrases(label: string) {
const before = /^[A-Za-z.]/.test(label) ? " " : ""
const after = /[A-Za-z)]$/.test(label) ? " " : ""
return { use: `用${before}${label}${after}再写一遍`, avoid: `不用${before}${label},再写一遍` }
}
const node = (target: string, label: string): Feature => ({
rule: { engine: "must_exist_node", target },
...phrases(label),
})
const call = (target: string): Feature => ({
rule: { engine: "must_call_function", target },
...phrases(`${target}()`),
})
const method = (target: string): Feature => ({
rule: { engine: "must_call_method", target },
...phrases(`.${target}()`),
})
/**
* 去挖哪些写法。2026-10 第一批挑战是这样挖出来的,挖出来以后人工逐条看过,这几类基本
* 都判掉了,所以这里干脆不挖:「不用 int()」(做到的是 eval)、「不用 elif / else」
* (拆成一串 if、多写一个反向条件)、「用 str() / list() / strip()」(凑数)。
*/
const FEATURES: Record<string, Feature[]> = {
Python: [
node("for_loop", "for 循环"),
node("while_loop", "while 循环"),
node("if_statement", "if"),
node("function_definition", "自定义函数"),
node("list_comprehension", "列表推导式"),
node("list_literal", "列表"),
node("dict_literal", "字典"),
node("f_string", "f-string"),
...["max", "min", "sum", "len", "sorted", "abs", "round", "map", "enumerate", "zip"].map(call),
...["join", "format", "count", "replace", "split", "upper", "lower", "find"].map(method),
],
C: [
node("for_loop", "for 循环"),
node("while_loop", "while 循环"),
node("do_while", "do-while 循环"),
node("if_statement", "if"),
node("switch_statement", "switch"),
node("function_definition", "自定义函数"),
...["puts", "strlen", "abs", "pow", "sqrt"].map(call),
],
}
FEATURES["C++"] = FEATURES.C!
const PRINT: Record<string, string> = { Python: "print", C: "printf", "C++": "printf" }
/** 「不用」能直接变成规则的引擎 */
const NEGATE: Record<string, AstRule["engine"]> = {
must_exist_node: "must_not_exist_node",
must_call_function: "must_not_call_function",
must_call_method: "must_not_call_method",
}
/**
* 出题页「挑战」的候选:这道题历史上做对的代码里,一批人用另一种写法也做对了的地方。
* 门槛和第一批一样:多数人用了、有 ≥5 人没用也做对了 → 「不用 X」;少数人用了且 ≥5 人 →
* 「用 X」;print 次数少于中位数且 ≥5 人做到 → 「少用 print」。
*/
export async function challengeSuggestions(
problemId: number,
language: string,
existing: Challenge[],
): Promise<{ acCount: number; suggestions: ChallengeSuggestion[] }> {
const features = FEATURES[language] ?? []
const printName = PRINT[language] ?? "print"
const subs = await db
.select({ userId: schema.submission.userId, code: schema.submission.code })
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, problemId),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
eq(schema.submission.language, language as ProblemLanguage),
isNull(schema.submission.contestId),
),
)
.orderBy(asc(schema.submission.createTime))
const groups = [
...features.map((f) => [f.rule]),
[{ engine: "count_function_call", target: printName, min: 0 } satisfies AstRule],
]
interface Seen {
code: string
has: boolean[]
prints: number
}
const byUser = new Map<number, Seen[]>()
for (const [index, sub] of subs.entries()) {
const list = byUser.get(sub.userId) ?? []
// 一个人最多看 3 份:有人同一道题交了几十遍
if (list.length >= 3) continue
const verdicts = await checkAstGroups(sub.code, language, groups)
if (!verdicts) continue
list.push({
code: sub.code,
has: verdicts.slice(0, features.length).map((v) => v.passed),
prints: verdicts.at(-1)?.results[0]?.actual ?? 0,
})
byUser.set(sub.userId, list)
if (index % 50 === 49) await Bun.sleep(0)
}
const users = [...byUser.values()]
const acCount = users.length
if (acCount === 0) return { acCount, suggestions: [] }
const example = (pick: (s: Seen) => boolean) => {
const pool = users.flatMap((list) => list.filter(pick)).map((s) => s.code.trim())
pool.sort((a, b) => a.length - b.length)
return pool[Math.floor(pool.length / 2)] ?? ""
}
const suggestions: ChallengeSuggestion[] = []
for (const [i, feature] of features.entries()) {
const usedFirst = users.filter((list) => list[0]!.has[i]).length
if (usedFirst / acCount >= 0.5) {
const did = users.filter((list) => list.some((s) => !s.has[i])).length
const engine = NEGATE[feature.rule.engine]
if (did >= 5 && engine) {
suggestions.push({
wording: feature.avoid,
rules: [{ ...feature.rule, engine }],
did,
firstShare: Math.round((usedFirst / acCount) * 100),
example: example((s) => !s.has[i]),
})
}
} else {
const did = users.filter((list) => list.some((s) => s.has[i])).length
if (did >= 5) {
suggestions.push({
wording: feature.use,
rules: [feature.rule],
did,
firstShare: Math.round((usedFirst / acCount) * 100),
example: example((s) => s.has[i] === true),
})
}
}
}
const firstPrints = users.map((list) => list[0]!.prints).sort((a, b) => a - b)
const median = firstPrints[Math.floor(firstPrints.length / 2)]!
for (let level = 1; level < median; level++) {
const ok = (s: Seen) => s.prints >= 1 && s.prints <= level
const did = users.filter((list) => list.some(ok)).length
if (did < 5) continue
suggestions.push({
wording: `只用 ${level} 次 ${printName}`,
rules: [{ engine: "count_function_call", target: printName, max: level }],
did,
firstShare: null,
example: example(ok),
})
break
}
const taken = existing.map((c) => JSON.stringify(c.rules))
return {
acCount,
suggestions: suggestions
.filter((s) => !taken.includes(JSON.stringify(s.rules)))
.sort((a, b) => b.did - a.did)
.slice(0, 12),
}
}
+55
View File
@@ -11,6 +11,10 @@ import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import { import {
astRuleSchema, astRuleSchema,
astRulesSchema, astRulesSchema,
CHALLENGE_RULES_MAX,
CHALLENGE_WORDING_MAX,
CHALLENGES_PER_LANGUAGE_MAX,
problemChallengesSchema,
problemDifficultySchema, problemDifficultySchema,
sqlConfigSchema, sqlConfigSchema,
sqlDisplaySchema, sqlDisplaySchema,
@@ -945,6 +949,7 @@ export const adminProblemSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()), flowchartData: z.record(z.string(), z.unknown()),
flowchartHint: z.string().nullable(), flowchartHint: z.string().nullable(),
astRules: astRulesSchema.nullable(), astRules: astRulesSchema.nullable(),
challenges: problemChallengesSchema.nullable(),
answers: z.array(problemAnswerSchema), answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(), prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(), sqlConfig: sqlConfigSchema.nullable(),
@@ -963,6 +968,21 @@ export const adminProblemSchema = z.object({
*/ */
export const PROBLEM_TAG_MAX_LENGTH = 32 export const PROBLEM_TAG_MAX_LENGTH = 32
/**
* 出题页提交的一条挑战。新加的不带 id,后端发;规则改过的后端也换新 id(见契约
* challengeSchema)。规则的语义校验(这种语言有没有这个语法)在后端 astRulesError,同 astRules
*/
export const challengeInputSchema = z.object({
id: z.string().max(32).optional(),
wording: z.string().trim().min(1).max(CHALLENGE_WORDING_MAX),
rules: z.array(astRuleSchema).min(1).max(CHALLENGE_RULES_MAX),
})
export const challengeInputsSchema = z.record(
z.string(),
z.array(challengeInputSchema).max(CHALLENGES_PER_LANGUAGE_MAX),
)
export const createProblemRequestSchema = z.object({ export const createProblemRequestSchema = z.object({
_id: z.string().trim().min(1).max(32), _id: z.string().trim().min(1).max(32),
title: z.string().trim().min(1).max(1024), title: z.string().trim().min(1).max(1024),
@@ -1002,6 +1022,11 @@ export const createProblemRequestSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()).nullable().default(null), flowchartData: z.record(z.string(), z.unknown()).nullable().default(null),
flowchartHint: z.string().nullable().default(null), flowchartHint: z.string().nullable().default(null),
astRules: astRulesSchema.nullable().default(null), astRules: astRulesSchema.nullable().default(null),
/**
* 不带这个键 = 不动原来的挑战。上线那一刻浏览器里还开着旧版出题页,它保存时不带挑战,
* 给个默认 null 的话一保存就把挑战清了;null 才是「清空」
*/
challenges: challengeInputsSchema.nullable().optional(),
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的 // order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
// `BooleanField(default=False)` 一致 // `BooleanField(default=False)` 一致
sqlConfig: sqlConfigSchema sqlConfig: sqlConfigSchema
@@ -1122,6 +1147,33 @@ export const astCheckResponseSchema = z.object({
), ),
}) })
/**
* 出题页「挑战」的候选:拿这道题历史上做对的代码,找「一批人用另一种写法也做对了」的地方。
* 只是给老师挑的素材 —— 机械找出来的会有坏的(拆掉 elif 换成一串 if 也算「不用 elif」),
* 所以每条都带一份做到的人的真实代码,好不好老师看了再加
*/
export const challengeSuggestionsRequestSchema = z.object({
language: runnableLanguageSchema,
})
export const challengeSuggestionSchema = z.object({
/** 默认说法,老师加上之后可以改 */
wording: z.string(),
rules: z.array(astRuleSchema),
/** 这样做对过的人数 */
did: z.number().int(),
/** 第一次做对时就这样写的人占几成(%);「少用 print」这类没有 */
firstShare: z.number().int().nullable(),
/** 一份做到的人的代码 */
example: z.string(),
})
export const challengeSuggestionsResponseSchema = z.object({
/** 这种语言做对过的人数 */
acCount: z.number().int(),
suggestions: z.array(challengeSuggestionSchema),
})
/** AI 按题面和标准答案想几组测试输入(输出由标准答案在判题机上跑,不让 AI 编) */ /** AI 按题面和标准答案想几组测试输入(输出由标准答案在判题机上跑,不让 AI 编) */
export const generateTestInputsRequestSchema = z.object({ export const generateTestInputsRequestSchema = z.object({
description: z.string().max(64 * 1024), description: z.string().max(64 * 1024),
@@ -1180,6 +1232,9 @@ export type GenerateSqlTestCaseResponse = z.infer<typeof generateSqlTestCaseResp
export type TestCaseFiles = z.infer<typeof testCaseFilesSchema> export type TestCaseFiles = z.infer<typeof testCaseFilesSchema>
export type AstCheckRequest = z.input<typeof astCheckRequestSchema> export type AstCheckRequest = z.input<typeof astCheckRequestSchema>
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema> export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
export type ChallengeInput = z.input<typeof challengeInputSchema>
export type ChallengeSuggestion = z.infer<typeof challengeSuggestionSchema>
export type ChallengeSuggestionsResponse = z.infer<typeof challengeSuggestionsResponseSchema>
export type GenerateTestInputsRequest = z.input<typeof generateTestInputsRequestSchema> export type GenerateTestInputsRequest = z.input<typeof generateTestInputsRequestSchema>
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema> export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
+7
View File
@@ -88,6 +88,8 @@ export const classBoardProblemSchema = z.object({
title: z.string(), title: z.string(),
/** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */ /** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */
isSql: z.boolean(), isSql: z.boolean(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(),
}) })
export const classBoardCellSchema = z.object({ export const classBoardCellSchema = z.object({
@@ -128,6 +130,11 @@ export const classBoardCellSchema = z.object({
trail: z.array(z.object({ id: z.string(), result: judgeStatusSchema, createTime: z.string() })), trail: z.array(z.object({ id: z.string(), result: judgeStatusSchema, createTime: z.string() })),
/** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */ /** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */
acceptedTry: z.number().int().nullable(), acceptedTry: z.number().int().nullable(),
/**
* 挑战:done = 做到了这道题的一条挑战(不限今天);doing = 做对之后今天又交了、还没做到 ——
* 做完了的学生是在挑战还是闲着,老师一眼看得出。没配挑战的题、没做对的格子为 null
*/
challenge: z.enum(["done", "doing"]).nullable(),
}) })
export const classBoardStudentSchema = z.object({ export const classBoardStudentSchema = z.object({
+62
View File
@@ -355,6 +355,62 @@ export const astRequirementSchema = z.object({
/** 按语言分组,与 astRulesSchema 同一套键 */ /** 按语言分组,与 astRulesSchema 同一套键 */
export const astRequirementsSchema = z.record(z.string(), z.array(astRequirementSchema)) export const astRequirementsSchema = z.record(z.string(), z.array(astRequirementSchema))
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍(「整首歌只用 1 次 print」)。
* 判定沿用 AST 规则,几条规则同时满足才算做到 —— 单写「用 while」的话学生留着原来的
* for、再补一个空 while 就过了,所以常配成「用 while + 不用 for」。
*
* 和 astRules 一样按语言分组。`id` 是谁做到了(challenge_success)的记账键:
* 规则一改就换新 id,旧的做到记录跟着旧规则作废,只改说法不换。
* `scannedAt` 是后端记的「已经拿历史上做对的代码补过一遍记录」,见 services/challenge.ts。
*/
export const challengeSchema = z.object({
id: z.string(),
/** 给学生看的说法,例如「整首歌只用 1 次 print」 */
wording: z.string(),
rules: z.array(astRuleSchema),
scannedAt: z.string().optional(),
})
export const problemChallengesSchema = z.record(z.string(), z.array(challengeSchema))
/** 挑战说法的长度上限:要在结果页签的一行里放得下 */
export const CHALLENGE_WORDING_MAX = 24
/** 一条挑战最多几条规则、一道题一种语言最多几条挑战 */
export const CHALLENGE_RULES_MAX = 4
export const CHALLENGES_PER_LANGUAGE_MAX = 4
/** 我做到了一条挑战:本班、全站各是第几个做到的 */
export const challengeMineSchema = z.object({
at: z.string(),
siteRank: z.number().int(),
/** 没有班级(老师、自己注册的号)时为 null */
classRank: z.number().int().nullable(),
})
/** 下发给学生的一条挑战:只有说法和人数,规则原文不出去(同 astRequirements) */
export const challengeItemSchema = z.object({
id: z.string(),
wording: z.string(),
/** 全站做到的人数 */
doneCount: z.number().int(),
mine: challengeMineSchema.nullable(),
})
/** GET /challenges:几道题、某种语言下的挑战 */
export const problemChallengeGroupSchema = z.object({
problemId: z.string(),
title: z.string(),
/** 这种语言做对过这道题的人数(「642 个做对的人里 62 人做到」的 642) */
acCount: z.number().int(),
/** 我用这种语言做对过没有:没做对的挑战不给接 */
solved: z.boolean(),
challenges: z.array(challengeItemSchema),
})
export const challengeListSchema = z.array(problemChallengeGroupSchema)
export const problemDetailSchema = z.object({ export const problemDetailSchema = z.object({
id: z.number().int(), id: z.number().int(),
_id: z.string(), _id: z.string(),
@@ -555,6 +611,12 @@ export type AstRule = z.infer<typeof astRuleSchema>
export type AstRules = z.infer<typeof astRulesSchema> export type AstRules = z.infer<typeof astRulesSchema>
export type AstRequirement = z.infer<typeof astRequirementSchema> export type AstRequirement = z.infer<typeof astRequirementSchema>
export type AstRequirements = z.infer<typeof astRequirementsSchema> export type AstRequirements = z.infer<typeof astRequirementsSchema>
export type Challenge = z.infer<typeof challengeSchema>
export type ProblemChallenges = z.infer<typeof problemChallengesSchema>
export type ChallengeMine = z.infer<typeof challengeMineSchema>
export type ChallengeItem = z.infer<typeof challengeItemSchema>
export type ProblemChallengeGroup = z.infer<typeof problemChallengeGroupSchema>
export type ChallengeList = z.infer<typeof challengeListSchema>
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema> export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
export type ProblemListItem = z.infer<typeof problemListItemSchema> export type ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema> export type ProblemList = z.infer<typeof problemListSchema>
+22 -4
View File
@@ -51,6 +51,19 @@ export interface JudgeInfo {
data: JudgeCaseResult[] | null data: JudgeCaseResult[] | null
} }
/** 一条 AST 规则的判定结果,judge/ast.ts 的 checkAst 产出 */
export const astResultSchema = z.object({
description: z.string(),
passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
actual: z.number().optional(),
/**
* 没过的「不能用 X」「X 最多 N 次」:X 出现在学生代码的第几行(1 起,已扣掉题目模板的
* 前置代码)。2026-10 加的,之前的提交没有
*/
lines: z.array(z.number().int()).optional(),
})
/** /**
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。 * 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
* *
@@ -67,13 +80,17 @@ export const statisticInfoSchema = z.looseObject({
err_info: z.string().optional(), err_info: z.string().optional(),
time_cost: z.number().optional(), time_cost: z.number().optional(),
memory_cost: z.number().optional(), memory_cost: z.number().optional(),
ast_results: z ast_results: z.array(astResultSchema).optional(),
/**
* 做对的提交拿这道题的每条挑战(problem.challenges 里这种语言的)各判一遍,
* judge/run.ts 写。比赛提交不判。前端在挑战中时按 id 取它正在挑战的那条
*/
challenges: z
.array( .array(
z.object({ z.object({
description: z.string(), id: z.string(),
passed: z.boolean(), passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */ results: z.array(astResultSchema),
actual: z.number().optional(),
}), }),
) )
.optional(), .optional(),
@@ -616,6 +633,7 @@ export type TrialRunResponse =
| { status: "too-much-output" } | { status: "too-much-output" }
export type StatisticInfo = z.infer<typeof statisticInfoSchema> export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type AstResultInfo = z.infer<typeof astResultSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema> export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema> export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema> export type SubmissionDetail = z.infer<typeof submissionDetailSchema>