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:
1 parent
cc1361e596
commit
0faf78ad8f
17 files changed
+9057
-37
No files matched your search
@@ -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");
|
||||
@@ -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
@@ -267,6 +267,20 @@
|
||||
"when": 1792494707274,
|
||||
"tag": "0037_add_user_token_version",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
HintDiagnosis,
|
||||
JudgeStatus,
|
||||
LessonLanguage,
|
||||
ProblemChallenges,
|
||||
ProblemDifficulty,
|
||||
ProblemLanguage,
|
||||
ProblemPermission,
|
||||
@@ -570,6 +571,8 @@ export const problem = pgTable(
|
||||
mermaidCode: text("mermaid_code"),
|
||||
showFlowchart: boolean("show_flowchart").default(false).notNull(),
|
||||
astRules: jsonb("ast_rules").$type<AstRules>(),
|
||||
/** 做对之后的挑战,按语言分组。写入侧在 admin/problem.ts 校验,见契约 challengeSchema */
|
||||
challenges: jsonb().$type<ProblemChallenges>(),
|
||||
sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
|
||||
sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
|
||||
},
|
||||
@@ -1467,3 +1470,59 @@ export const classLesson = pgTable(
|
||||
}).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"),
|
||||
],
|
||||
)
|
||||
@@ -22,6 +22,7 @@ import { problemStatsRoutes } from "./routes/problem-stats"
|
||||
import { submissionRoutes } from "./routes/submission"
|
||||
import { trialRunRoutes } from "./routes/trial-run"
|
||||
import { siteRoutes } from "./routes/site"
|
||||
import { scanPendingChallenges } from "./services/challenge"
|
||||
import {
|
||||
bridgeSubmissionEvents,
|
||||
isAllowedWebSocketOrigin,
|
||||
@@ -165,3 +166,6 @@ const server = Bun.serve<SubmissionSocketData>({
|
||||
await bridgeSubmissionEvents(server)
|
||||
startSessionSweep()
|
||||
console.log(`OJ2 API listening on http://localhost:${server.port}`)
|
||||
// 迁移刚灌进来、或者上次没补完的挑战:拿历史上做对的代码补一遍谁已经做到了。
|
||||
// 晚一点再跑,别和刚起来时涌进来的请求抢那两个核
|
||||
setTimeout(() => void scanPendingChallenges(), 20_000)
|
||||
+89
-27
@@ -30,6 +30,18 @@ export interface AstResult {
|
||||
passed: boolean
|
||||
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
|
||||
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
|
||||
@@ -266,47 +278,60 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
|
||||
description: describeAstRule(rule, language),
|
||||
passed: hasNode(root, nodeType),
|
||||
}
|
||||
case "must_not_exist_node":
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: !hasNode(root, nodeType),
|
||||
}
|
||||
case "must_not_exist_node": {
|
||||
const nodes = collectNodes(root, nodeType)
|
||||
return withLines(
|
||||
{ description: describeAstRule(rule, language), passed: nodes.length === 0 },
|
||||
nodes,
|
||||
)
|
||||
}
|
||||
case "count_node": {
|
||||
const count = collectNodes(root, nodeType).length
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: rangePassed(count, rule),
|
||||
actual: count,
|
||||
}
|
||||
const nodes = collectNodes(root, nodeType)
|
||||
return withLines(
|
||||
{
|
||||
description: describeAstRule(rule, language),
|
||||
passed: rangePassed(nodes.length, rule),
|
||||
actual: nodes.length,
|
||||
},
|
||||
// 次数太少时没有「哪几行」可标
|
||||
rule.max !== undefined || rule.exact !== undefined ? nodes : [],
|
||||
)
|
||||
}
|
||||
case "must_call_function":
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: functionCalls(root, target, language).length > 0,
|
||||
}
|
||||
case "must_not_call_function":
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: functionCalls(root, target, language).length === 0,
|
||||
}
|
||||
case "must_not_call_function": {
|
||||
const calls = functionCalls(root, target, language)
|
||||
return withLines(
|
||||
{ description: describeAstRule(rule, language), passed: calls.length === 0 },
|
||||
calls,
|
||||
)
|
||||
}
|
||||
case "count_function_call": {
|
||||
const count = functionCalls(root, target, language).length
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: rangePassed(count, rule),
|
||||
actual: count,
|
||||
}
|
||||
const calls = functionCalls(root, target, language)
|
||||
return withLines(
|
||||
{
|
||||
description: describeAstRule(rule, language),
|
||||
passed: rangePassed(calls.length, rule),
|
||||
actual: calls.length,
|
||||
},
|
||||
rule.max !== undefined || rule.exact !== undefined ? calls : [],
|
||||
)
|
||||
}
|
||||
case "must_call_method":
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: methodCalls(root, target, language).length > 0,
|
||||
}
|
||||
case "must_not_call_method":
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
passed: methodCalls(root, target, language).length === 0,
|
||||
}
|
||||
case "must_not_call_method": {
|
||||
const calls = methodCalls(root, target, language)
|
||||
return withLines(
|
||||
{ description: describeAstRule(rule, language), passed: calls.length === 0 },
|
||||
calls,
|
||||
)
|
||||
}
|
||||
case "must_use_operator":
|
||||
return {
|
||||
description: describeAstRule(rule, language),
|
||||
@@ -357,3 +382,40 @@ export async function checkAst(
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { publishAchievementNotification } from "../events"
|
||||
import { updateAchievementsForSubmission } from "../services/achievements"
|
||||
import {
|
||||
challengesFor,
|
||||
judgeChallenges,
|
||||
recordChallengeSuccesses,
|
||||
type ChallengeVerdict,
|
||||
} from "../services/challenge"
|
||||
import { recordSolvedAndNotify } from "../services/problemset"
|
||||
import { checkAst, type AstRule } from "./ast"
|
||||
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)
|
||||
@@ -722,6 +741,22 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
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 {
|
||||
const unlocked = await updateAchievementsForSubmission(row.submission.id, counted)
|
||||
await publishAchievementNotification(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addContestProblemRequestSchema,
|
||||
reorderContestProblemsRequestSchema,
|
||||
astCheckRequestSchema,
|
||||
challengeSuggestionsRequestSchema,
|
||||
generateTestInputsRequestSchema,
|
||||
isSqlProblem,
|
||||
TEST_CASE_EDIT_MAX_CASES,
|
||||
@@ -18,8 +19,10 @@ import {
|
||||
type AdminProblemListItem,
|
||||
type AstCheckResponse,
|
||||
type AstRules,
|
||||
type ChallengeSuggestionsResponse,
|
||||
type GenerateTestInputsResponse,
|
||||
type GenerateSqlTestCaseResponse,
|
||||
type ProblemChallenges,
|
||||
type SqlConfig,
|
||||
type SqlDisplay,
|
||||
type SqlTestCaseScript,
|
||||
@@ -35,6 +38,12 @@ import { failure, parseBody, success } from "../../http"
|
||||
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
|
||||
import { buildSqlDisplay } from "../../judge/sql"
|
||||
import { completeChat } from "../../services/ai"
|
||||
import {
|
||||
challengesFor,
|
||||
challengeSuggestions,
|
||||
normalizeChallenges,
|
||||
scanPendingChallenges,
|
||||
} from "../../services/challenge"
|
||||
import { plainText } from "../../services/hint-diagnosis"
|
||||
import { contestStatus } from "../../services/contest"
|
||||
import {
|
||||
@@ -152,6 +161,7 @@ async function serialize(row: ProblemRow) {
|
||||
flowchartData: asRecord(row.flowchartData),
|
||||
flowchartHint: row.flowchartHint,
|
||||
astRules: row.astRules,
|
||||
challenges: row.challenges,
|
||||
answers: Array.isArray(row.answers) ? row.answers : [],
|
||||
prompt: row.prompt,
|
||||
sqlConfig: row.sqlConfig,
|
||||
@@ -271,19 +281,34 @@ type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
|
||||
*/
|
||||
async function validateProblem(
|
||||
data: ProblemInput,
|
||||
): Promise<{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }> {
|
||||
previousChallenges: ProblemChallenges | null = null,
|
||||
): Promise<{ error: string } | Validated> {
|
||||
const checked = commonChecks(data)
|
||||
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!)
|
||||
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)连同标签,一个事务 */
|
||||
async function insertProblem(
|
||||
data: ProblemInput,
|
||||
validated: { sql: boolean; sqlDisplay: SqlDisplay | null },
|
||||
validated: Validated,
|
||||
owner: { contestId: number | null; createdById: number },
|
||||
) {
|
||||
const now = new Date().toISOString()
|
||||
@@ -292,6 +317,7 @@ async function insertProblem(
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...problemValues(data, validated.sql),
|
||||
challenges: validated.challenges,
|
||||
...owner,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
@@ -489,6 +515,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
contestId: null,
|
||||
createdById: c.get("user")!.id,
|
||||
})
|
||||
void scanPendingChallenges()
|
||||
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))) {
|
||||
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)
|
||||
|
||||
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
|
||||
@@ -529,6 +556,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
.update(schema.problem)
|
||||
.set({
|
||||
...problemValues(parsed.data, validated.sql),
|
||||
challenges: validated.challenges,
|
||||
sqlDisplay: validated.sqlDisplay,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
})
|
||||
@@ -537,6 +565,8 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
await setTags(tx, id, parsed.data.tags)
|
||||
return row!
|
||||
})
|
||||
// 新加、改过的挑战拿历史上做对的代码补一遍谁已经做到了,不等它
|
||||
void scanPendingChallenges()
|
||||
return success(c, await serialize(updated))
|
||||
})
|
||||
|
||||
@@ -962,6 +992,32 @@ adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => {
|
||||
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 编的输出不可信。
|
||||
|
||||
@@ -20,6 +20,7 @@ import { seenToday } from "../auth/presence"
|
||||
import { getLoginWindow } from "../auth/session"
|
||||
import { presenceOf } from "../collab/state"
|
||||
import { db, schema } from "../db"
|
||||
import { challengeSuccessesOf } from "../services/challenge"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { isAccepted } from "../judge/status"
|
||||
import {
|
||||
@@ -465,6 +466,27 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
||||
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
||||
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
||||
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([
|
||||
ids.length && userIds.length
|
||||
? db
|
||||
@@ -619,6 +641,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
||||
problemDisplayId: problem.displayId,
|
||||
title: problem.title,
|
||||
isSql: problem.isSql,
|
||||
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
|
||||
})),
|
||||
students: roster.map(
|
||||
(student) =>
|
||||
@@ -659,6 +682,14 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
||||
}))
|
||||
: [],
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
AST_SUPPORTED_LANGUAGES,
|
||||
isSqlProblem,
|
||||
normalizeLanguage,
|
||||
problemListSortSchema,
|
||||
problemTypeFilterSchema,
|
||||
type ChallengeList,
|
||||
type ProblemAuthor,
|
||||
type ProblemDetail,
|
||||
type ProblemList,
|
||||
@@ -28,9 +30,10 @@ import {
|
||||
} from "drizzle-orm"
|
||||
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 { astRequirements } from "../judge/ast"
|
||||
import { challengeList } from "../services/challenge"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import {
|
||||
@@ -340,6 +343,21 @@ problemRoutes.get("/problem-authors", async (c) => {
|
||||
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) => {
|
||||
const [target] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { problemLanguageSchema, runnableLanguageSchema } from "./language"
|
||||
import {
|
||||
astRuleSchema,
|
||||
astRulesSchema,
|
||||
CHALLENGE_RULES_MAX,
|
||||
CHALLENGE_WORDING_MAX,
|
||||
CHALLENGES_PER_LANGUAGE_MAX,
|
||||
problemChallengesSchema,
|
||||
problemDifficultySchema,
|
||||
sqlConfigSchema,
|
||||
sqlDisplaySchema,
|
||||
@@ -945,6 +949,7 @@ export const adminProblemSchema = z.object({
|
||||
flowchartData: z.record(z.string(), z.unknown()),
|
||||
flowchartHint: z.string().nullable(),
|
||||
astRules: astRulesSchema.nullable(),
|
||||
challenges: problemChallengesSchema.nullable(),
|
||||
answers: z.array(problemAnswerSchema),
|
||||
prompt: z.string().nullable(),
|
||||
sqlConfig: sqlConfigSchema.nullable(),
|
||||
@@ -963,6 +968,21 @@ export const adminProblemSchema = z.object({
|
||||
*/
|
||||
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({
|
||||
_id: z.string().trim().min(1).max(32),
|
||||
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),
|
||||
flowchartHint: z.string().nullable().default(null),
|
||||
astRules: astRulesSchema.nullable().default(null),
|
||||
/**
|
||||
* 不带这个键 = 不动原来的挑战。上线那一刻浏览器里还开着旧版出题页,它保存时不带挑战,
|
||||
* 给个默认 null 的话一保存就把挑战清了;null 才是「清空」
|
||||
*/
|
||||
challenges: challengeInputsSchema.nullable().optional(),
|
||||
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
|
||||
// `BooleanField(default=False)` 一致
|
||||
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 编) */
|
||||
export const generateTestInputsRequestSchema = z.object({
|
||||
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 AstCheckRequest = z.input<typeof astCheckRequestSchema>
|
||||
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 GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ export const classBoardProblemSchema = z.object({
|
||||
title: z.string(),
|
||||
/** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */
|
||||
isSql: z.boolean(),
|
||||
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
|
||||
hasChallenges: z.boolean(),
|
||||
})
|
||||
|
||||
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() })),
|
||||
/** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */
|
||||
acceptedTry: z.number().int().nullable(),
|
||||
/**
|
||||
* 挑战:done = 做到了这道题的一条挑战(不限今天);doing = 做对之后今天又交了、还没做到 ——
|
||||
* 做完了的学生是在挑战还是闲着,老师一眼看得出。没配挑战的题、没做对的格子为 null
|
||||
*/
|
||||
challenge: z.enum(["done", "doing"]).nullable(),
|
||||
})
|
||||
|
||||
export const classBoardStudentSchema = z.object({
|
||||
|
||||
@@ -355,6 +355,62 @@ export const astRequirementSchema = z.object({
|
||||
/** 按语言分组,与 astRulesSchema 同一套键 */
|
||||
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({
|
||||
id: z.number().int(),
|
||||
_id: z.string(),
|
||||
@@ -555,6 +611,12 @@ 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 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 ProblemListItem = z.infer<typeof problemListItemSchema>
|
||||
export type ProblemList = z.infer<typeof problemListSchema>
|
||||
|
||||
@@ -51,6 +51,19 @@ export interface JudgeInfo {
|
||||
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 原文)。
|
||||
*
|
||||
@@ -67,13 +80,17 @@ export const statisticInfoSchema = z.looseObject({
|
||||
err_info: z.string().optional(),
|
||||
time_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(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
passed: z.boolean(),
|
||||
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
|
||||
actual: z.number().optional(),
|
||||
results: z.array(astResultSchema),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
@@ -616,6 +633,7 @@ export type TrialRunResponse =
|
||||
| { status: "too-much-output" }
|
||||
|
||||
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
|
||||
export type AstResultInfo = z.infer<typeof astResultSchema>
|
||||
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
|
||||
export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema>
|
||||
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
|
||||
|
||||
Reference in new issue
Block a user