diff --git a/apps/api/src/db/0031_tutorial_project_and_problems.sql b/apps/api/src/db/0031_tutorial_project_and_problems.sql new file mode 100644 index 0000000..5ebed10 --- /dev/null +++ b/apps/api/src/db/0031_tutorial_project_and_problems.sql @@ -0,0 +1,15 @@ +CREATE TABLE "tutorial_problem" ( + "tutorial_id" integer NOT NULL, + "problem_id" integer NOT NULL, + "order" integer NOT NULL, + CONSTRAINT "tutorial_problem_pkey" PRIMARY KEY("tutorial_id","problem_id") +); +--> statement-breakpoint +ALTER TABLE "tutorial" ADD COLUMN "project" varchar(64) DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "tutorial_problem" ADD CONSTRAINT "tutorial_problem_tutorial_id_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tutorial_problem" ADD CONSTRAINT "tutorial_problem_problem_id_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +-- 项目原来只写在正文第一段(「**所属项目:项目三 · 智慧农业·蜜橘园**」),抽出来回填。 +-- 没写的(9 篇没公开的老 Python 教程)留空,自学目录里归到「其他」 +UPDATE "tutorial" +SET "project" = left(trim(substring("content" from '\*\*所属项目[::]\s*([^*\n]+)\*\*')), 64) +WHERE "content" ~ '\*\*所属项目[::]'; diff --git a/apps/api/src/db/meta/0031_snapshot.json b/apps/api/src/db/meta/0031_snapshot.json new file mode 100644 index 0000000..4c87070 --- /dev/null +++ b/apps/api/src/db/meta/0031_snapshot.json @@ -0,0 +1,3981 @@ +{ + "id": "d904b7a3-5c07-4d9c-bd14-9683c64a4dfe", + "prevId": "fa4d1f59-fde6-4b77-a8af-5e1d70000a74", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.achievement": { + "name": "achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "unlock_count": { + "name": "unlock_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.acm_contest_rank": { + "name": "acm_contest_rank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_time": { + "name": "total_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_info": { + "name": "submission_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "acm_rank_contest_user_idx": { + "name": "acm_rank_contest_user_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "acm_rank_order_idx": { + "name": "acm_rank_order_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "accepted_number", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "total_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "acm_contest_rank_contest_id_21030ccd_fk_contest_id": { + "name": "acm_contest_rank_contest_id_21030ccd_fk_contest_id", + "tableFrom": "acm_contest_rank", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "acm_contest_rank_user_id_40391ab2_fk_user_id": { + "name": "acm_contest_rank_user_id_40391ab2_fk_user_id", + "tableFrom": "acm_contest_rank", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_acm_rank_user_contest": { + "name": "unique_acm_rank_user_contest", + "nullsNotDistinct": false, + "columns": [ + "contest_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_analysis": { + "name": "ai_analysis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_analysis_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_prompt": { + "name": "user_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "analysis": { + "name": "analysis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_analysis_user_id_3aa23011": { + "name": "ai_analysis_user_id_3aa23011", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_analysis_user_id_3aa23011_fk_user_id": { + "name": "ai_analysis_user_id_3aa23011_fk_user_id", + "tableFrom": "ai_analysis", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_hint": { + "name": "ai_hint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_hint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "diagnosis": { + "name": "diagnosis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "diagnosis_error": { + "name": "diagnosis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_attempt": { + "name": "filter_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_blocked": { + "name": "filter_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "filter_reason": { + "name": "filter_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "helpful": { + "name": "helpful", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "feedback_time": { + "name": "feedback_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_hint_submission_id_idx": { + "name": "ai_hint_submission_id_idx", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_hint_submission_id_fk_submission_id": { + "name": "ai_hint_submission_id_fk_submission_id", + "tableFrom": "ai_hint", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement": { + "name": "announcement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "top": { + "name": "top", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_created_by_id_359ccf50": { + "name": "announcement_created_by_id_359ccf50", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_list_idx": { + "name": "announcement_list_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "top", + "isExpression": false, + "asc": false, + "nulls": "first" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_created_by_id_359ccf50_fk_user_id": { + "name": "announcement_created_by_id_359ccf50_fk_user_id", + "tableFrom": "announcement", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.class_lesson": { + "name": "class_lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "class_lesson_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problem_ids": { + "name": "problem_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "class_lesson_created_by_idx": { + "name": "class_lesson_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "class_lesson_created_by_fk_user_id": { + "name": "class_lesson_created_by_fk_user_id", + "tableFrom": "class_lesson", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "class_lesson_class_day_unique": { + "name": "class_lesson_class_day_unique", + "nullsNotDistinct": false, + "columns": [ + "class_name", + "day" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contest": { + "name": "contest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contest_created_by_id_a763ca7e": { + "name": "contest_created_by_id_a763ca7e", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contest_created_by_id_a763ca7e_fk_user_id": { + "name": "contest_created_by_id_a763ca7e_fk_user_id", + "tableFrom": "contest", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise": { + "name": "exercise", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "exercise_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "exercise_tutorial_id_6fd04055": { + "name": "exercise_tutorial_id_6fd04055", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_tutorial_id_6fd04055_fk_tutorial_id": { + "name": "exercise_tutorial_id_6fd04055_fk_tutorial_id", + "tableFrom": "exercise", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise_attempt": { + "name": "exercise_attempt", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "exercise_id": { + "name": "exercise_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wrong_attempts": { + "name": "wrong_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "solved": { + "name": "solved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts_to_solve": { + "name": "attempts_to_solve", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_wrong_answer": { + "name": "last_wrong_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_attempt_at": { + "name": "first_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "solved_at": { + "name": "solved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exercise_attempt_exercise_id_idx": { + "name": "exercise_attempt_exercise_id_idx", + "columns": [ + { + "expression": "exercise_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_attempt_user_id_fk_user_id": { + "name": "exercise_attempt_user_id_fk_user_id", + "tableFrom": "exercise_attempt", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exercise_attempt_exercise_id_fk_exercise_id": { + "name": "exercise_attempt_exercise_id_fk_exercise_id", + "tableFrom": "exercise_attempt", + "tableTo": "exercise", + "columnsFrom": [ + "exercise_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exercise_attempt_pkey": { + "name": "exercise_attempt_pkey", + "columns": [ + "user_id", + "exercise_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flowchart_submission": { + "name": "flowchart_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ai_score": { + "name": "ai_score", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "ai_grade": { + "name": "ai_grade", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "ai_feedback": { + "name": "ai_feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_suggestions": { + "name": "ai_suggestions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_criteria_details": { + "name": "ai_criteria_details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ai_provider": { + "name": "ai_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ai_model": { + "name": "ai_model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "processing_time": { + "name": "processing_time", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "evaluation_time": { + "name": "evaluation_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "flowchart_problem_time_idx": { + "name": "flowchart_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_status_idx": { + "name": "flowchart_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_create_time_idx": { + "name": "flowchart_create_time_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_user_time_idx": { + "name": "flowchart_user_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flowchart_submission_problem_id_8551edbf_fk_problem_id": { + "name": "flowchart_submission_problem_id_8551edbf_fk_problem_id", + "tableFrom": "flowchart_submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "flowchart_submission_user_id_225c83e8_fk_user_id": { + "name": "flowchart_submission_user_id_225c83e8_fk_user_id", + "tableFrom": "flowchart_submission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.judge_server": { + "name": "judge_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judger_version": { + "name": "judger_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cpu_core": { + "name": "cpu_core", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_usage": { + "name": "memory_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cpu_usage": { + "name": "cpu_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "task_number": { + "name": "task_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "message_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_recipient_time_idx": { + "name": "message_recipient_time_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_sender_id_a2a2e825": { + "name": "message_sender_id_a2a2e825", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_submission_id_2fdf8a47": { + "name": "message_submission_id_2fdf8a47", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_recipient_id_2aa5dd76_fk_user_id": { + "name": "message_recipient_id_2aa5dd76_fk_user_id", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_sender_id_a2a2e825_fk_user_id": { + "name": "message_sender_id_a2a2e825_fk_user_id", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_submission_id_2fdf8a47_fk_submission_id": { + "name": "message_submission_id_2fdf8a47_fk_submission_id", + "tableFrom": "message", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options_sysoptions": { + "name": "options_sysoptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "options_sysoptions_key_key": { + "name": "options_sysoptions_key_key", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem": { + "name": "problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_description": { + "name": "input_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_description": { + "name": "output_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "samples": { + "name": "samples", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "test_case_id": { + "name": "test_case_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_case_score": { + "name": "test_case_score", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_limit": { + "name": "time_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_limit": { + "name": "memory_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submission_number": { + "name": "submission_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_id": { + "name": "_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_submission": { + "name": "share_submission", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allow_flowchart": { + "name": "allow_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "flowchart_hint": { + "name": "flowchart_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "show_flowchart": { + "name": "show_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ast_rules": { + "name": "ast_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_config": { + "name": "sql_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_display": { + "name": "sql_display", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problem_contest_visible_idx": { + "name": "problem_contest_visible_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_created_by_id_cb362143": { + "name": "problem_created_by_id_cb362143", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_visible_idx": { + "name": "problem_visible_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_contest_id_328e013a_fk_contest_id": { + "name": "problem_contest_id_328e013a_fk_contest_id", + "tableFrom": "problem", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "problem_created_by_id_cb362143_fk_user_id": { + "name": "problem_created_by_id_cb362143_fk_user_id", + "tableFrom": "problem", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problem_id_contest": { + "name": "unique_problem_id_contest", + "nullsNotDistinct": false, + "columns": [ + "_id", + "contest_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tag": { + "name": "problem_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'knowledge'" + }, + "category_confirmed": { + "name": "category_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "problem_tag_name_ci_unique": { + "name": "problem_tag_name_ci_unique", + "columns": [ + { + "expression": "lower(name)", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tags": { + "name": "problem_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemtag_id": { + "name": "problemtag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problem_tags_problemtag_id_72d20571": { + "name": "problem_tags_problemtag_id_72d20571", + "columns": [ + { + "expression": "problemtag_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_tags_problem_id_866ecb8d_fk_problem_id": { + "name": "problem_tags_problem_id_866ecb8d_fk_problem_id", + "tableFrom": "problem_tags", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problem_tags_problemtag_id_72d20571_fk_problem_tag_id": { + "name": "problem_tags_problemtag_id_72d20571_fk_problem_tag_id", + "tableFrom": "problem_tags", + "tableTo": "problem_tag", + "columnsFrom": [ + "problemtag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "problem_tags_problem_id_problemtag_id_318459d1_uniq": { + "name": "problem_tags_problem_id_problemtag_id_318459d1_uniq", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemtag_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset": { + "name": "problemset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problemset_created_by_id_01b5197f": { + "name": "problemset_created_by_id_01b5197f", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_created_by_id_01b5197f_fk_user_id": { + "name": "problemset_created_by_id_01b5197f_fk_user_id", + "tableFrom": "problemset", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_badge": { + "name": "problemset_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_value": { + "name": "condition_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_badge_problemset_id_6cb6c74f": { + "name": "problemset_badge_problemset_id_6cb6c74f", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id": { + "name": "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id", + "tableFrom": "problemset_badge", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_problem": { + "name": "problemset_problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_problem_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_problem_problem_id_fff2d686": { + "name": "problemset_problem_problem_id_fff2d686", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_problem_problem_id_fff2d686_fk_problem_id": { + "name": "problemset_problem_problem_id_fff2d686_fk_problem_id", + "tableFrom": "problemset_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_problem_problemset_id_350d17fb_fk_problemset_id": { + "name": "problemset_problem_problemset_id_350d17fb_fk_problemset_id", + "tableFrom": "problemset_problem", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_problem": { + "name": "unique_problemset_problem", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemset_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_progress": { + "name": "problemset_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_progress_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "join_time": { + "name": "join_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "complete_time": { + "name": "complete_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "completed_problems_count": { + "name": "completed_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_problems_count": { + "name": "total_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress_detail": { + "name": "progress_detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_progress_user_id_c8041a80": { + "name": "problemset_progress_user_id_c8041a80", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_progress_problemset_id_20a9632e_fk_problemset_id": { + "name": "problemset_progress_problemset_id_20a9632e_fk_problemset_id", + "tableFrom": "problemset_progress", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_progress_user_id_c8041a80_fk_user_id": { + "name": "problemset_progress_user_id_c8041a80_fk_user_id", + "tableFrom": "problemset_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_progress_user": { + "name": "unique_problemset_progress_user", + "nullsNotDistinct": false, + "columns": [ + "problemset_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reaction": { + "name": "reaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "reaction_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reaction_problem_type_idx": { + "name": "reaction_problem_type_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_user_id_cfa7f469": { + "name": "reaction_user_id_cfa7f469", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reaction_problem_id_a7f3b9f3_fk_problem_id": { + "name": "reaction_problem_id_a7f3b9f3_fk_problem_id", + "tableFrom": "reaction", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_user_id_cfa7f469_fk_user_id": { + "name": "reaction_user_id_cfa7f469_fk_user_id", + "tableFrom": "reaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reaction_problem_user_unique": { + "name": "reaction_problem_user_unique", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission": { + "name": "submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "info": { + "name": "info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contest_create_time_idx": { + "name": "contest_create_time_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_create_time_id_idx": { + "name": "submission_public_create_time_id_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_user_idx": { + "name": "problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_37e2f67a": { + "name": "submission_result_37e2f67a", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_create_time_idx": { + "name": "user_create_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_language_time_idx": { + "name": "submission_language_time_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_time_idx": { + "name": "submission_result_time_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_time_idx": { + "name": "submission_public_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_username_trgm_idx": { + "name": "submission_public_username_trgm_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "gin", + "with": {} + }, + "submission_public_metrics_idx": { + "name": "submission_public_metrics_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_user_idx": { + "name": "submission_public_problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_problemset_id_idx": { + "name": "submission_problemset_id_idx", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"problemset_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_contest_id_775716d5_fk_contest_id": { + "name": "submission_contest_id_775716d5_fk_contest_id", + "tableFrom": "submission", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problem_id_76847b55_fk_problem_id": { + "name": "submission_problem_id_76847b55_fk_problem_id", + "tableFrom": "submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problemset_id_fk_problemset_id": { + "name": "submission_problemset_id_fk_problemset_id", + "tableFrom": "submission", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_trace": { + "name": "submission_trace", + "schema": "", + "columns": { + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active_ms": { + "name": "active_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "since_open_ms": { + "name": "since_open_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "typed_chars": { + "name": "typed_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pasted_chars": { + "name": "pasted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paste_count": { + "name": "paste_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_paste": { + "name": "max_paste", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_chars": { + "name": "deleted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blur_count": { + "name": "blur_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "initial_len": { + "name": "initial_len", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collab": { + "name": "collab", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "since_prev_ms": { + "name": "since_prev_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "submission_trace_submission_id_fk_submission_id": { + "name": "submission_trace_submission_id_fk_submission_id", + "tableFrom": "submission_trace", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial": { + "name": "tutorial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "tutorial_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "project": { + "name": "project", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": { + "tutorial_created_by_id_07973cab": { + "name": "tutorial_created_by_id_07973cab", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_created_by_id_07973cab_fk_user_id": { + "name": "tutorial_created_by_id_07973cab_fk_user_id", + "tableFrom": "tutorial", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_problem": { + "name": "tutorial_problem", + "schema": "", + "columns": { + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tutorial_problem_tutorial_id_fk_tutorial_id": { + "name": "tutorial_problem_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_problem", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_problem_problem_id_fk_problem_id": { + "name": "tutorial_problem_problem_id_fk_problem_id", + "tableFrom": "tutorial_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_problem_pkey": { + "name": "tutorial_problem_pkey", + "columns": [ + "tutorial_id", + "problem_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_progress": { + "name": "tutorial_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_viewed_at": { + "name": "first_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tutorial_progress_tutorial_id_idx": { + "name": "tutorial_progress_tutorial_id_idx", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_progress_user_id_fk_user_id": { + "name": "tutorial_progress_user_id_fk_user_id", + "tableFrom": "tutorial_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_progress_tutorial_id_fk_tutorial_id": { + "name": "tutorial_progress_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_progress", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_progress_pkey": { + "name": "tutorial_progress_pkey", + "columns": [ + "user_id", + "tutorial_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "last_login": { + "name": "last_login", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "admin_type": { + "name": "admin_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "problem_permission": { + "name": "problem_permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_password": { + "name": "raw_password", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rank_hidden_at": { + "name": "rank_hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_active_idx": { + "name": "user_active_idx", + "columns": [ + { + "expression": "is_disabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_login", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_class_name_idx": { + "name": "user_class_name_idx", + "columns": [ + { + "expression": "class_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_key": { + "name": "user_username_key", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_achievement": { + "name": "user_achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "unlock_time": { + "name": "unlock_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "backfilled": { + "name": "backfilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "achievement_id": { + "name": "achievement_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_achievement_achievement_id_29db600d": { + "name": "user_achievement_achievement_id_29db600d", + "columns": [ + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_notified_idx": { + "name": "user_achv_notified_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "notified", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_time_idx": { + "name": "user_achv_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "unlock_time", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievement_achievement_id_29db600d_fk_achievement_id": { + "name": "user_achievement_achievement_id_29db600d_fk_achievement_id", + "tableFrom": "user_achievement", + "tableTo": "achievement", + "columnsFrom": [ + "achievement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_achievement_user_id_b8ec7d6a_fk_user_id": { + "name": "user_achievement_user_id_b8ec7d6a_fk_user_id", + "tableFrom": "user_achievement", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_achievement": { + "name": "unique_user_achievement", + "nullsNotDistinct": false, + "columns": [ + "achievement_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_badge": { + "name": "user_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "earned_time": { + "name": "earned_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "badge_id": { + "name": "badge_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_badge_badge_id_92a983e9": { + "name": "user_badge_badge_id_92a983e9", + "columns": [ + { + "expression": "badge_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_badge_badge_id_92a983e9_fk_problemset_badge_id": { + "name": "user_badge_badge_id_92a983e9_fk_problemset_badge_id", + "tableFrom": "user_badge", + "tableTo": "problemset_badge", + "columnsFrom": [ + "badge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_badge_user_id_a286d718_fk_user_id": { + "name": "user_badge_user_id_a286d718_fk_user_id", + "tableFrom": "user_badge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_badge": { + "name": "unique_user_badge", + "nullsNotDistinct": false, + "columns": [ + "badge_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "acm_problems_status": { + "name": "acm_problems_status", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_8fdce8e2_fk_user_id": { + "name": "user_profile_user_id_8fdce8e2_fk_user_id", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_key": { + "name": "user_profile_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stat": { + "name": "user_stat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_stat_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "update_time": { + "name": "update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_stat_user_id_73337fc0_fk_user_id": { + "name": "user_stat_user_id_73337fc0_fk_user_id", + "tableFrom": "user_stat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stat_user_id_key": { + "name": "user_stat_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/meta/_journal.json b/apps/api/src/db/meta/_journal.json index 87edb6b..2da851c 100644 --- a/apps/api/src/db/meta/_journal.json +++ b/apps/api/src/db/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1791587559546, "tag": "0030_add_user_rank_hidden", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1791766110876, + "tag": "0031_tutorial_project_and_problems", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 3c5ff10..0dc1edd 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1042,6 +1042,9 @@ export const tutorial = pgTable( createdById: integer("created_by_id").notNull(), code: text(), type: varchar({ length: 10 }).notNull().$type(), + // 所属项目(「项目三 · 智慧农业·蜜橘园」),自学目录按它分组。0031 从正文第一段的 + // 「**所属项目:…**」回填;发布脚本不传它,后端照样从正文里抽,见 admin/tutorial.ts + project: varchar({ length: 64 }).default("").notNull(), }, (table) => [ index("tutorial_created_by_id_07973cab").using( @@ -1056,6 +1059,37 @@ export const tutorial = pgTable( ], ) +/** + * 一课挂的「去做几道题」:老师在后台手挑的题目,按 `order` 排。 + * + * 两头都 CASCADE:教程删了这串题就没意义了;题目删了,教程少挂一道题也不是学生的 + * 做题证据(证据在 submission 那边,它对 problem 是拦住的)。 + */ +export const tutorialProblem = pgTable( + "tutorial_problem", + { + tutorialId: integer("tutorial_id").notNull(), + problemId: integer("problem_id").notNull(), + order: integer().notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.tutorialId, table.problemId], + name: "tutorial_problem_pkey", + }), + foreignKey({ + columns: [table.tutorialId], + foreignColumns: [tutorial.id], + name: "tutorial_problem_tutorial_id_fk_tutorial_id", + }).onDelete("cascade"), + foreignKey({ + columns: [table.problemId], + foreignColumns: [problem.id], + name: "tutorial_problem_problem_id_fk_problem_id", + }).onDelete("cascade"), + ], +) + export const userStat = pgTable( "user_stat", { diff --git a/apps/api/src/routes/admin/learn.ts b/apps/api/src/routes/admin/learn.ts index db8ac56..a12584d 100644 --- a/apps/api/src/routes/admin/learn.ts +++ b/apps/api/src/routes/admin/learn.ts @@ -1,13 +1,10 @@ import { STUDENT_ROLES, - TUTORIAL_READ_SECONDS, + isLessonDone, + type LearnBoard, type LearnExerciseAttempt, type LearnExerciseProgress, type LearnExerciseProgressList, - type LearnStudentProgress, - type LearnStudentProgressList, - type LearnTutorialProgress, - type LearnTutorialProgressList, } from "@oj2/contract" import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm" import { Hono } from "hono" @@ -15,6 +12,8 @@ import { Hono } from "hono" import { requireTeacher, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, success } from "../../http" +import { defaultClassFor } from "../../services/ranking" +import { exerciseCountColumn } from "../../services/tutorial" import { queryInteger, rounded } from "../helpers" /** @@ -64,157 +63,117 @@ function studentCondition(value: string | null) { ) } -adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => { +/** + * 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。 + * + * 不选班就按排名页同一套规则猜老师在教哪个班(`defaultClassFor`);也能传年级前缀, + * 但一个年级两三百行方块,页面上主要还是按班看。 + */ +adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => { const type = tutorialTypeOf(c.req.query("type")) - const className = classFilter(c.req.query("className")) - if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字") + const requested = classFilter(c.req.query("className")) + if (!requested.ok) return failure(c, 400, "invalid-class", "班级只能是数字") + const className = requested.value ?? (await defaultClassFor(c.get("user")!.id)) - // 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 —— - // 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完 const tutorials = await db - .select({ id: schema.tutorial.id }) - .from(schema.tutorial) - .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) - const tutorialIds = tutorials.map((row) => row.id) - - // 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点 - const progressJoin = tutorialIds.length - ? and( - eq(schema.tutorialProgress.userId, schema.user.id), - inArray(schema.tutorialProgress.tutorialId, tutorialIds), - ) - : sql`false` - - // 阅读和练习**分两条查**再在内存里拼。写成一条的话,一个学生读了 3 课、 - // 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上 - // 就是这个下场,用 filter 也救不回来 - const [rows, exerciseRows] = await Promise.all([ - db - .select({ - userId: schema.user.id, - username: schema.user.username, - realName: schema.userProfile.realName, - className: schema.user.className, - // 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。 - // 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况 - readCount: - sql`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith( - Number, - ), - totalSeconds: - sql`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number), - lastViewedAt: sql`max(${schema.tutorialProgress.lastViewedAt})`, - }) - .from(schema.user) - .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) - .leftJoin(schema.tutorialProgress, progressJoin) - .where(studentCondition(className.value)) - .groupBy( - schema.user.id, - schema.user.username, - schema.userProfile.realName, - schema.user.className, - ) - // 前端默认按「已读」升序排,同分的一大批(尤其一堆 0)就落回这里的次序。 - // 不给 orderBy 的话那是聚合吐出来的任意顺序,刷一次换一个样 —— 按班级、 - // 学号排稳住它。className 为空的(推不出班级的)ASC 默认排在最后 - .orderBy(asc(schema.user.className), asc(schema.user.username)), - db - .select({ - userId: schema.exerciseAttempt.userId, - tried: count(), - solved: sql`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith( - Number, - ), - attempts: sql`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number), - }) - .from(schema.exerciseAttempt) - .innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId)) - .innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId)) - .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) - .groupBy(schema.exerciseAttempt.userId), - ]) - const attempts = new Map(exerciseRows.map((row) => [row.userId, row])) - - const [exerciseCountRow] = tutorialIds.length - ? await db - .select({ value: count() }) - .from(schema.exercise) - .where(inArray(schema.exercise.tutorialId, tutorialIds)) - : [{ value: 0 }] - - return success(c, { - tutorialCount: tutorialIds.length, - exerciseCount: exerciseCountRow?.value ?? 0, - results: rows.map( - (row) => - ({ - ...row, - exerciseTried: attempts.get(row.userId)?.tried ?? 0, - exerciseSolved: attempts.get(row.userId)?.solved ?? 0, - exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0, - }) satisfies LearnStudentProgress, - ), - } satisfies LearnStudentProgressList) -}) - -adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => { - const type = tutorialTypeOf(c.req.query("type")) - const className = classFilter(c.req.query("className")) - if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字") - - const [studentCountRow] = await db - .select({ value: count() }) - .from(schema.user) - .where(studentCondition(className.value)) - const studentCount = studentCountRow?.value ?? 0 - - // 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面 - const rows = await db .select({ - tutorialId: schema.tutorial.id, + id: schema.tutorial.id, title: schema.tutorial.title, - order: schema.tutorial.order, - // 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、 - // 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它, - // 而 progress.user_id 那边永远非空,会把过滤当没发生 - readers: - sql`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith( - Number, - ), - totalSeconds: - sql`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith( - Number, - ), - // 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长, - // 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高 - readSeconds: - sql`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith( - Number, - ), + project: schema.tutorial.project, + exerciseTotal: exerciseCountColumn, }) .from(schema.tutorial) - .leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id)) - .leftJoin( - schema.user, - and(eq(schema.user.id, schema.tutorialProgress.userId), studentCondition(className.value)), - ) - // 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉, - // 而「一节课一个人都没读」恰恰是老师最需要看见的一行 .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) - .groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .orderBy(asc(schema.tutorial.order)) + const tutorialIds = tutorials.map((row) => row.id) + if (!className) + return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard) + + const students = await db + .select({ + userId: schema.user.id, + username: schema.user.username, + realName: schema.userProfile.realName, + className: schema.user.className, + }) + .from(schema.user) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .where(studentCondition(className)) + .orderBy(asc(schema.user.className), asc(schema.user.username)) + const userIds = students.map((row) => row.userId) + + // 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的 + const [progressRows, solvedRows] = + userIds.length && tutorialIds.length + ? await Promise.all([ + db + .select({ + userId: schema.tutorialProgress.userId, + tutorialId: schema.tutorialProgress.tutorialId, + viewCount: schema.tutorialProgress.viewCount, + totalSeconds: schema.tutorialProgress.totalSeconds, + lastViewedAt: schema.tutorialProgress.lastViewedAt, + }) + .from(schema.tutorialProgress) + .where( + and( + inArray(schema.tutorialProgress.userId, userIds), + inArray(schema.tutorialProgress.tutorialId, tutorialIds), + ), + ), + db + .select({ + userId: schema.exerciseAttempt.userId, + tutorialId: schema.exercise.tutorialId, + solved: count(), + }) + .from(schema.exerciseAttempt) + .innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId)) + .where( + and( + inArray(schema.exerciseAttempt.userId, userIds), + inArray(schema.exercise.tutorialId, tutorialIds), + eq(schema.exerciseAttempt.solved, true), + ), + ) + .groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId), + ]) + : [[], []] + + const progress = new Map() + for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row) + const solved = new Map() + for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved) return success(c, { - studentCount, - results: rows.map( - ({ readSeconds, ...row }) => - ({ - ...row, - avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0, - }) satisfies LearnTutorialProgress, - ), - } satisfies LearnTutorialProgressList) + className, + tutorials, + students: students.map((student) => { + const opened: number[] = [] + const done: number[] = [] + let exerciseSolved = 0 + let totalSeconds = 0 + let lastViewedAt: string | null = null + for (const tutorial of tutorials) { + const key = `${student.userId}:${tutorial.id}` + const row = progress.get(key) + const solvedHere = solved.get(key) ?? 0 + exerciseSolved += solvedHere + if (row) { + opened.push(tutorial.id) + totalSeconds += row.totalSeconds + if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt + } + const lesson = { + viewCount: row?.viewCount ?? 0, + exerciseTotal: tutorial.exerciseTotal, + exerciseSolved: solvedHere, + } + if (isLessonDone(lesson)) done.push(tutorial.id) + } + return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt } + }), + } satisfies LearnBoard) }) /** diff --git a/apps/api/src/routes/admin/tutorial.ts b/apps/api/src/routes/admin/tutorial.ts index 451f749..6db977f 100644 --- a/apps/api/src/routes/admin/tutorial.ts +++ b/apps/api/src/routes/admin/tutorial.ts @@ -1,34 +1,57 @@ import { + STUDENT_ROLES, createExerciseRequestSchema, createTutorialRequestSchema, + renameTutorialProjectRequestSchema, + reorderTutorialsRequestSchema, + setTutorialProblemsRequestSchema, setTutorialVisibilityRequestSchema, updateExerciseRequestSchema, updateTutorialRequestSchema, type AdminExercise, type AdminTutorial, type AdminTutorialGroups, + type AdminTutorialListItem, + type TutorialProblemCandidate, } from "@oj2/contract" -import { asc, desc, eq } from "drizzle-orm" +import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" import { Hono } from "hono" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { exerciseDataError } from "../../services/exercise" +import { + MUST_DO_TAG, + exerciseCountColumn, + mustDoColumn, + problemCountColumn, + problemTagNames, + projectFromContent, + tutorialProblems, +} from "../../services/tutorial" import { asRecord, queryInteger, sampleUser } from "../helpers" export const adminTutorialRoutes = new Hono() -function serializeTutorial(row: { +async function serializeTutorial(row: { tutorial: typeof schema.tutorial.$inferSelect user: typeof schema.user.$inferSelect realName: string | null }) { + const problems = await tutorialProblems([row.tutorial.id], false) return { id: row.tutorial.id, title: row.tutorial.title, content: row.tutorial.content, code: row.tutorial.code, + project: row.tutorial.project, + problems: problems.map(({ id, displayId, title, mustDo }) => ({ + id, + displayId, + title, + mustDo, + })), isPublic: row.tutorial.isPublic, order: row.tutorial.order, type: row.tutorial.type, @@ -53,59 +76,174 @@ function selectTutorial(id: number) { } adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => { - const rows = await db - .select({ - tutorial: schema.tutorial, - user: schema.user, - realName: schema.userProfile.realName, - }) - .from(schema.tutorial) - .innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id)) - .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) - .orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt)) - const all = rows.map(serializeTutorial) - // 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip + const [rows, readerRows] = await Promise.all([ + db + .select({ + tutorial: schema.tutorial, + user: schema.user, + realName: schema.userProfile.realName, + exerciseCount: exerciseCountColumn, + problemCount: problemCountColumn, + }) + .from(schema.tutorial) + .innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id)) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt)), + // 打开过的学生数:老师自己点开看的不算 + db + .select({ + tutorialId: schema.tutorialProgress.tutorialId, + readers: countDistinct(schema.tutorialProgress.userId), + }) + .from(schema.tutorialProgress) + .innerJoin(schema.user, eq(schema.user.id, schema.tutorialProgress.userId)) + .where(inArray(schema.user.adminType, [...STUDENT_ROLES])) + .groupBy(schema.tutorialProgress.tutorialId), + ]) + const readers = new Map(readerRows.map((row) => [row.tutorialId, row.readers])) + const all = rows.map( + (row) => + ({ + id: row.tutorial.id, + title: row.tutorial.title, + project: row.tutorial.project, + isPublic: row.tutorial.isPublic, + order: row.tutorial.order, + type: row.tutorial.type, + createdBy: sampleUser(row.user, row.realName), + createdAt: row.tutorial.createdAt, + updatedAt: row.tutorial.updatedAt, + exerciseCount: row.exerciseCount, + problemCount: row.problemCount, + readers: readers.get(row.tutorial.id) ?? 0, + }) satisfies AdminTutorialListItem, + ) + // 分组返回,形状对齐旧 TutorialAdminAPI.get return success(c, { python: all.filter((item) => item.type === "python"), c: all.filter((item) => item.type === "c"), } satisfies AdminTutorialGroups) }) +/** + * 拖动排序:把这一串 id 的 order 依次改成 1、2、3…… + * 路径不放在 `/tutorials/` 下,理由同 learn.ts 顶上那段:`/tutorials/:id` 会先吃掉它。 + */ +adminTutorialRoutes.put("/tutorial-order", requireSuperAdmin, async (c) => { + const parsed = await parseBody(c, reorderTutorialsRequestSchema) + if (!parsed.success) return parsed.response + const ids = parsed.data.ids + await db.transaction(async (tx) => { + for (const [index, id] of ids.entries()) { + // 排序不是内容修改,不动 updatedAt + await tx + .update(schema.tutorial) + .set({ order: index + 1 }) + .where(eq(schema.tutorial.id, id)) + } + }) + return success(c, null) +}) + +/** 项目改名:这门课里挂在旧名字下的教程一起改 */ +adminTutorialRoutes.put("/tutorial-projects", requireSuperAdmin, async (c) => { + const parsed = await parseBody(c, renameTutorialProjectRequestSchema) + if (!parsed.success) return parsed.response + const { type, from, to } = parsed.data + await db + .update(schema.tutorial) + .set({ project: to }) + .where(and(eq(schema.tutorial.type, type), eq(schema.tutorial.project, from))) + return success(c, null) +}) + +/** + * 给一课挑题的候选。不带关键字列出全部必会题;带关键字按题号、标题找, + * 必会题排在前面。只给公开题库里的题(比赛题学生点不进去)。 + */ +adminTutorialRoutes.get("/tutorial-problem-candidates", requireSuperAdmin, async (c) => { + const keyword = c.req.query("keyword")?.trim() ?? "" + const pattern = `%${keyword.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%` + const rows = await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + mustDo: mustDoColumn, + tags: problemTagNames, + }) + .from(schema.problem) + .where( + and( + isNull(schema.problem.contestId), + eq(schema.problem.visible, true), + keyword + ? or(eq(schema.problem.displayId, keyword), ilike(schema.problem.title, pattern)) + : mustDoColumn, + ), + ) + .orderBy( + desc(sql`${schema.problem.displayId} = ${keyword}`), + desc(mustDoColumn), + asc(schema.problem.displayId), + ) + .limit(keyword ? 20 : 60) + return success( + c, + rows.map( + (row) => + ({ + ...row, + tags: row.tags.filter((tag) => tag !== MUST_DO_TAG), + }) satisfies TutorialProblemCandidate, + ), + ) +}) + adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => { const parsed = await parseBody(c, createTutorialRequestSchema) if (!parsed.success) return parsed.response const now = new Date().toISOString() + const { project, ...data } = parsed.data const [created] = await db .insert(schema.tutorial) .values({ - ...parsed.data, + ...data, + project: project ?? projectFromContent(data.content), createdAt: now, updatedAt: now, createdById: c.get("user")!.id, }) .returning({ id: schema.tutorial.id }) const [row] = await selectTutorial(created!.id) - return success(c, serializeTutorial(row!), 201) + return success(c, await serializeTutorial(row!), 201) }) adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => { const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 })) if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") - return success(c, serializeTutorial(row)) + return success(c, await serializeTutorial(row)) }) adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => { const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const parsed = await parseBody(c, updateTutorialRequestSchema) if (!parsed.success) return parsed.response + const { project, ...data } = parsed.data + // 没传 project(发布脚本)就从正文里抽;正文里也没写就不动原来的 + const derived = project ?? (projectFromContent(data.content) || undefined) const updated = await db .update(schema.tutorial) - .set({ ...parsed.data, updatedAt: new Date().toISOString() }) + .set({ + ...data, + ...(derived === undefined ? {} : { project: derived }), + updatedAt: new Date().toISOString(), + }) .where(eq(schema.tutorial.id, id)) .returning({ id: schema.tutorial.id }) if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") const [row] = await selectTutorial(id) - return success(c, serializeTutorial(row!)) + return success(c, await serializeTutorial(row!)) }) adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => { @@ -120,13 +258,41 @@ adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c .returning({ id: schema.tutorial.id }) if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") const [row] = await selectTutorial(id) - return success(c, serializeTutorial(row!)) + return success(c, await serializeTutorial(row!)) +}) + +/** 一课挂哪几道题:整串替换,顺序就是数组顺序 */ +adminTutorialRoutes.put("/tutorials/:id/problems", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const parsed = await parseBody(c, setTutorialProblemsRequestSchema) + if (!parsed.success) return parsed.response + const problemIds = [...new Set(parsed.data.problemIds)] + const [row] = await selectTutorial(id) + if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") + if (problemIds.length) { + const found = await db + .select({ id: schema.problem.id }) + .from(schema.problem) + .where(and(inArray(schema.problem.id, problemIds), isNull(schema.problem.contestId))) + if (found.length !== problemIds.length) { + return failure(c, 400, "problem-not-found", "有的题不存在,或者是比赛题") + } + } + await db.transaction(async (tx) => { + await tx.delete(schema.tutorialProblem).where(eq(schema.tutorialProblem.tutorialId, id)) + if (problemIds.length) { + await tx + .insert(schema.tutorialProblem) + .values(problemIds.map((problemId, index) => ({ tutorialId: id, problemId, order: index }))) + } + }) + return success(c, await serializeTutorial(row)) }) adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => { const id = queryInteger(c.req.param("id"), 0, { min: 1 }) - // 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id - // 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**, + // 练习、学习留痕、挂的题都随教程一起没:exercise / tutorial_progress / tutorial_problem + // 的 tutorial_id 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**, // 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。 const deleted = await db .delete(schema.tutorial) diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts index a89d8a8..1dfc3b1 100644 --- a/apps/api/src/routes/content.ts +++ b/apps/api/src/routes/content.ts @@ -20,10 +20,12 @@ import { import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm" import { Hono } from "hono" -import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware" +import { optionalAuth, requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" +import { problemStates } from "../services/problem-progress" +import { tutorialProblems } from "../services/tutorial" import { asRecord, queryInteger, sampleUser } from "./helpers" export const contentRoutes = new Hono() @@ -254,15 +256,20 @@ contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => { contentRoutes.get("/tutorials", async (c) => { const type = c.req.query("type") === "c" ? "c" : "python" const rows = await db - .select({ id: schema.tutorial.id, title: schema.tutorial.title }) + .select({ + id: schema.tutorial.id, + title: schema.tutorial.title, + project: schema.tutorial.project, + }) .from(schema.tutorial) .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))) .orderBy(asc(schema.tutorial.order)) return success(c, rows satisfies TutorialSummary[]) }) -contentRoutes.get("/tutorials/:id", async (c) => { +contentRoutes.get("/tutorials/:id", optionalAuth, async (c) => { const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const user = c.get("user") const [row] = await db .select({ tutorial: schema.tutorial, @@ -275,11 +282,22 @@ contentRoutes.get("/tutorials/:id", async (c) => { .where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))) .limit(1) if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") + const [problems, states] = await Promise.all([ + tutorialProblems([id], true), + user ? problemStates(user.id) : null, + ]) return success(c, { id: row.tutorial.id, title: row.tutorial.title, + project: row.tutorial.project, content: row.tutorial.content, - code: row.tutorial.code, + problems: problems.map(({ id, displayId, title, mustDo }) => ({ + id, + displayId, + title, + mustDo, + solved: states?.solved.has(id) ?? false, + })), isPublic: row.tutorial.isPublic, order: row.tutorial.order, type: row.tutorial.type, @@ -475,8 +493,9 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => { return success(c, null) }) -contentRoutes.get("/tutorials/:id/exercises", async (c) => { +contentRoutes.get("/tutorials/:id/exercises", optionalAuth, async (c) => { const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const user = c.get("user") const [tutorial] = await db .select({ id: schema.tutorial.id }) .from(schema.tutorial) @@ -484,19 +503,33 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => { .limit(1) if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") const rows = await db - .select() + .select({ + exercise: schema.exercise, + // 目录、大纲上标「做对过」,一课的练一练全对就算学完这课 + solved: user + ? sql`coalesce(${schema.exerciseAttempt.solved}, false)` + : sql`false`, + }) .from(schema.exercise) + .leftJoin( + schema.exerciseAttempt, + and( + eq(schema.exerciseAttempt.exerciseId, schema.exercise.id), + eq(schema.exerciseAttempt.userId, user?.id ?? 0), + ), + ) .where(eq(schema.exercise.tutorialId, id)) .orderBy(asc(schema.exercise.order)) return success( c, rows.map( - (row) => + ({ exercise: row, solved }) => ({ id: row.id, type: row.type, data: asRecord(row.data), order: row.order, + solved, }) satisfies Exercise, ), ) diff --git a/apps/api/src/routes/ranking.ts b/apps/api/src/routes/ranking.ts index 76655d7..282cda6 100644 --- a/apps/api/src/routes/ranking.ts +++ b/apps/api/src/routes/ranking.ts @@ -11,7 +11,7 @@ import { type SampleUser, type WeeklyChampion, } from "@oj2/contract" -import { and, countDistinct, desc, eq, gte, inArray, isNotNull, ne } from "drizzle-orm" +import { and, eq, inArray, isNotNull } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" @@ -23,10 +23,10 @@ import { classBattle, classDetail, classPk, + defaultClassFor, loadEntrants, PK_MAX_CLASSES, pkPartner, - rankedStudents, standings, WEEK_MS, type Standing, @@ -73,48 +73,6 @@ function periodStart(period: RankPeriod) { return null } -/** - * 老师打开排名页默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。 - * 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。 - */ -async function defaultClassFor(teacherId: number) { - const [lesson] = await db - .select({ className: schema.classLesson.className }) - .from(schema.classLesson) - .where(eq(schema.classLesson.createdBy, teacherId)) - .orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt)) - .limit(1) - if (lesson) return lesson.className - - const users = countDistinct(schema.submission.userId) - const [busiest] = await db - .select({ className: schema.user.className, users }) - .from(schema.submission) - .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) - .where( - and( - rankedStudents, - isNotNull(schema.user.className), - ne(schema.user.className, ""), - gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()), - ), - ) - .groupBy(schema.user.className) - .orderBy(desc(users)) - .limit(1) - if (busiest?.className) return busiest.className - - // 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班 - const [latest] = await db - .select({ className: schema.user.className }) - .from(schema.submission) - .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) - .where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, ""))) - .orderBy(desc(schema.submission.createTime)) - .limit(1) - return latest?.className ?? null -} - /** * 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选, * 不给就猜(`defaultClassFor`)。返回 undefined 表示该报「没有班级」。 diff --git a/apps/api/src/routes/trial-run.ts b/apps/api/src/routes/trial-run.ts index b8a8672..4cf2ec1 100644 --- a/apps/api/src/routes/trial-run.ts +++ b/apps/api/src/routes/trial-run.ts @@ -16,6 +16,9 @@ export const trialRunRoutes = new Hono() /** 后台生成测试点时题目可能还没存,没有题目的限制可用,按这两个跑 */ const ADMIN_TIME_LIMIT = 3000 const ADMIN_MEMORY_LIMIT = 256 +/** 自学教程里的代码块:都是几行的例子,给和普通题一样的限制,死循环早点停 */ +const TUTORIAL_TIME_LIMIT = 1000 +const TUTORIAL_MEMORY_LIMIT = 256 /** * 试运行,见 judge/trial.ts。不落库、不算提交、不走令牌桶 —— @@ -37,7 +40,22 @@ trialRunRoutes.post("/trial-runs", requireAuth, async (c) => { let memoryLimit = ADMIN_MEMORY_LIMIT let source = body.code let prependLines = 0 - if (body.problemId === undefined) { + if (body.problemId === undefined && body.tutorialId !== undefined) { + // 教程正文里的代码块:只有一组输入(代码块下面那个输入框),教程得是公开的 + if (body.cases.length !== 1) { + return failure(c, 400, "too-many-cases", "教程里的代码一次只能跑一组") + } + const [tutorial] = await db + .select({ isPublic: schema.tutorial.isPublic }) + .from(schema.tutorial) + .where(eq(schema.tutorial.id, body.tutorialId)) + .limit(1) + if (!tutorial || (!tutorial.isPublic && !admin)) { + return failure(c, 404, "tutorial-not-found", "教程不存在") + } + timeLimit = TUTORIAL_TIME_LIMIT + memoryLimit = TUTORIAL_MEMORY_LIMIT + } else if (body.problemId === undefined) { if (!admin) return failure(c, 403, "permission-denied", "请从题目页试运行") } else { let contestId: number | null = null diff --git a/apps/api/src/services/ranking.ts b/apps/api/src/services/ranking.ts index 1dfe9a7..848e822 100644 --- a/apps/api/src/services/ranking.ts +++ b/apps/api/src/services/ranking.ts @@ -11,6 +11,8 @@ import { import { and, count, + countDistinct, + desc, eq, gte, inArray, @@ -534,3 +536,45 @@ export async function classPk( solo, } satisfies ClassPk } + +/** + * 老师打开排名页(和后台「自学情况」)默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。 + * 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。 + */ +export async function defaultClassFor(teacherId: number) { + const [lesson] = await db + .select({ className: schema.classLesson.className }) + .from(schema.classLesson) + .where(eq(schema.classLesson.createdBy, teacherId)) + .orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt)) + .limit(1) + if (lesson) return lesson.className + + const users = countDistinct(schema.submission.userId) + const [busiest] = await db + .select({ className: schema.user.className, users }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where( + and( + rankedStudents, + isNotNull(schema.user.className), + ne(schema.user.className, ""), + gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()), + ), + ) + .groupBy(schema.user.className) + .orderBy(desc(users)) + .limit(1) + if (busiest?.className) return busiest.className + + // 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班 + const [latest] = await db + .select({ className: schema.user.className }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, ""))) + .orderBy(desc(schema.submission.createTime)) + .limit(1) + return latest?.className ?? null +} diff --git a/apps/api/src/services/tutorial.ts b/apps/api/src/services/tutorial.ts new file mode 100644 index 0000000..8e46876 --- /dev/null +++ b/apps/api/src/services/tutorial.ts @@ -0,0 +1,70 @@ +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm" + +import { db, schema } from "../db" + +/** 「去做几道题」里优先推荐、要标出来的那批题挂的标签 */ +export const MUST_DO_TAG = "必会题" + +const PROJECT_RE = /\*\*所属项目[::]\s*([^*\n]+)\*\*/ + +/** + * 从正文第一段的「**所属项目:项目三 · 智慧农业·蜜橘园**」抽出项目名,抽不到给空串。 + * 和 0031 迁移回填用的是同一个写法;发布脚本不传 `project` 时靠它。 + */ +export function projectFromContent(content: string) { + return (content.match(PROJECT_RE)?.[1] ?? "").trim().slice(0, 64) +} + +/** + * 放进 select 列表的关联子查询**一律手写表别名**:drizzle 在 select 列表里渲染列时 + * 会省掉表名,子查询里的 `"id"` 要么报 ambiguous,要么就近绑到子查询自己的表上 —— + * 后一种不报错,只是数全错。外层表也写死成 `"problem"` / `"tutorial"`。 + */ + +/** 这道题带不带「必会题」标签,做成 select 里的一列 */ +export const mustDoColumn = sql`exists ( + select 1 from "problem_tags" pt join "problem_tag" t on t."id" = pt."problemtag_id" + where pt."problem_id" = "problem"."id" and t."name" = ${MUST_DO_TAG} +)` + +/** 这道题的全部标签名 */ +export const problemTagNames = sql`coalesce(( + select array_agg(t."name" order by t."name") + from "problem_tags" pt join "problem_tag" t on t."id" = pt."problemtag_id" + where pt."problem_id" = "problem"."id" +), '{}')` + +/** 一课有几道练一练 / 挂了几道题 */ +export const exerciseCountColumn = sql`( + select count(*) from "exercise" e where e."tutorial_id" = "tutorial"."id" +)`.mapWith(Number) +export const problemCountColumn = sql`( + select count(*) from "tutorial_problem" tp where tp."tutorial_id" = "tutorial"."id" +)`.mapWith(Number) + +/** + * 几课各挂了哪些题,按老师排的顺序。`publicOnly` 给学生端用:没公开的题、比赛题不出现 + * (老师挑的时候公开、后来藏起来了的,学生点进去只会 404)。 + */ +export async function tutorialProblems(tutorialIds: number[], publicOnly: boolean) { + if (tutorialIds.length === 0) return [] + return db + .select({ + tutorialId: schema.tutorialProblem.tutorialId, + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + mustDo: mustDoColumn, + }) + .from(schema.tutorialProblem) + .innerJoin(schema.problem, eq(schema.problem.id, schema.tutorialProblem.problemId)) + .where( + and( + inArray(schema.tutorialProblem.tutorialId, tutorialIds), + publicOnly + ? and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)) + : undefined, + ), + ) + .orderBy(asc(schema.tutorialProblem.tutorialId), asc(schema.tutorialProblem.order)) +} diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index ab6a07e..f0fadb1 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -66,11 +66,22 @@ export type AdminAnnouncementListItem = z.infer export type AdminAiReport = z.infer export type AdminAiReportList = z.infer export type StuckProblem = z.infer -export type LearnStudentProgress = z.infer -export type LearnStudentProgressList = z.infer -export type LearnTutorialProgress = z.infer -export type LearnTutorialProgressList = z.infer +export type LearnBoard = z.infer +export type LearnBoardStudent = z.infer export type LearnExerciseProgress = z.infer export type LearnExerciseProgressList = z.infer export type LearnExerciseAttempt = z.infer @@ -932,6 +968,11 @@ export type TutorialType = z.infer export type AdminTutorial = z.infer export type AdminTutorialListItem = z.infer export type AdminTutorialGroups = z.infer +export type AdminTutorialProblem = z.infer +export type TutorialProblemCandidate = z.infer +export type SetTutorialProblemsRequest = z.infer +export type ReorderTutorialsRequest = z.infer +export type RenameTutorialProjectRequest = z.infer export type CreateTutorialRequest = z.infer export type UpdateTutorialRequest = z.infer export type SetTutorialVisibilityRequest = z.infer diff --git a/packages/contract/src/content.ts b/packages/contract/src/content.ts index 29608da..949d97d 100644 --- a/packages/contract/src/content.ts +++ b/packages/contract/src/content.ts @@ -63,14 +63,31 @@ export const reactionStateSchema = z.object({ export const setReactionRequestSchema = z.object({ type: reactionKeySchema }) +/** + * 目录里的一课。`title` 是「情境——知识点」(「蜜橘园环境预警——条件语句基础」), + * 前端拆成知识点在前、情境在后显示;`project` 是所属项目,目录按它分组,可能为空。 + */ export const tutorialSummarySchema = z.object({ id: z.number().int(), title: z.string(), + project: z.string(), +}) + +/** + * 一课结尾「去做几道题」里的一道。`solved` 是当前学生做对过没有(未登录恒为 false), + * `mustDo` 是带不带「必会题」标签。 + */ +export const tutorialProblemSchema = z.object({ + id: z.number().int(), + displayId: z.string(), + title: z.string(), + mustDo: z.boolean(), + solved: z.boolean(), }) export const tutorialSchema = tutorialSummarySchema.extend({ content: z.string(), - code: z.string().nullable(), + problems: z.array(tutorialProblemSchema), isPublic: z.boolean(), order: z.number().int(), type: z.enum(["python", "c"]), @@ -173,6 +190,8 @@ export const exerciseSchema = z.object({ type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]), data: z.record(z.string(), z.unknown()), order: z.number().int(), + /** 当前学生做对过没有。未登录恒为 false;一课的练一练全做对就算学完这课 */ + solved: z.boolean(), }) export type Message = z.infer @@ -188,20 +207,26 @@ export type ReactionCounts = z.infer export type ReactionState = z.infer export type SetReactionRequest = z.infer export type Tutorial = z.infer +export type TutorialProblem = z.infer export type Exercise = z.infer export type TutorialProgress = z.infer export type TutorialProgressPing = z.infer export type ExerciseAttemptRequest = z.infer /** - * 「已读」的门槛:累计停留满 3 分钟才算读过这一课。 + * 「学完一课」的口径:**这课的练一练全做对**。没有练一练的课,打开过就算。 * - * 之前只要打开过(`viewCount > 0`)就记成已读,于是「点开看一眼就退」和「认真读完」 - * 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。3 分钟是按最短的一课 - * 定的下限——读不完还翻不动的课文,扫一眼是到不了这个数的。 + * 原来按「累计停留满 3 分钟」算已读,学生看不见这条规则,只看见圆点变黄;而快照里 + * 停留时长中位数是 0 秒 —— 一节课上十几分钟点开 17 课的大有人在。练一练是学生 + * 自己做的、看得见的,「全对了」比「待够了」说得清。停留时长照样记,只给老师看。 * - * 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数), - * 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去, - * 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。 + * 前端目录、首页、后台「自学情况」都走这一个函数,别各写一份。 */ -export const TUTORIAL_READ_SECONDS = 180 +export function isLessonDone(progress: { + viewCount: number + exerciseTotal: number + exerciseSolved: number +}) { + if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal + return progress.viewCount > 0 +} diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index 59d11fe..950ae1b 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -558,7 +558,8 @@ export const TRIAL_ADMIN_MAX_CASES = 50 * 试运行:「运行例子」「自己输入」、后台「生成测试点」。走本站判题机,不落库、不算提交。 * * - 带 `problemId`:按这道题的时间 / 内存限制和代码模板跑,和提交一个口径; - * - 不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。 + * - 带 `tutorialId`:自学教程正文里的代码块「运行」,只能跑一组、限制用默认的; + * - 都不带:只有管理员能这么调(后台生成测试点时题目可能还没存)。 * * `output` 是这组的正确输出,给了就由判题机比对(和提交一样去掉末尾空白再比), * 结果是通过 / 答案错误;不给(「自己输入」、生成测试点)就只看有没有跑出错。 @@ -566,6 +567,7 @@ export const TRIAL_ADMIN_MAX_CASES = 50 export const trialRunRequestSchema = z.object({ problemId: z.number().int().positive().optional(), contestId: z.number().int().positive().optional(), + tutorialId: z.number().int().positive().optional(), language: runnableLanguageSchema, code: z .string()