diff --git a/apps/api/src/auth/presence.ts b/apps/api/src/auth/presence.ts index e76ac48..a08d2b1 100644 --- a/apps/api/src/auth/presence.ts +++ b/apps/api/src/auth/presence.ts @@ -41,15 +41,6 @@ export async function onlineUserIds() { return new Set(members.map(Number).filter(Number.isInteger)) } -/** - * 在线人数。前台榜单页要的就是这一个数 —— 不必像 onlineUserIds 那样把成员全拉回来, - * ZCOUNT 让 Redis 自己数(O(log N))。这里不顺手清过期成员:清理是写操作, - * 而这个端点是匿名可访问的。 - */ -export async function onlineCount() { - return redis.zcount(PRESENCE_KEY, Date.now() - ONLINE_WINDOW_MS, "+inf") -} - /** 登出、被禁用、被踢下线:立刻从在线名单里摘掉,别等窗口自然过期 */ export async function clearOnline(userId: number) { await redis.zrem(PRESENCE_KEY, String(userId)) diff --git a/apps/api/src/db/0030_add_user_rank_hidden.sql b/apps/api/src/db/0030_add_user_rank_hidden.sql new file mode 100644 index 0000000..044fa3e --- /dev/null +++ b/apps/api/src/db/0030_add_user_rank_hidden.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "rank_hidden_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/src/db/meta/0030_snapshot.json b/apps/api/src/db/meta/0030_snapshot.json new file mode 100644 index 0000000..5c3302b --- /dev/null +++ b/apps/api/src/db/meta/0030_snapshot.json @@ -0,0 +1,3908 @@ +{ + "id": "fa4d1f59-fde6-4b77-a8af-5e1d70000a74", + "prevId": "5f978004-d395-451a-8aa2-9e9719cc5732", + "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 + } + }, + "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_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 29fd6e2..87edb6b 100644 --- a/apps/api/src/db/meta/_journal.json +++ b/apps/api/src/db/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1791555848163, "tag": "0029_regrant_problemset_badges", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1791587559546, + "tag": "0030_add_user_rank_hidden", + "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 a168488..3c5ff10 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1259,6 +1259,11 @@ export const user = pgTable( problemPermission: text("problem_permission").notNull().$type(), rawPassword: varchar("raw_password", { length: 20 }), className: text("class_name"), + /** + * 老师把这个学生「不计入排名」的时刻,null = 正常上榜。怀疑抄代码时用,随时能恢复 + * (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响 + */ + rankHiddenAt: timestamp("rank_hidden_at", { withTimezone: true, mode: "string" }), }, (table) => [ unique("user_username_key").on(table.username), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d2f9b97..b21a718 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { judgeServerRoutes } from "./routes/judge-server" import { contestRoutes } from "./routes/contest" import { contentRoutes } from "./routes/content" import { classroomRoutes } from "./routes/classroom" +import { rankingRoutes } from "./routes/ranking" import { problemsetRoutes } from "./routes/problemset" import { achievementRoutes } from "./routes/achievement" import { aiRoutes } from "./routes/ai" @@ -36,6 +37,7 @@ app.route("/api", siteRoutes) app.route("/api", contestRoutes) app.route("/api", contentRoutes) app.route("/api", classroomRoutes) +app.route("/api", rankingRoutes) app.route("/api", problemsetRoutes) app.route("/api", achievementRoutes) app.route("/api", aiRoutes) diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 18c5cc5..70a1d42 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -5,25 +5,18 @@ import { registerRequestSchema, STUDENT_ROLES, updateProfileRequestSchema, - type ActivityRankItem, type KnowledgeLevel, type KnowledgeMap, type Metrics, - type MyRank, type ProblemRank, - type RankProfile, - type UserRank, type WeeklyRank, type WeeklyRankItem, } from "@oj2/contract" import { and, - asc, count, countDistinct, - desc, eq, - gt, gte, inArray, isNull, @@ -31,7 +24,6 @@ import { lte, max, min, - ne, notExists, or, sql, @@ -40,7 +32,6 @@ import { alias } from "drizzle-orm/pg-core" import { Hono } from "hono" import { hashPassword } from "../auth/password" -import { onlineUserIds } from "../auth/presence" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware" import { config } from "../config" import { db, schema } from "../db" @@ -51,7 +42,7 @@ import { sniffImageExtension } from "../services/image" import { getUserProfileById } from "../services/profile" import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling" import { localTime, weekStart } from "../time" -import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers" +import { asRecord, queryInteger, sampleUser } from "./helpers" export const accountRoutes = new Hono() @@ -134,7 +125,7 @@ accountRoutes.post("/users", async (c) => { accountRoutes.get("/profiles/:username", optionalAuth, async (c) => { // 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行: // `if not user.is_authenticated: return self.success()` —— 匿名一律返回空, - // 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。 + // 否则用户名可经排名接口(/rankings/board)公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。 if (!c.get("user")) return success(c, null) const [target] = await db .select({ id: schema.user.id }) @@ -209,200 +200,17 @@ accountRoutes.get("/users/:id/metrics", async (c) => { }) /** - * 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性, - * 不是调用方的选择。 - * - * 之前它是个 `top` 查询参数,三个调用方各传各的(100 / 10 / 0), - * 而 top 又会覆盖 limit 与 offset,total 却按全量人数算 —— 于是分页器算出几十页、 - * 页页内容相同(36e4ac2)。「全服 Top10」不需要另一个上限,取 limit=10&offset=0 即可; - * 后台那个「不限量」的用法搬去了 /admin/rankings/users。 - */ -const LEADERBOARD_SIZE = 100 - -/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */ -const leaderboardWhere = and( - inArray(schema.user.adminType, [...STUDENT_ROLES]), - eq(schema.user.isDisabled, false), -) - -/** - * 榜单排序:AC 多的在前 → 同 AC 时提交少的在前 → 再同就按 id。 - * - * 第三档不是凑数:前两个键完全相同的学生在真实数据里成片存在(都是 0/0), - * 没有稳定的兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复或漏掉的人。 - */ -const leaderboardOrder = [ - desc(schema.userProfile.acceptedNumber), - asc(schema.userProfile.submissionNumber), - asc(schema.user.id), -] - -accountRoutes.get("/rankings/users", optionalAuth, async (c) => { - const limit = queryInteger(c.req.query("limit"), 10, { - min: 1, - max: LEADERBOARD_SIZE, - }) - const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) - - // 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** —— - // 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去, - // 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。 - const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset)) - - // 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline) - const [totalRow, rows, me, online] = await Promise.all([ - db - .select({ value: count() }) - .from(schema.userProfile) - .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) - .where(leaderboardWhere) - .then(([row]) => row), - pageLimit === 0 - ? [] - : db - .select({ profile: schema.userProfile, user: schema.user }) - .from(schema.userProfile) - .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) - .where(leaderboardWhere) - .orderBy(...leaderboardOrder) - .limit(pageLimit) - .offset(offset), - myLeaderboardRank(c.get("user")?.id), - isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null, - ]) - - return success(c, { - results: rows.map((row) => serializeRankRow(row, online)), - total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE), - me, - } satisfies UserRank) -}) - -function serializeRankRow( - { - profile, - user, - }: { - profile: typeof schema.userProfile.$inferSelect - user: typeof schema.user.$inferSelect - }, - online: Set | null = null, -) { - return { - id: profile.id, - user: sampleUser(user, profile.realName), - acceptedNumber: profile.acceptedNumber, - submissionNumber: profile.submissionNumber, - mood: profile.mood, - isOnline: online ? online.has(user.id) : null, - } satisfies RankProfile -} - -/** - * 「我」的全服名次,登录且身份入榜时才有。 - * - * 名次 = 排在我前面的人数 + 1,三个排序键**逐级**比较,与列表的 orderBy 逐字对应 —— - * 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」这种对不上的情况。 - * 三个键全等才算并列,此时名次相同。 - */ -async function myLeaderboardRank(userId: number | undefined) { - if (!userId) return null - const [mine] = await db - .select({ profile: schema.userProfile, user: schema.user }) - .from(schema.userProfile) - .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) - .where(and(leaderboardWhere, eq(schema.user.id, userId))) - .limit(1) - if (!mine) return null - - const { acceptedNumber, submissionNumber } = mine.profile - const [ahead] = await db - .select({ value: count() }) - .from(schema.userProfile) - .innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)) - .where( - and( - leaderboardWhere, - or( - gt(schema.userProfile.acceptedNumber, acceptedNumber), - and( - eq(schema.userProfile.acceptedNumber, acceptedNumber), - lt(schema.userProfile.submissionNumber, submissionNumber), - ), - and( - eq(schema.userProfile.acceptedNumber, acceptedNumber), - eq(schema.userProfile.submissionNumber, submissionNumber), - lt(schema.user.id, userId), - ), - ), - ), - ) - - return { - ...serializeRankRow(mine), - rank: (ahead?.value ?? 0) + 1, - } satisfies MyRank -} - -accountRoutes.get("/rankings/activity", async (c) => { - const start = c.req.query("start") - if (!start || Number.isNaN(Date.parse(start))) - return failure(c, 400, "invalid-start", "start time is required") - /** - * 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话, - * 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以 - * 两个名字同时挂在榜上。同 `/submissions/statistics` 那批。 - * - * innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。 - */ - const rows = await db - .select({ - username: schema.user.username, - value: countDistinct(schema.submission.problemId), - }) - .from(schema.submission) - .innerJoin(schema.user, eq(schema.submission.userId, schema.user.id)) - .where( - and( - isNull(schema.submission.contestId), - gte(schema.submission.createTime, start), - inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]), - eq(schema.user.isDisabled, false), - ne(schema.user.adminType, "Super Admin"), - ), - ) - .groupBy(schema.submission.userId, schema.user.username) - .orderBy(desc(countDistinct(schema.submission.problemId))) - .limit(10) - return success( - c, - rows.map( - (row) => - ({ - username: row.username, - count: row.value, - }) satisfies ActivityRankItem, - ), - ) -}) - -/** - * 周榜的榜面大小。**存量榜(`/rankings/users`)解决的是「谁最强」,周榜解决的是 - * 「这一周谁在往前走」** —— 后者每周一清零,所以榜面短一点更像「这周的头名」, + * 首页周榜的榜面大小。周榜解决的是「这一周谁在往前走」—— 每周一清零,所以榜面短一点更像「这周的头名」, * 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。 */ const WEEKLY_BOARD_SIZE = 10 -/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */ +/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与排名页(routes/ranking.ts)同口径 */ const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] /** * 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。 * - * 和 `/rankings/users` 的区别不只是加了时间窗:那张榜排的是 `user_profile` 的存量 - * AC 总数,名次几乎不动,中位学生看一眼就知道追不上,等于负反馈。这张榜的分母是 - * 「这一周」,谁都可能进前十。 - * * 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单 * 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。 * 相关子查询的四个条件正好是 `submission_public_metrics_idx` @@ -417,10 +225,12 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => { const start = weekStart() - // 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员 + // 入榜人群与排名页一致(routes/ranking.ts 的 rankedStudents):正常状态的学生与学生管理员, + // 老师设成不计入排名的不算 const audience = and( inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false), + isNull(schema.user.rankHiddenAt), className ? eq(schema.user.className, className) : undefined, ) const thisWeek = and( diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index ad81606..b8f2d16 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -10,8 +10,6 @@ import { type ClassActivityProblem, type ClassComparison, type ClassComparisonResponse, - type ClassRankItem, - type ClassUserRank, type LastVisit, type LessonLanguage, FLOWCHART_PASS_GRADES, @@ -39,7 +37,7 @@ import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" import { accepted } from "../services/learning-stats" import { calendarDay, dayStart, localTime } from "../time" -import { queryInteger, rounded } from "./helpers" +import { rounded } from "./helpers" export const classroomRoutes = new Hono() @@ -108,65 +106,6 @@ function sampleStdDev(values: number[]) { ) } -classroomRoutes.get("/rankings/classes", async (c) => { - const grade = c.req.query("grade")?.trim() - if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required") - const users = await loadClassUsers(undefined, grade) - const groups = new Map() - for (const user of users) - groups.set(user.className, [...(groups.get(user.className) ?? []), user]) - const result = [...groups] - .map(([className, members]) => { - const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0) - const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0) - return { - className, - userCount: members.length, - totalAc, - totalSubmission, - avgAc: rounded(totalAc / members.length), - acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0, - } - }) - .sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission) - return success( - c, - result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem), - ) -}) - -classroomRoutes.get("/me/class-rank", requireAuth, async (c) => { - const user = c.get("user")! - if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息") - const members = (await loadClassUsers([user.className])).sort( - (a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber, - ) - const ranks = members.map((member, index) => ({ - userId: member.userId, - username: member.username, - acceptedNumber: member.acceptedNumber, - submissionNumber: member.submissionNumber, - rank: index + 1, - })) - const myRank = ranks.find((rank) => rank.userId === user.id)?.rank ?? -1 - const showAll = c.req.query("scope") === "all" - let selected = ranks - if (showAll) { - const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) - const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) - selected = ranks.slice(offset, offset + limit) - } else if (myRank > 0 && ranks.length > 10) { - const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10) - selected = ranks.slice(start, start + 10) - } - return success(c, { - className: user.className, - myRank, - total: ranks.length, - ranks: selected, - } satisfies ClassUserRank) -}) - /** * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: * (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), diff --git a/apps/api/src/routes/ranking.ts b/apps/api/src/routes/ranking.ts new file mode 100644 index 0000000..4e5ff3c --- /dev/null +++ b/apps/api/src/routes/ranking.ts @@ -0,0 +1,461 @@ +import { + rankHiddenRequestSchema, + rankPeriodSchema, + rankScopeSchema, + STUDENT_ROLES, + type ClassBattleItem, + type RankBoard, + type RankPeriod, + type RankRow, + type RankScope, + type SampleUser, + type WeeklyChampion, +} from "@oj2/contract" +import { + and, + countDistinct, + desc, + eq, + gte, + inArray, + isNotNull, + isNull, + like, + min, + ne, + type SQL, +} from "drizzle-orm" +import { Hono } from "hono" + +import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware" +import { db, schema } from "../db" +import { failure, parseBody, success } from "../http" +import { JudgeStatus } from "../judge/status" +import { dayStart, termStart, weekStart } from "../time" +import { isTeacherOrAbove, rounded, sampleUser } from "./helpers" + +/** + * 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释: + * 做对多的在前,一样多的先做到的在前;「做对」= 这道题第一次做对落在这段时间里。 + */ +export const rankingRoutes = new Hono() + +/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */ +const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] + +/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */ +const BOARD_HEAD = 16 +/** 我附近一段:前面 5 个、后面 7 个(前面的人是要追的,多给后面几个看「谁在追我」) */ +const WINDOW_BEFORE = 5 +const WINDOW_AFTER = 7 +/** 没有「我」(老师、没上榜)时榜面给多少 */ +const BOARD_HEAD_NO_ME = 30 +/** 走势图往回看几周 */ +const TREND_WEEKS = 4 +/** 每周冠军列几周 */ +const CHAMPION_WEEKS = 3 + +const WEEK_MS = 7 * 86_400_000 + +/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */ +const rankedStudents = and( + inArray(schema.user.adminType, [...STUDENT_ROLES]), + eq(schema.user.isDisabled, false), + isNull(schema.user.rankHiddenAt), +)! + +interface Entrant { + id: number + username: string + className: string | null + avatar: string | null + /** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */ + times: number[] + stamps: string[] +} + +interface Standing { + entrant: Entrant + rank: number + solved: number + reachedAt: string | null +} + +function audienceWhere(scope: RankScope, className: string | null) { + if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))! + if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))! + return rankedStudents +} + +function avatarOf(avatar: string | null) { + return !avatar || avatar.endsWith("/default.png") ? null : avatar +} + +/** + * 这群人、从 `since` 起每道题第一次做对的时刻。 + * + * 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是 + * 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。 + * 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE + * contest_id IS NULL),全服全部历史实测 24ms。 + */ +async function loadEntrants(where: SQL, since: string | null): Promise { + const firstSolved = min(schema.submission.createTime) + const [people, solves] = await Promise.all([ + db + .select({ + id: schema.user.id, + username: schema.user.username, + className: schema.user.className, + avatar: schema.userProfile.avatar, + }) + .from(schema.user) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .where(where), + db + .select({ userId: schema.submission.userId, at: firstSolved }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where( + and( + where, + isNull(schema.submission.contestId), + inArray(schema.submission.result, SOLVED_RESULTS), + ), + ) + .groupBy(schema.submission.userId, schema.submission.problemId) + .having(since ? gte(firstSolved, since) : undefined), + ]) + + const byUser = new Map() + for (const row of solves) { + if (!row.at) continue + const list = byUser.get(row.userId) + if (list) list.push(row.at) + else byUser.set(row.userId, [row.at]) + } + return people.map((person) => { + const pairs = (byUser.get(person.id) ?? []) + .map((stamp) => [Date.parse(stamp), stamp] as const) + .sort((a, b) => a[0] - b[0]) + return { + id: person.id, + username: person.username, + className: person.className, + avatar: avatarOf(person.avatar), + times: pairs.map(([time]) => time), + stamps: pairs.map(([, stamp]) => stamp), + } + }) +} + +/** + * `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id —— + * 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。 + * `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。 + */ +function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) { + const list = entrants + .map((entrant) => { + let solved = entrant.times.length + if (cutoff !== null) { + solved = 0 + while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++ + } + return { + entrant, + solved, + reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY, + reachedAt: solved ? entrant.stamps[solved - 1]! : null, + } + }) + .filter((row) => includeZero || row.solved > 0) + .sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id) + return list.map((row, index): Standing => ({ + entrant: row.entrant, + rank: index + 1, + solved: row.solved, + reachedAt: row.reachedAt, + })) +} + +function toRow(standing: Standing, change: number | null): RankRow { + const { entrant } = standing + return { + rank: standing.rank, + user: sampleUser(entrant, null), + avatar: entrant.avatar, + className: entrant.className, + solved: standing.solved, + reachedAt: standing.reachedAt, + change, + } satisfies RankRow +} + +function periodStart(period: RankPeriod) { + if (period === "week") return weekStart() + if (period === "term") return termStart() + 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 表示该报「没有班级」。 + */ +async function resolveGroup( + user: AppEnv["Variables"]["user"], + scope: RankScope, + requested: string | undefined, +): Promise { + if (scope === "all") return null + let className = user?.className || null + if (isTeacherOrAbove(user)) className = requested || (await defaultClassFor(user!.id)) + if (!className) return undefined + return scope === "class" ? className : className.slice(0, 2) +} + +rankingRoutes.get("/rankings/board", optionalAuth, async (c) => { + const user = c.get("user") + const scope = rankScopeSchema.safeParse(c.req.query("scope")) + const period = rankPeriodSchema.safeParse(c.req.query("period")) + if (!scope.success || !period.success) + return failure(c, 400, "invalid-query", "排名范围或时间段不对") + const group = await resolveGroup(user, scope.data, c.req.query("className")?.trim()) + if (group === undefined) return failure(c, 400, "class-missing", "没有班级,看不了本班和本年级") + if (scope.data === "grade" && !/^\d{2}$/.test(group!)) + return failure(c, 400, "invalid-grade", "年级不对") + + const start = periodStart(period.data) + const changeSince = period.data === "week" ? dayStart() : weekStart() + const includeZero = scope.data === "class" + const isTeacher = isTeacherOrAbove(user) + + const [entrants, mine, hiddenUsers] = await Promise.all([ + loadEntrants(audienceWhere(scope.data, group), start), + user && !isTeacher + ? db + .select({ rankHiddenAt: schema.user.rankHiddenAt }) + .from(schema.user) + .where(eq(schema.user.id, user.id)) + .then(([row]) => row) + : undefined, + isTeacher && scope.data === "class" + ? db + .select({ id: schema.user.id, username: schema.user.username }) + .from(schema.user) + .where( + and( + eq(schema.user.className, group!), + inArray(schema.user.adminType, [...STUDENT_ROLES]), + eq(schema.user.isDisabled, false), + isNotNull(schema.user.rankHiddenAt), + ), + ) + .orderBy(schema.user.username) + : [], + ]) + + const now = standings(entrants, null, includeZero) + const before = new Map( + standings(entrants, Date.parse(changeSince), includeZero) + .filter((row) => row.solved > 0) + .map((row) => [row.entrant.id, row.rank]), + ) + const rowOf = (standing: Standing | undefined) => + standing + ? toRow( + standing, + before.has(standing.entrant.id) ? before.get(standing.entrant.id)! - standing.rank : null, + ) + : null + + // 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有对手) + const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1 + const me = meIndex >= 0 ? now[meIndex] : undefined + + const full = includeZero || c.req.query("full") === "1" + let picked = now + if (!full) { + const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME + picked = now.filter( + (_, index) => + index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER), + ) + } + + let trend: RankBoard["trend"] = null + if (me && period.data !== "week") { + const thisWeek = Date.parse(weekStart()) + const points = Array.from( + { length: TREND_WEEKS }, + (_, k) => thisWeek - (TREND_WEEKS - 1 - k) * WEEK_MS, + ).filter((point) => !start || point > Date.parse(start)) + const tracked = [now[meIndex - 1], me, now[meIndex + 1]].filter((row): row is Standing => !!row) + const snapshots = points.map((point) => { + const ranks = new Map( + standings(entrants, point, includeZero) + .filter((row) => row.solved > 0) + .map((row) => [row.entrant.id, row.rank]), + ) + return ranks + }) + trend = { + points: [...points.map((point) => new Date(point).toISOString()), new Date().toISOString()], + series: tracked.map((row) => ({ + userId: row.entrant.id, + ranks: [...snapshots.map((ranks) => ranks.get(row.entrant.id) ?? null), row.rank], + })), + } + } + + return success(c, { + scope: scope.data, + period: period.data, + className: group, + start, + changeSince, + total: now.length, + rows: picked.map((row) => rowOf(row)!), + complete: picked.length === now.length, + me: rowOf(me), + ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined), + behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined), + trend, + hidden: !!mine?.rankHiddenAt, + hiddenUsers: hiddenUsers.map((row): SampleUser => sampleUser(row, null)), + } satisfies RankBoard) +}) + +/** + * 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 —— + * 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。 + * 不计入排名的人分子分母都不算:抄来的题不该替全班加分。 + */ +rankingRoutes.get("/rankings/classes", async (c) => { + const where = and( + rankedStudents, + isNotNull(schema.user.className), + ne(schema.user.className, ""), + )! + const term = termStart() + const week = Date.parse(weekStart()) + const entrants = await loadEntrants(where, term) + + const classes = new Map() + for (const entrant of entrants) { + const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 } + entry.members++ + entry.term += entrant.times.length + entry.week += entrant.times.filter((time) => time >= week).length + classes.set(entrant.className!, entry) + } + + const result = [...classes] + .filter(([, entry]) => entry.term > 0) + .map(([className, entry]) => ({ + className, + members: entry.members, + perCapita: rounded(entry.term / entry.members, 1), + weekGain: rounded(entry.week / entry.members, 1), + })) + .sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain) + return success( + c, + result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem), + ) +}) + +/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */ +rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => { + const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim()) + if (!className) return failure(c, 400, "class-missing", "没有班级") + + const thisWeek = Date.parse(weekStart()) + const since = new Date(thisWeek - CHAMPION_WEEKS * WEEK_MS).toISOString() + const entrants = await loadEntrants(audienceWhere("class", className), since) + + const champions: WeeklyChampion[] = [] + for (let k = 1; k <= CHAMPION_WEEKS; k++) { + const from = thisWeek - k * WEEK_MS + const inWeek = entrants.map((entrant) => { + const keep = entrant.times + .map((time, index) => [time, entrant.stamps[index]!] as const) + .filter(([time]) => time >= from && time < from + WEEK_MS) + return { + ...entrant, + times: keep.map(([time]) => time), + stamps: keep.map(([, stamp]) => stamp), + } + }) + const [top] = standings(inWeek, null, false) + if (!top) continue + champions.push({ + weekStart: new Date(from).toISOString(), + user: sampleUser(top.entrant, null), + avatar: top.entrant.avatar, + solved: top.solved, + } satisfies WeeklyChampion) + } + return success(c, champions) +}) + +/** + * 老师把一个学生设成不计入排名 / 恢复。怀疑抄代码时用(用户 2026-10-10 定的)。 + * 所有排名页的榜和首页周榜都不出现他;他自己看得到「不计入排名」;比赛排名不受影响。 + */ +rankingRoutes.put("/rankings/hidden/:userId", requireTeacher, async (c) => { + const userId = Number(c.req.param("userId")) + if (!Number.isInteger(userId)) return failure(c, 400, "invalid-user", "用户不对") + const body = await parseBody(c, rankHiddenRequestSchema) + if (!body.success) return body.response + + const updated = await db + .update(schema.user) + .set({ rankHiddenAt: body.data.hidden ? new Date().toISOString() : null }) + .where(and(eq(schema.user.id, userId), inArray(schema.user.adminType, [...STUDENT_ROLES]))) + .returning({ id: schema.user.id }) + if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生") + return success(c, null) +}) diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index 982717b..a2acc8e 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -1,9 +1,8 @@ -import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract" +import type { Quote, WebsiteConfig } from "@oj2/contract" import { asc, desc, eq } from "drizzle-orm" import { Hono } from "hono" import { resolve } from "node:path" -import { onlineCount } from "../auth/presence" import { config } from "../config" import { db, schema } from "../db" import { failure, success } from "../http" @@ -26,14 +25,6 @@ siteRoutes.get("/site", async (c) => { } satisfies WebsiteConfig) }) -/** - * 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份, - * 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。 - */ -siteRoutes.get("/site/online", async (c) => { - return success(c, { count: await onlineCount() } satisfies OnlineCount) -}) - // 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里) const fallbackQuotes = [ { diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index 8a997e1..dde0762 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -83,6 +83,18 @@ export function weekStart(now: Date | number | string = new Date()): string { return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString() } +/** + * 「这学期」从哪天零点(东八区)算起:秋季学期 9 月 1 日起(到次年 1 月都算),春季学期 + * 2 月 1 日起(7、8 月的暑假也归春季,开学前没人做题,不影响)。返回 ISO 字符串。 + */ +export function termStart(now: Date | number | string = new Date()): string { + const wall = toWallClock(now) + const year = wall.getUTCFullYear() + const month = wall.getUTCMonth() + 1 + const [termYear, termMonth] = month >= 9 ? [year, 9] : month >= 2 ? [year, 2] : [year - 1, 9] + return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString() +} + /** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */ export function shiftMonthsByCalendar(instant: Date, months: number): Date { const wall = toWallClock(instant) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 6696815..2d0b6e2 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -6,7 +6,6 @@ import { type ContestList, type ContestClassView, type ContestScoreboard, - type ActivityRankItem, type FormatCodeResponse, type Metrics, type TutorialSummary, @@ -16,9 +15,11 @@ import { type ClassLesson, type KnowledgeMap, type ClassComparisonResponse, - type ClassRankItem, - type ClassUserRank, - type UserRank, + type ClassBattleItem, + type RankBoard, + type RankPeriod, + type RankScope, + type WeeklyChampion, type WeeklyRank, type ProblemRank, type CreateSubmissionResponse, @@ -64,7 +65,6 @@ import type { Submission, SubmissionListPayload, SubmitCodePayload, - OnlineCount, Tutorial, TutorialProgress, } from "utils/types" @@ -77,11 +77,6 @@ import type { */ const loadDetailSchemas = () => import("./detailSchemas") -/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ -export function getOnlineCount() { - return api.get("site/online") -} - export async function getProblemList( offset = 0, limit = 10, @@ -227,19 +222,41 @@ export function getSubmissionStatisticsGrid( } /** - * 全服榜单。上限(100 名)由服务端定,调用方只管翻页 —— - * 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。 + * 排名页的榜:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部。学生看自己的班和年级, + * 老师用 `className` 选班(不给就是最近上课的班)。`full` 把本年级 / 全服中间折起来的那段也要回来 */ -export function getRank(offset: number, limit: number) { - return api.get("rankings/users", { params: { offset, limit } }) +export function getRankBoard( + scope: RankScope, + period: RankPeriod, + options: { className?: string; full?: boolean } = {}, +) { + return api.get("rankings/board", { + params: { + scope, + period, + className: options.className || undefined, + full: options.full ? 1 : undefined, + }, + }) } -export function getActivityRank(start: string) { - return api.get("rankings/activity", { - params: { start }, +/** 班级对抗:全服各班这学期人均做对,外加这周人均涨了多少 */ +export function getClassBattle() { + return api.get("rankings/classes") +} + +/** 本班最近几周的每周冠军 */ +export function getWeeklyChampions(className?: string) { + return api.get("rankings/champions", { + params: { className: className || undefined }, }) } +/** 老师把学生设成不计入排名 / 恢复 */ +export function setRankHidden(userId: number, hidden: boolean) { + return api.put(`rankings/hidden/${userId}`, { hidden }) +} + /** * 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global —— * 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。 @@ -281,16 +298,6 @@ export function getLastVisit() { return api.get("me/last-visit") } -export function getClassRank(grade?: number | null) { - return api.get("rankings/classes", { params: { grade } }) -} - -export function getUserClassRank(scope?: "all" | "window", offset?: number, limit?: number) { - return api.get("me/class-rank", { - params: { scope, offset, limit }, - }) -} - export function getClassPK(classNames: string[], startTime?: string, endTime?: string) { return api.post("classes/comparison", { classNames, diff --git a/apps/web/src/oj/class/pk.vue b/apps/web/src/oj/class/pk.vue index 718cb3f..85b6a64 100644 --- a/apps/web/src/oj/class/pk.vue +++ b/apps/web/src/oj/class/pk.vue @@ -60,7 +60,7 @@ const aiLoading = aiStream.waiting const aiContent = ref("") const showAIModal = ref(false) -// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」 +// 长时段选项(LONG_DURATION_OPTIONS),外加一个「全部时间」 const timeRangeOptions: SelectOption[] = [ { label: "全部时间", value: "" }, ...LONG_DURATION_OPTIONS, diff --git a/apps/web/src/oj/rank/components/Chart.vue b/apps/web/src/oj/rank/components/Chart.vue deleted file mode 100644 index be4f162..0000000 --- a/apps/web/src/oj/rank/components/Chart.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - diff --git a/apps/web/src/oj/rank/components/ClassBattle.vue b/apps/web/src/oj/rank/components/ClassBattle.vue new file mode 100644 index 0000000..4a828c0 --- /dev/null +++ b/apps/web/src/oj/rank/components/ClassBattle.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/apps/web/src/oj/rank/components/RankAvatar.vue b/apps/web/src/oj/rank/components/RankAvatar.vue new file mode 100644 index 0000000..e90d2e2 --- /dev/null +++ b/apps/web/src/oj/rank/components/RankAvatar.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/apps/web/src/oj/rank/components/RankMe.vue b/apps/web/src/oj/rank/components/RankMe.vue new file mode 100644 index 0000000..b658797 --- /dev/null +++ b/apps/web/src/oj/rank/components/RankMe.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/apps/web/src/oj/rank/components/RankMedal.vue b/apps/web/src/oj/rank/components/RankMedal.vue deleted file mode 100644 index 477cf58..0000000 --- a/apps/web/src/oj/rank/components/RankMedal.vue +++ /dev/null @@ -1,31 +0,0 @@ - - diff --git a/apps/web/src/oj/rank/components/RankPodium.vue b/apps/web/src/oj/rank/components/RankPodium.vue new file mode 100644 index 0000000..5df78b2 --- /dev/null +++ b/apps/web/src/oj/rank/components/RankPodium.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/apps/web/src/oj/rank/components/RankTrack.vue b/apps/web/src/oj/rank/components/RankTrack.vue new file mode 100644 index 0000000..4e2acb3 --- /dev/null +++ b/apps/web/src/oj/rank/components/RankTrack.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/apps/web/src/oj/rank/components/RankTrend.vue b/apps/web/src/oj/rank/components/RankTrend.vue new file mode 100644 index 0000000..9081fda --- /dev/null +++ b/apps/web/src/oj/rank/components/RankTrend.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index f54607a..e2460e2 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -1,934 +1,851 @@ diff --git a/apps/web/src/oj/rank/palette.ts b/apps/web/src/oj/rank/palette.ts new file mode 100644 index 0000000..dd8a414 --- /dev/null +++ b/apps/web/src/oj/rank/palette.ts @@ -0,0 +1,33 @@ +import { useDark } from "@vueuse/core" + +/** + * 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、 + * 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。 + * 暗色下灰条和浅绿换深一档,不然在暗底上发白。 + */ +export function useRankPalette() { + const isDark = useDark() + return computed(() => ({ + me: "#18a058", + chase: "#2f6fd0", + threat: "#c76a12", + mate: isDark.value ? "#2f6b47" : "#a8dcbd", + bar: isDark.value ? "#4a525c" : "#c9d3dc", + medal: [ + { + color: isDark.value ? "#e2b33b" : "#9a6700", + background: isDark.value ? "rgba(240,180,40,0.22)" : "#fbf1d9", + }, + { + color: isDark.value ? "#a7b1bd" : "#5f6b7a", + background: isDark.value ? "rgba(120,135,155,0.22)" : "#eef1f4", + }, + { + color: isDark.value ? "#c58b62" : "#8c5a3c", + background: isDark.value ? "rgba(170,110,70,0.22)" : "#f6ece5", + }, + ], + })) +} + +export type RankPalette = ReturnType["value"] diff --git a/apps/web/src/oj/rank/utils.ts b/apps/web/src/oj/rank/utils.ts new file mode 100644 index 0000000..7c7053b --- /dev/null +++ b/apps/web/src/oj/rank/utils.ts @@ -0,0 +1,63 @@ +import type { RankPeriod, RankRow, RankScope } from "@oj2/contract" +import { classLabel } from "oj/submission/utils" + +export const SCOPE_OPTIONS: { value: RankScope; label: string }[] = [ + { value: "class", label: "本班" }, + { value: "grade", label: "本年级" }, + { value: "all", label: "全服" }, +] + +export const PERIOD_OPTIONS: { value: RankPeriod; label: string }[] = [ + { value: "week", label: "这周" }, + { value: "term", label: "这学期" }, + { value: "all", label: "全部" }, +] + +export function scopeLabel(scope: RankScope) { + return SCOPE_OPTIONS.find((option) => option.value === scope)!.label +} + +export function periodLabel(period: RankPeriod) { + return PERIOD_OPTIONS.find((option) => option.value === period)!.label +} + +/** 榜的标题:本班写班名,本年级写「25 级」,全服就是「全服」 */ +export function groupTitle(scope: RankScope, className: string | null) { + if (scope === "class" && className) return classLabel(className) + if (scope === "grade" && className) return `${className} 级` + return "全服" +} + +/** + * 两个人做到同样多道的时间差,说成学生的话:「6 秒」「2 分 40 秒」「3 小时」「2 天」。 + * 一节课的最后一道,全班常常差在几秒到几十秒 —— 这就是竞争感的来源,秒要写出来。 + */ +export function gapText(earlier: string, later: string) { + const seconds = Math.max(0, Math.round((Date.parse(later) - Date.parse(earlier)) / 1000)) + if (seconds === 0) return "同一秒" + if (seconds < 60) return `${seconds} 秒` + if (seconds < 3600) { + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟` + } + if (seconds < 86_400) return `${Math.floor(seconds / 3600)} 小时` + return `${Math.floor(seconds / 86_400)} 天` +} + +/** + * 再做对几道能超过 `target`:一样多时先做到的在前,后做到的人只能靠多做 1 道超过 —— + * 所以永远是「差几道 + 1」。 + */ +export function toPass(me: RankRow, target: RankRow) { + return target.solved - me.solved + 1 +} + +/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */ +export function hottest(rows: RankRow[]) { + let best: RankRow | null = null + for (const row of rows) { + if ((row.change ?? 0) >= 3 && (!best || row.change! > best.change!)) best = row + } + return best +} diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index 7d051f9..cc7e97c 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -254,11 +254,6 @@ export const CODE_TEMPLATES = { SQL: blankTemplate, } as const -export enum ChartType { - Rank, - Activity, -} - // 成就稀有度 export const RARITY_LABEL: Record = { bronze: "青铜", @@ -329,11 +324,10 @@ export const DURATION_OPTIONS = [ ] as const /** - * 榜单和班级对比用的长时段,一周起步。 + * 班级对比用的长时段,一周起步。 * - * 这两个页面看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的 - * AC 数拉出来比不出什么。原来 rank/list.vue 和 class/pk.vue 各手抄了一份同样的五条, - * pk.vue 里还留着「与 rank/list.vue 保持一致」的注释,现在从上面那份派生。 + * 看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的 + * AC 数拉出来比不出什么。从上面那份派生,不另抄一份。 */ export const LONG_DURATION_OPTIONS = DURATION_OPTIONS.filter( (option) => !["hours:1", "hours:2", "days:1"].includes(option.value), diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 5b51903..0a0441f 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -271,12 +271,11 @@ export type { SessionUser } from "@oj2/contract" export type Rank = RankProfile /** 榜单里「我」的位置:比 Rank 多一个全服名次 */ -export type { MyRank } from "@oj2/contract" /** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */ export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract" -export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contract" +export type { ClassComparison } from "@oj2/contract" /** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */ export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract" @@ -310,7 +309,7 @@ export type BlankContest = Omit< */ export type { ContestSubmissionInfo } from "@oj2/contract" -export type { WebsiteConfig, OnlineCount } from "@oj2/contract" +export type { WebsiteConfig } from "@oj2/contract" export type { JudgeServer as Server, diff --git a/packages/contract/src/account.ts b/packages/contract/src/account.ts index 49207b0..bdace87 100644 --- a/packages/contract/src/account.ts +++ b/packages/contract/src/account.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { paginatedSchema, sampleUserSchema } from "./common" +import { sampleUserSchema } from "./common" import { userProfileSchema } from "./auth" export const registerRequestSchema = z.object({ @@ -37,27 +37,6 @@ export const rankProfileSchema = z.object({ isOnline: z.boolean().nullable().default(null), }) -/** - * 榜单里「我」的位置。`rank` 是**全服名次**,与当前翻到第几页无关 —— - * 前 100 名之外的学生也拿得到,页面靠它单独显示一行。 - * - * 名次口径与列表的排序完全一致(AC 降序 → 提交数升序 → id 升序), - * 所以「我的名次」和「我在表格里的行号」永远对得上;三个键都相同才算并列。 - */ -export const myRankSchema = rankProfileSchema.extend({ - rank: z.number().int().positive(), -}) - -export const userRankSchema = paginatedSchema(rankProfileSchema).extend({ - /** 未登录、或身份不入榜(教师/超管)时为 null */ - me: myRankSchema.nullable(), -}) - -export const activityRankItemSchema = z.object({ - username: z.string(), - count: z.number().int().nonnegative(), -}) - /** * 周榜的一行。`solvedCount` 是**本周首次 AC 的题目数** —— 不是「本周 AC 过的去重题数」。 * 后者会把上周就做出来的题重交一次也算成本周成绩,等于给刷榜留了个口子; @@ -99,9 +78,6 @@ export type RegisterRequest = z.infer export type UpdateProfileRequest = z.infer export type ProblemRank = z.infer export type RankProfile = z.infer -export type UserRank = z.infer -export type MyRank = z.infer -export type ActivityRankItem = z.infer export type WeeklyRankItem = z.infer export type WeeklyRank = z.infer export type Metrics = z.infer diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index 1e881c1..e7af81d 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -3,31 +3,6 @@ import { z } from "zod" import { judgeStatusSchema } from "./judge-status" import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language" -export const classRankItemSchema = z.object({ - className: z.string(), - userCount: z.number().int(), - totalAc: z.number().int(), - totalSubmission: z.number().int(), - avgAc: z.number(), - acRate: z.number(), - rank: z.number().int(), -}) - -export const classUserRankItemSchema = z.object({ - userId: z.number().int(), - username: z.string(), - acceptedNumber: z.number().int(), - submissionNumber: z.number().int(), - rank: z.number().int(), -}) - -export const classUserRankSchema = z.object({ - className: z.string(), - myRank: z.number().int(), - total: z.number().int(), - ranks: z.array(classUserRankItemSchema), -}) - export const classComparisonSchema = z.object({ className: z.string(), userCount: z.number().int(), @@ -225,8 +200,6 @@ export const classLessonSchema = z.object({ problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), }) -export type ClassRankItem = z.infer -export type ClassUserRank = z.infer export type ClassComparison = z.infer export type ClassComparisonResponse = z.infer @@ -240,5 +213,4 @@ export type ClassBoardStudent = z.infer export type ClassBoardCell = z.infer export type ClassActivityProblem = z.infer -export type ClassUserRankItem = z.infer export type ClassComparisonRequest = z.infer diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index af61b57..b180b96 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -12,6 +12,7 @@ export * from "./judge-status" export * from "./language" export * from "./problem" export * from "./problemset" +export * from "./ranking" export * from "./roles" export * from "./site" export * from "./submission" diff --git a/packages/contract/src/ranking.ts b/packages/contract/src/ranking.ts new file mode 100644 index 0000000..d6139f6 --- /dev/null +++ b/packages/contract/src/ranking.ts @@ -0,0 +1,103 @@ +import { z } from "zod" + +import { sampleUserSchema } from "./common" + +/** + * 排名页(设计稿「排名重设计」G1–G3)。用户要的是**激发竞争、一眼看出排名**,所以: + * + * - **同样多不并列**:做对多的在前,一样多的**先做到的在前**(`reachedAt` 早的在前)。 + * 课堂驱动下一个班十几个人同分是常态,原来按「提交少的在前」排等于罚多试的人; + * 按先后排,名次差的是「这节课谁先做完」。 + * - 「做对」= 这道题**第一次**做对落在这段时间里(比赛里的提交不算),老题重交不算成绩。 + */ +export const rankScopeSchema = z.enum(["class", "grade", "all"]) +export const rankPeriodSchema = z.enum(["week", "term", "all"]) + +export const rankRowSchema = z.object({ + rank: z.number().int().positive(), + user: sampleUserSchema, + /** 自己上传的头像;还是默认头像时为 null,前端画名字最后一个字 */ + avatar: z.string().nullable(), + className: z.string().nullable(), + solved: z.number().int().nonnegative(), + /** 做到这个数的时刻(最后一道新做对的题);一道没做对时为 null */ + reachedAt: z.string().nullable(), + /** + * 比 `changeSince` 那一刻升了几名,正数是升。那时还一道没做对(不在榜上)时为 null —— + * 从无到有不算「升了几名」,否则开学第一周人人都是 ↑30。 + */ + change: z.number().int().nullable(), +}) + +/** 我和对手每周的名次,给「我和对手的名次」那张走势图 */ +export const rankTrendSchema = z.object({ + /** 每个点的时刻(周一零点,也就是上周日晚上),最后一个是现在 */ + points: z.array(z.string()), + series: z.array( + z.object({ + userId: z.number().int(), + /** 和 points 一一对应;那时还没上榜为 null */ + ranks: z.array(z.number().int().positive().nullable()), + }), + ), +}) + +export const rankBoardSchema = z.object({ + scope: rankScopeSchema, + period: rankPeriodSchema, + /** 本班 = 班号;本年级 = 年级(班号前两位,如 "25");全服 = null */ + className: z.string().nullable(), + /** 这段时间从哪一刻算起;「全部」为 null */ + start: z.string().nullable(), + /** `change` 拿哪一刻比:这周 = 今天零点,这学期 / 全部 = 本周一零点 */ + changeSince: z.string(), + /** 榜上一共几个人。本班把一道没做对的也算上(全班名单),本年级 / 全服只算做对过的 */ + total: z.number().int().nonnegative(), + /** + * 本班、或者带了 `full=1` 时是全部;否则是前面一段 + 我附近一段(`complete = false`), + * 中间折起来,按名次断开的地方就是折叠处 + */ + rows: z.array(rankRowSchema), + complete: z.boolean(), + /** 我在这张榜上的那一行。没登录、老师、不计入排名、或者这段时间一道没做对时为 null */ + me: rankRowSchema.nullable(), + /** 名次紧挨在我前面、后面的人 */ + ahead: rankRowSchema.nullable(), + behind: rankRowSchema.nullable(), + /** 只有「这学期」「全部」且有 me 时才有:这周清零,画走势没意义 */ + trend: rankTrendSchema.nullable(), + /** 我被老师设成了不计入排名(学生自己看得到这句话,别人看不到他) */ + hidden: z.boolean(), + /** 老师看本班时:这个班不计入排名的人。其余情况为空 */ + hiddenUsers: z.array(sampleUserSchema), +}) + +/** 班级对抗:人均做对,按这学期算;`weekGain` 是这周人均涨了多少 */ +export const classBattleItemSchema = z.object({ + rank: z.number().int().positive(), + className: z.string(), + members: z.number().int().positive(), + perCapita: z.number(), + weekGain: z.number(), +}) + +/** 本班每周冠军:最近几个已经结束的周,每周做对新题最多的人 */ +export const weeklyChampionSchema = z.object({ + weekStart: z.string(), + user: sampleUserSchema, + avatar: z.string().nullable(), + solved: z.number().int().positive(), +}) + +export const rankHiddenRequestSchema = z.object({ + hidden: z.boolean(), +}) + +export type RankScope = z.infer +export type RankPeriod = z.infer +export type RankRow = z.infer +export type RankTrend = z.infer +export type RankBoard = z.infer +export type ClassBattleItem = z.infer +export type WeeklyChampion = z.infer +export type RankHiddenRequest = z.infer diff --git a/packages/contract/src/site.ts b/packages/contract/src/site.ts index 8a43b8b..1f28ce8 100644 --- a/packages/contract/src/site.ts +++ b/packages/contract/src/site.ts @@ -11,11 +11,6 @@ export const websiteConfigSchema = z.object({ enableMaxkb: z.boolean(), }) -/** 当前在线人数。只有聚合值 —— 「某某在不在线」是个人状态,不往匿名接口放 */ -export const onlineCountSchema = z.object({ - count: z.number().int().nonnegative(), -}) - /** * 一言。`from` 是出处(作品),`fromWho` 是谁说的,`type` 是数据集的分类名(动画、诗词……,界面不显示, * 只拿来判断出处要不要加书名号), @@ -31,4 +26,3 @@ export const quoteSchema = z.object({ export type WebsiteConfig = z.infer export type Quote = z.infer -export type OnlineCount = z.infer