From 5e7b7b1cda8a701c9acdf331ea9d063d2d56cbe5 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Mon, 8 Jun 2026 15:43:54 +0800 Subject: [PATCH] Add region-based leaderboard support --- db/migrations/0004_user_region.sql | 15 + db/migrations/meta/0004_snapshot.json | 3361 +++++++++++++++++ db/migrations/meta/_journal.json | 7 + docs/api-reference.md | 157 +- .../services/app/bootstrap-service.test.ts | 1 + .../services/app/regions-service.test.ts | 74 + .../gamification/leaderboard-service.test.ts | 38 +- .../learning/challenge-service.test.ts | 18 +- src/__tests__/utils/errors.test.ts | 27 + src/__tests__/utils/json-parser.test.ts | 36 + src/config/regions.ts | 46 + src/db/schema.ts | 19 + src/index.ts | 3 + src/middleware/auth.ts | 1 + src/routes/app-api.ts | 21 +- src/routes/auth.ts | 2 + src/services/app/bootstrap-service.ts | 5 + src/services/app/regions-service.ts | 159 + .../gamification/leaderboard-service.ts | 63 + src/services/learning/challenge-service.ts | 3 +- .../learning/leaderboard-api-service.ts | 122 +- src/types/app-api.ts | 30 + src/utils/errors.ts | 13 + src/utils/json-parser.ts | 20 + 24 files changed, 4221 insertions(+), 20 deletions(-) create mode 100644 db/migrations/0004_user_region.sql create mode 100644 db/migrations/meta/0004_snapshot.json create mode 100644 src/__tests__/services/app/regions-service.test.ts create mode 100644 src/__tests__/utils/errors.test.ts create mode 100644 src/__tests__/utils/json-parser.test.ts create mode 100644 src/config/regions.ts create mode 100644 src/services/app/regions-service.ts create mode 100644 src/utils/json-parser.ts diff --git a/db/migrations/0004_user_region.sql b/db/migrations/0004_user_region.sql new file mode 100644 index 0000000..60c8dcc --- /dev/null +++ b/db/migrations/0004_user_region.sql @@ -0,0 +1,15 @@ +ALTER TABLE `users` ADD `region_code` varchar(20);--> statement-breakpoint +ALTER TABLE `users` ADD `region_selected_at` datetime;--> statement-breakpoint +ALTER TABLE `users` ADD `region_changed_at` datetime;--> statement-breakpoint +CREATE INDEX `idx_users_region` ON `users` (`region_code`);--> statement-breakpoint +CREATE TABLE `user_region_change_logs` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `from_region_code` varchar(20), + `to_region_code` varchar(20) NOT NULL, + `changed_at` datetime DEFAULT CURRENT_TIMESTAMP, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `user_region_change_logs_id` PRIMARY KEY(`id`) +);--> statement-breakpoint +ALTER TABLE `user_region_change_logs` ADD CONSTRAINT `user_region_change_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_region_change_user_changed` ON `user_region_change_logs` (`user_id`,`changed_at`); diff --git a/db/migrations/meta/0004_snapshot.json b/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..aab72ab --- /dev/null +++ b/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,3361 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "336479f9-32f5-490f-84f4-c505f206b02b", + "prevId": "0c272a13-5ef4-40ac-adc8-5c10d9c351fb", + "tables": { + "account_migrations": { + "name": "account_migrations", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guest_user_id": { + "name": "guest_user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formal_user_id": { + "name": "formal_user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('apple','google','phone')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_migration_id": { + "name": "client_migration_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('in_progress','completed','failed')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'in_progress'" + }, + "migration_summary": { + "name": "migration_summary", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uk_migration_guest_client": { + "name": "uk_migration_guest_client", + "columns": [ + "guest_user_id", + "client_migration_id" + ], + "isUnique": true + }, + "uk_migration_guest_provider": { + "name": "uk_migration_guest_provider", + "columns": [ + "guest_user_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": { + "account_migrations_guest_user_id_users_id_fk": { + "name": "account_migrations_guest_user_id_users_id_fk", + "tableFrom": "account_migrations", + "tableTo": "users", + "columnsFrom": [ + "guest_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "account_migrations_formal_user_id_users_id_fk": { + "name": "account_migrations_formal_user_id_users_id_fk", + "tableFrom": "account_migrations", + "tableTo": "users", + "columnsFrom": [ + "formal_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_migrations_id": { + "name": "account_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "achievements": { + "name": "achievements", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('knowledge','behavior')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "achievements_id": { + "name": "achievements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ad_recovery_sessions": { + "name": "ad_recovery_sessions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('hearts','bonusAttempts','streakProtection')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','settling','completed','failed','expired')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "client_request_id": { + "name": "client_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "complete_request_id": { + "name": "complete_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "enum('ios','android','harmony','web')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ad_provider": { + "name": "ad_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ad_placement_id": { + "name": "ad_placement_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_reward_token": { + "name": "provider_reward_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_before": { + "name": "progress_before", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_after": { + "name": "progress_after", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_error": { + "name": "provider_error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duplicate_count": { + "name": "duplicate_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_ad_recovery_user_client_request": { + "name": "uk_ad_recovery_user_client_request", + "columns": [ + "user_id", + "client_request_id" + ], + "isUnique": true + }, + "idx_ad_recovery_user_type_status_created": { + "name": "idx_ad_recovery_user_type_status_created", + "columns": [ + "user_id", + "type", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ad_recovery_sessions_user_id_users_id_fk": { + "name": "ad_recovery_sessions_user_id_users_id_fk", + "tableFrom": "ad_recovery_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ad_recovery_sessions_id": { + "name": "ad_recovery_sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "admin_audit_log": { + "name": "admin_audit_log", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "admin_id": { + "name": "admin_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "admin_audit_log_id": { + "name": "admin_audit_log_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "admin_users": { + "name": "admin_users", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','super_admin')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'admin'" + }, + "is_active": { + "name": "is_active", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_admin_username": { + "name": "uk_admin_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "admin_users_id": { + "name": "admin_users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_settings_key": { + "name": "app_settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "question_count": { + "name": "question_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "enum('active','inactive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_slug": { + "name": "uk_slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "categories_id": { + "name": "categories_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "challenge_session_answers": { + "name": "challenge_session_answers", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submit_request_id": { + "name": "submit_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer_order": { + "name": "answer_order", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "correct": { + "name": "correct", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_ms": { + "name": "time_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "combo_count": { + "name": "combo_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "result_snapshot": { + "name": "result_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_challenge_answer_session_question": { + "name": "uk_challenge_answer_session_question", + "columns": [ + "session_id", + "question_id" + ], + "isUnique": true + }, + "uk_challenge_answer_session_request": { + "name": "uk_challenge_answer_session_request", + "columns": [ + "session_id", + "submit_request_id" + ], + "isUnique": true + }, + "idx_challenge_answer_user_submitted": { + "name": "idx_challenge_answer_user_submitted", + "columns": [ + "user_id", + "submitted_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "challenge_session_answers_session_id_challenge_sessions_id_fk": { + "name": "challenge_session_answers_session_id_challenge_sessions_id_fk", + "tableFrom": "challenge_session_answers", + "tableTo": "challenge_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_session_answers_user_id_users_id_fk": { + "name": "challenge_session_answers_user_id_users_id_fk", + "tableFrom": "challenge_session_answers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_session_answers_question_id_questions_id_fk": { + "name": "challenge_session_answers_question_id_questions_id_fk", + "tableFrom": "challenge_session_answers", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "challenge_session_answers_id": { + "name": "challenge_session_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "challenge_sessions": { + "name": "challenge_sessions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_id": { + "name": "track_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_id": { + "name": "chapter_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','in_progress','completed','abandoned','expired')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "client_request_id": { + "name": "client_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "complete_request_id": { + "name": "complete_request_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "question_ids": { + "name": "question_ids", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_questions": { + "name": "total_questions", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5 + }, + "answered_count": { + "name": "answered_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "correct_count": { + "name": "correct_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_reward_eligible": { + "name": "high_reward_eligible", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_before": { + "name": "progress_before", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_after": { + "name": "progress_after", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_challenge_session_user_client_request": { + "name": "uk_challenge_session_user_client_request", + "columns": [ + "user_id", + "client_request_id" + ], + "isUnique": true + }, + "uk_challenge_session_user_complete_request": { + "name": "uk_challenge_session_user_complete_request", + "columns": [ + "user_id", + "complete_request_id" + ], + "isUnique": true + }, + "idx_challenge_session_user_status_created": { + "name": "idx_challenge_session_user_status_created", + "columns": [ + "user_id", + "status", + "created_at" + ], + "isUnique": false + }, + "idx_challenge_session_chapter_status": { + "name": "idx_challenge_session_chapter_status", + "columns": [ + "chapter_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "challenge_sessions_user_id_users_id_fk": { + "name": "challenge_sessions_user_id_users_id_fk", + "tableFrom": "challenge_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_sessions_category_id_categories_id_fk": { + "name": "challenge_sessions_category_id_categories_id_fk", + "tableFrom": "challenge_sessions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_sessions_chapter_id_skill_tree_id_fk": { + "name": "challenge_sessions_chapter_id_skill_tree_id_fk", + "tableFrom": "challenge_sessions", + "tableTo": "skill_tree", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "challenge_sessions_id": { + "name": "challenge_sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "inventory_transactions": { + "name": "inventory_transactions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inventory_item_id": { + "name": "inventory_item_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "enum('coins','streak_shield','double_xp_potion','heart_supply','hint_feather','mascot_outfit')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "enum('grant','consume','adjust')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity_delta": { + "name": "quantity_delta", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balance_after": { + "name": "balance_after", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "enum('challenge','daily_task','level_up','theme_node','chest','shop_purchase','ad_recovery','subscription','admin_grant','system_adjust','leaderboard_settlement')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_inventory_transaction_idempotency": { + "name": "uk_inventory_transaction_idempotency", + "columns": [ + "user_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_inventory_transaction_user_created": { + "name": "idx_inventory_transaction_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_inventory_transaction_source": { + "name": "idx_inventory_transaction_source", + "columns": [ + "source_type", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inventory_transactions_user_id_users_id_fk": { + "name": "inventory_transactions_user_id_users_id_fk", + "tableFrom": "inventory_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fk_inv_tx_item": { + "name": "fk_inv_tx_item", + "tableFrom": "inventory_transactions", + "tableTo": "user_inventory_items", + "columnsFrom": [ + "inventory_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inventory_transactions_id": { + "name": "inventory_transactions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_cards": { + "name": "knowledge_cards", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deep_dive": { + "name": "deep_dive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_ref": { + "name": "source_ref", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_question": { + "name": "uk_question", + "columns": [ + "question_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "knowledge_cards_question_id_questions_id_fk": { + "name": "knowledge_cards_question_id_questions_id_fk", + "tableFrom": "knowledge_cards", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_cards_id": { + "name": "knowledge_cards_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "leaderboard_snapshots": { + "name": "leaderboard_snapshots", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "enum('bronze','silver','gold','platinum','diamond','master','grandmaster','champion','legend','mythic')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weekly_xp": { + "name": "weekly_xp", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rank": { + "name": "rank", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "league": { + "name": "league", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_leaderboard_snapshot_user_week": { + "name": "uk_leaderboard_snapshot_user_week", + "columns": [ + "user_id", + "week_start" + ], + "isUnique": true + }, + "idx_leaderboard_snapshot_group_rank": { + "name": "idx_leaderboard_snapshot_group_rank", + "columns": [ + "group_id", + "week_start", + "rank" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "leaderboard_snapshots_id": { + "name": "leaderboard_snapshots_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "question_ratings": { + "name": "question_ratings", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "enum('good','bad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_user_question_rating": { + "name": "uk_user_question_rating", + "columns": [ + "user_id", + "question_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "question_ratings_id": { + "name": "question_ratings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stem": { + "name": "stem", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "enum('text','image','video','audio')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "correct_answer": { + "name": "correct_answer", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distractors": { + "name": "distractors", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dynamic_difficulty": { + "name": "dynamic_difficulty", + "type": "decimal(3,1)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "enum('system','ugc')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'system'" + }, + "creator_id": { + "name": "creator_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('draft','reviewing','published','archived')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "stats": { + "name": "stats", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"timesAnswered\":0,\"correctRate\":0,\"avgTimeMs\":0}')" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "reward_ledger": { + "name": "reward_ledger", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "enum('challenge_answer','challenge_completion','daily_task','streak_milestone','level_up','theme_node','knowledge_card','chest','shop_purchase','ad_recovery','leaderboard_settlement','subscription','admin_grant','system_adjust')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','settling','completed','failed','reversed')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_deltas": { + "name": "resource_deltas", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_before": { + "name": "state_before", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_after": { + "name": "state_after", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_reward_ledger_user_idempotency": { + "name": "uk_reward_ledger_user_idempotency", + "columns": [ + "user_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_reward_ledger_user_status_created": { + "name": "idx_reward_ledger_user_status_created", + "columns": [ + "user_id", + "status", + "created_at" + ], + "isUnique": false + }, + "idx_reward_ledger_source": { + "name": "idx_reward_ledger_source", + "columns": [ + "source_type", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reward_ledger_user_id_users_id_fk": { + "name": "reward_ledger_user_id_users_id_fk", + "tableFrom": "reward_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reward_ledger_id": { + "name": "reward_ledger_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_tree": { + "name": "skill_tree", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "questions_required": { + "name": "questions_required", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 4 + }, + "pass_threshold": { + "name": "pass_threshold", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "skill_tree_category_id_categories_id_fk": { + "name": "skill_tree_category_id_categories_id_fk", + "tableFrom": "skill_tree", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tree_id": { + "name": "skill_tree_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "enum('free','pro','proplus')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'free'" + }, + "platform": { + "name": "platform", + "type": "enum('huawei','apple','google')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_token": { + "name": "purchase_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_renew": { + "name": "auto_renew", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "enum('active','expired','cancelled')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_subscription_user": { + "name": "uk_subscription_user", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscriptions_id": { + "name": "subscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_achievements": { + "name": "user_achievements", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "achievement_id": { + "name": "achievement_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_user_achievement": { + "name": "uk_user_achievement", + "columns": [ + "user_id", + "achievement_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_achievements_id": { + "name": "user_achievements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_chapter_progress": { + "name": "user_chapter_progress", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_id": { + "name": "chapter_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('locked','unlocked','passed','perfect')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'locked'" + }, + "best_correct_count": { + "name": "best_correct_count", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uk_user_chapter": { + "name": "uk_user_chapter", + "columns": [ + "user_id", + "chapter_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_chapter_progress_user_id_users_id_fk": { + "name": "user_chapter_progress_user_id_users_id_fk", + "tableFrom": "user_chapter_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_chapter_progress_chapter_id_skill_tree_id_fk": { + "name": "user_chapter_progress_chapter_id_skill_tree_id_fk", + "tableFrom": "user_chapter_progress", + "tableTo": "skill_tree", + "columnsFrom": [ + "chapter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_chapter_progress_id": { + "name": "user_chapter_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_daily_progress": { + "name": "user_daily_progress", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress_date": { + "name": "progress_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "first_challenge_session_id": { + "name": "first_challenge_session_id", + "type": "char(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_challenge_completed_at": { + "name": "first_challenge_completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "challenge_sessions_completed": { + "name": "challenge_sessions_completed", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_reward_sessions_max": { + "name": "high_reward_sessions_max", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "high_reward_sessions_used": { + "name": "high_reward_sessions_used", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_reward_sessions_restored": { + "name": "high_reward_sessions_restored", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "daily_tasks_completed": { + "name": "daily_tasks_completed", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "daily_tasks_reward_claimed": { + "name": "daily_tasks_reward_claimed", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "xp_earned": { + "name": "xp_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "coins_earned": { + "name": "coins_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak_counted": { + "name": "streak_counted", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_daily_progress_user_date": { + "name": "uk_daily_progress_user_date", + "columns": [ + "user_id", + "progress_date" + ], + "isUnique": true + }, + "idx_daily_progress_date": { + "name": "idx_daily_progress_date", + "columns": [ + "progress_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_daily_progress_user_id_users_id_fk": { + "name": "user_daily_progress_user_id_users_id_fk", + "tableFrom": "user_daily_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fk_daily_progress_session": { + "name": "fk_daily_progress_session", + "tableFrom": "user_daily_progress", + "tableTo": "challenge_sessions", + "columnsFrom": [ + "first_challenge_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_daily_progress_id": { + "name": "user_daily_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_daily_tasks": { + "name": "user_daily_tasks", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daily_progress_id": { + "name": "daily_progress_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_date": { + "name": "task_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_type": { + "name": "task_type", + "type": "enum('complete_challenge','earn_xp','answer_correct','review_explanation','use_item','watch_ad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_count": { + "name": "target_count", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "current_count": { + "name": "current_count", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "enum('active','completed','reward_claimed','expired')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "reward_snapshot": { + "name": "reward_snapshot", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward_claimed_at": { + "name": "reward_claimed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_daily_task_user_date_task": { + "name": "uk_daily_task_user_date_task", + "columns": [ + "user_id", + "task_date", + "task_id" + ], + "isUnique": true + }, + "idx_daily_task_progress_status": { + "name": "idx_daily_task_progress_status", + "columns": [ + "daily_progress_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_daily_tasks_daily_progress_id_user_daily_progress_id_fk": { + "name": "user_daily_tasks_daily_progress_id_user_daily_progress_id_fk", + "tableFrom": "user_daily_tasks", + "tableTo": "user_daily_progress", + "columnsFrom": [ + "daily_progress_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_daily_tasks_user_id_users_id_fk": { + "name": "user_daily_tasks_user_id_users_id_fk", + "tableFrom": "user_daily_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_daily_tasks_id": { + "name": "user_daily_tasks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_feedback": { + "name": "user_feedback", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_context": { + "name": "page_context", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_feedback_id": { + "name": "user_feedback_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_inventory_items": { + "name": "user_inventory_items", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "enum('streak_shield','double_xp_potion','heart_supply','hint_feather','mascot_outfit')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "active_until": { + "name": "active_until", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_inventory_user_item": { + "name": "uk_inventory_user_item", + "columns": [ + "user_id", + "item_id" + ], + "isUnique": true + }, + "idx_inventory_user_active": { + "name": "idx_inventory_user_active", + "columns": [ + "user_id", + "active_until" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_inventory_items_user_id_users_id_fk": { + "name": "user_inventory_items_user_id_users_id_fk", + "tableFrom": "user_inventory_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_inventory_items_id": { + "name": "user_inventory_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_progress": { + "name": "user_progress", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "correct": { + "name": "correct", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_ms": { + "name": "time_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answered_at": { + "name": "answered_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_answered": { + "name": "idx_user_answered", + "columns": [ + "user_id", + "answered_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_progress_user_id_users_id_fk": { + "name": "user_progress_user_id_users_id_fk", + "tableFrom": "user_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_progress_question_id_questions_id_fk": { + "name": "user_progress_question_id_questions_id_fk", + "tableFrom": "user_progress", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_progress_id": { + "name": "user_progress_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_region_change_logs": { + "name": "user_region_change_logs", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_region_code": { + "name": "from_region_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_region_code": { + "name": "to_region_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_region_change_user_changed": { + "name": "idx_region_change_user_changed", + "columns": [ + "user_id", + "changed_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_region_change_logs_user_id_users_id_fk": { + "name": "user_region_change_logs_user_id_users_id_fk", + "tableFrom": "user_region_change_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_region_change_logs_id": { + "name": "user_region_change_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_wallets": { + "name": "user_wallets", + "columns": { + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coins_balance": { + "name": "coins_balance", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "lifetime_coins_earned": { + "name": "lifetime_coins_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "lifetime_coins_spent": { + "name": "lifetime_coins_spent", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_users_id_fk": { + "name": "user_wallets_user_id_users_id_fk", + "tableFrom": "user_wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_wallets_user_id": { + "name": "user_wallets_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_weekly_xp": { + "name": "user_weekly_xp", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "xp_earned": { + "name": "xp_earned", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "challenge_sessions_completed": { + "name": "challenge_sessions_completed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank": { + "name": "rank", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settled": { + "name": "settled", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "settled_at": { + "name": "settled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_xp_at": { + "name": "last_xp_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_refresh_at": { + "name": "next_refresh_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_weekly_xp_user_week": { + "name": "uk_weekly_xp_user_week", + "columns": [ + "user_id", + "week_start" + ], + "isUnique": true + }, + "idx_weekly_xp_group_rank": { + "name": "idx_weekly_xp_group_rank", + "columns": [ + "group_id", + "week_start", + "xp_earned" + ], + "isUnique": false + }, + "idx_weekly_xp_week_settled": { + "name": "idx_weekly_xp_week_settled", + "columns": [ + "week_start", + "settled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_weekly_xp_user_id_users_id_fk": { + "name": "user_weekly_xp_user_id_users_id_fk", + "tableFrom": "user_weekly_xp", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_weekly_xp_id": { + "name": "user_weekly_xp_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "char(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "enum('huawei','guest','phone','apple','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_id": { + "name": "auth_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "enum('free','pro','proplus')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'free'" + }, + "xp_total": { + "name": "xp_total", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak_days": { + "name": "streak_days", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak_last_date": { + "name": "streak_last_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hearts_remaining": { + "name": "hearts_remaining", + "type": "tinyint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5 + }, + "hearts_last_restore": { + "name": "hearts_last_restore", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "daily_xp_goal": { + "name": "daily_xp_goal", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 50 + }, + "daily_xp_earned": { + "name": "daily_xp_earned", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "daily_xp_date": { + "name": "daily_xp_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_theme": { + "name": "current_theme", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'inkTeal'" + }, + "active_track_id": { + "name": "active_track_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_code": { + "name": "region_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_selected_at": { + "name": "region_selected_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_changed_at": { + "name": "region_changed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "daily_attempts_left": { + "name": "daily_attempts_left", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5 + }, + "daily_attempts_date": { + "name": "daily_attempts_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "check_in_days": { + "name": "check_in_days", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_check_in_date": { + "name": "last_check_in_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "streak_protected_until": { + "name": "streak_protected_until", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + } + }, + "indexes": { + "uk_auth": { + "name": "uk_auth", + "columns": [ + "auth_type", + "auth_id" + ], + "isUnique": true + }, + "idx_users_region": { + "name": "idx_users_region", + "columns": [ + "region_code" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index e096b47..94f9d91 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1778494900458, "tag": "0003_lyrical_carnage", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1780903327517, + "tag": "0004_user_region", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md index e49b3e9..4bec76a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -404,7 +404,14 @@ "nickname": "知识探险家", "avatarUrl": null, "tier": "free", - "level": 1 + "level": 1, + "region": { + "code": "310000", + "name": "上海市", + "shortName": "上海", + "selectedAt": "2026-06-08T12:00:00.000Z", + "nextChangeAllowedAt": "2026-06-30T16:00:00.000Z" + } }, "progress": { "hearts": 5, @@ -467,6 +474,83 @@ 说明:`shopBenefits` 为兼容旧客户端保留,内容等同于 `shop.benefits`。新客户端应优先读取 `shop.products`、`wallet.coinsBalance` 和 `inventory.items` 来展示金币、背包和可购买商品。 +#### GET /app/regions + +认证:公开接口(无需 JWT) + +客户端每次启动时同步地区列表到本地缓存。MVP 阶段仅包含中国大陆 31 个一级行政区;数据结构保留 `parentCode` / `level`,后续可扩展到二级地区而不改变协议形状。 + +响应: + +```json +{ + "success": true, + "data": { + "version": "2026-06-08.1", + "countryCode": "CN", + "hierarchy": "flat", + "updatedAt": "2026-06-08T00:00:00.000Z", + "regions": [ + { + "code": "310000", + "name": "上海市", + "shortName": "上海", + "parentCode": null, + "level": 1, + "sortOrder": 30, + "enabled": true + } + ] + }, + "error": null +} +``` + +#### PATCH /users/me/region + +认证:JWT + +用户选择或变更地区。首次选择不受限制;已有地区后,每个北京时间自然月只允许变更一次。 + +请求: + +```json +{ + "regionCode": "310000" +} +``` + +响应: + +```json +{ + "success": true, + "data": { + "region": { + "code": "310000", + "name": "上海市", + "shortName": "上海", + "selectedAt": "2026-06-08T12:00:00.000Z", + "nextChangeAllowedAt": "2026-06-30T16:00:00.000Z" + } + }, + "error": null +} +``` + +同月再次变更: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "REGION_CHANGE_LIMIT_REACHED", + "message": "每个自然月只能修改一次地区,请下个月再试。" + } +} +``` + #### GET /tracks 认证:JWT @@ -581,8 +665,8 @@ "xpDelta": 10, "progress": { "hearts": 5, - "dailyAttemptsLeft": 5, - "highRewardSessionsLeft": 2, + "dailyAttemptsLeft": 4, + "highRewardSessionsLeft": 3, "highRewardSessionsMax": 3, "xp": 10, "streakDays": 0 @@ -603,6 +687,11 @@ `answerState` 取值:`correct`, `wrong`。 +资源扣减规则: + +- 每次单题提交成功裁决后,`dailyAttemptsLeft` 扣 1;重复提交同一题或同一 `submitRequestId` 返回第一次裁决快照,不重复扣减。 +- `highRewardSessionsLeft` 按 5 题挑战组消耗;只有本组最后一题触发挑战完成结算后,才会从 3/3 变为 2/3。 + #### GET /progress/summary 认证:JWT @@ -626,9 +715,43 @@ #### POST /progress/check-in 认证:JWT -请求:无 -响应:更新后的 `ProgressSummaryDto`。 +用途:完成当天签到,并返回更新后的进度摘要。 + +请求 body:无。 + +客户端对接要求: + +- 推荐不发送 body,也不要设置 `Content-Type`。 +- 如果客户端网络库要求 JSON body,请发送空对象 `{}`,不要发送“带 `Content-Type: application/json` 但 body 为空”的请求。 +- 服务端以 UTC 日期判断“当天”。同一 UTC 日期内重复调用不会重复增加 `checkInDays`。 + +成功响应:更新后的 `ProgressSummaryDto`。 + +```json +{ + "success": true, + "data": { + "hearts": 5, + "maxHearts": 5, + "nextHeartRestoreAt": null, + "dailyAttemptsLeft": 5, + "dailyAttemptsMax": 5, + "nextAttemptResetAt": "2026-05-06T00:00:00.000Z", + "highRewardSessionsLeft": 3, + "highRewardSessionsMax": 3, + "xp": 0, + "level": 1, + "xpToNextLevel": 100, + "streakDays": 0, + "checkInDays": 1, + "streakProtectedUntil": null, + "activeTrackId": null, + "isSubscribed": false + }, + "error": null +} +``` #### GET /leaderboards @@ -638,8 +761,9 @@ | 参数 | 类型 | 默认 | 说明 | |------|------|------|------| -| `scope` | `region` 或 `topic` | `region` | 排行榜范围 | -| `trackId` | string | - | `scope=topic` 时可传 | +| `scope` | `region` 或 `topic` | `region` | `region` 返回地区榜;`topic` 暂保留原本周 XP 分组榜 | +| `regionCode` | string | 用户已选择地区 | 查看指定地区榜;不传则展示用户已选择地区 | +| `trackId` | string | - | 当前版本预留,不参与筛选 | | `page` | number | 1 | 页码 | | `limit` | number | 20 | 1-100 | @@ -664,6 +788,23 @@ "weekEnd": "2026-05-17", "nextRefreshAt": "2026-05-18", "groupId": "week-2026-05-11-group-1", + "requiresRegionSelection": false, + "selectedRegion": { + "code": "310000", + "name": "上海市", + "shortName": "上海", + "selectedAt": "2026-06-08T12:00:00.000Z", + "nextChangeAllowedAt": "2026-06-30T16:00:00.000Z" + }, + "viewRegion": { + "code": "310000", + "name": "上海市", + "shortName": "上海", + "parentCode": null, + "level": 1, + "sortOrder": 30, + "enabled": true + }, "rewardPreview": [ { "rank": 1, "coins": 300 }, { "rank": 2, "coins": 150 }, @@ -679,7 +820,7 @@ } ``` -> `xp` 为本周累计 XP(非全局累计),排名基于用户所在 20-30 人分组内。`meta.rewardPreview` 展示各组前 3 名的金币奖励,激励用户冲榜。 +> `scope=region` 时,`xp` 为本周累计 XP(非全局累计),排名基于 `users.region_code` 过滤后的地区榜。用户未选择地区且请求未带 `regionCode` 时,服务端返回空榜并设置 `meta.requiresRegionSelection=true`,客户端应提示用户选择所在地区。`scope=topic` 当前仍保留原本周 XP 分组榜。 #### GET /leaderboards/me diff --git a/src/__tests__/services/app/bootstrap-service.test.ts b/src/__tests__/services/app/bootstrap-service.test.ts index bebf9e9..8caf07a 100644 --- a/src/__tests__/services/app/bootstrap-service.test.ts +++ b/src/__tests__/services/app/bootstrap-service.test.ts @@ -78,6 +78,7 @@ describe('bootstrap-service', () => { avatarUrl: null, tier: 'free', level: 2, + region: null, }); expect(result.wallet).toEqual({ coinsBalance: 260 }); expect(result.inventory.items).toEqual([ diff --git a/src/__tests__/services/app/regions-service.test.ts b/src/__tests__/services/app/regions-service.test.ts new file mode 100644 index 0000000..d8cda3e --- /dev/null +++ b/src/__tests__/services/app/regions-service.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; +import { getRegionsConfig, updateUserRegion } from '../../../services/app/regions-service.js'; +import { mockSelectQueue } from '../../helpers/db-mock.js'; + +function setupUpdate() { + const whereSpy = vi.fn().mockResolvedValue({ affectedRows: 1 }); + const setSpy = vi.fn().mockReturnValue({ where: whereSpy }); + vi.mocked(db.update).mockReturnValue({ set: setSpy } as never); + return { setSpy, whereSpy }; +} + +function setupInsert() { + const valuesSpy = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never); + return valuesSpy; +} + +describe('regions-service', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-08T04:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('返回 31 个一级行政区配置', () => { + const config = getRegionsConfig(); + + expect(config.countryCode).toBe('CN'); + expect(config.regions).toHaveLength(31); + expect(config.regions[0]).toEqual({ + code: '110000', + name: '北京市', + shortName: '北京', + parentCode: null, + level: 1, + sortOrder: 10, + enabled: true, + }); + }); + + it('首次选择地区不受每月一次限制', async () => { + mockSelectQueue(vi.mocked(db.select), [[{ regionCode: null, regionSelectedAt: null, regionChangedAt: null }]]); + const { setSpy } = setupUpdate(); + const valuesSpy = setupInsert(); + + const result = await updateUserRegion('user-1', '310000'); + + expect(result.code).toBe('310000'); + expect(result.name).toBe('上海市'); + expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ regionCode: '310000' })); + expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-1', + fromRegionCode: null, + toRegionCode: '310000', + })); + }); + + it('同一自然月内再次变更地区时返回友好错误', async () => { + mockSelectQueue(vi.mocked(db.select), [ + [{ regionCode: '310000', regionSelectedAt: new Date('2026-06-01T00:00:00.000Z'), regionChangedAt: new Date('2026-06-01T00:00:00.000Z') }], + [{ id: 'log-1' }], + ]); + + await expect(updateUserRegion('user-1', '440000')).rejects.toMatchObject({ + code: 'REGION_CHANGE_LIMIT_REACHED', + message: '每个自然月只能修改一次地区,请下个月再试。', + }); + }); +}); diff --git a/src/__tests__/services/gamification/leaderboard-service.test.ts b/src/__tests__/services/gamification/leaderboard-service.test.ts index f42d5ee..8fefdb9 100644 --- a/src/__tests__/services/gamification/leaderboard-service.test.ts +++ b/src/__tests__/services/gamification/leaderboard-service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '../../../db/client.js'; -import { weeklySettlement, getUserRank, getLeaderboardMeta } from '../../../services/gamification/leaderboard-service.js'; +import { weeklySettlement, getRegionLeaderboard, getUserRank, getUserRegionRank, getLeaderboardMeta } from '../../../services/gamification/leaderboard-service.js'; import { addToWeeklyXp } from '../../../services/progress/xp-service.js'; import { mockSelectQueue } from '../../helpers/db-mock.js'; @@ -144,6 +144,42 @@ describe('leaderboard-service', () => { }); }); + // ── 地区榜 ────────────────────────────────────────────────────── + + describe('region leaderboard', () => { + it('按地区返回当前周 XP 排名', async () => { + setupSelectQueue([[ + { userId: 'u1', weeklyXp: 200, nickname: '甲', avatarUrl: null }, + { userId: 'u2', weeklyXp: 100, nickname: '乙', avatarUrl: null }, + ]]); + + const result = await getRegionLeaderboard('310000'); + + expect(result.items).toHaveLength(2); + expect(result.items[0]).toEqual(expect.objectContaining({ + userId: 'u1', + weeklyXp: 200, + rank: 1, + })); + expect(result.pagination.total).toBe(2); + }); + + it('返回用户在指定地区内的排名', async () => { + setupSelectQueue([ + [{ xpEarned: 120 }], + [{ count: 4 }], + ]); + + const result = await getUserRegionRank('user-1', '310000'); + + expect(result).toEqual({ + rank: 5, + tier: expect.any(String), + weeklyXp: 120, + }); + }); + }); + // ── 周结算 ────────────────────────────────────────────────────── describe('weeklySettlement', () => { diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index c34b0c6..361df28 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -295,6 +295,7 @@ describe('challenge-service', () => { }); it('awards XP for a correct answer', async () => { + const userAfterAttempt = { ...freeUserRow, dailyAttemptsLeft: 4 }; mockSelectQueue([ [makeSession()], // session [], // no existing answer @@ -302,13 +303,14 @@ describe('challenge-service', () => { [], // no previous correct answer for first knowledge card [], // addXp(correct): no existing weekly XP [], // addXp(correct): no existing leaderboard group + [freeUserRow], // deductDailyAttempt → getResourceUser [knowledgeCardRow], // getKnowledgeCard [{ groupId: 'week-2026-05-11-group-1' }], // addXp(first card): reuse weekly group - [freeUserRow], // getResourceUser (getProgressSummary) + [userAfterAttempt], // getResourceUser (getProgressSummary) [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak [], // getSubscriptionStatus - [freeUserRow], // getDailyAttempts + [userAfterAttempt], // getDailyAttempts [{ used: 0, restored: 0 }], // getHighRewardQuota ]); vi.mocked(db.insert).mockReturnValue(mockInsert()); @@ -325,6 +327,7 @@ describe('challenge-service', () => { expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }), ]), ); + expect(result.progress.dailyAttemptsLeft).toBe(4); expect(result.knowledgeCard.id).toBe('card-1'); }); @@ -398,18 +401,22 @@ describe('challenge-service', () => { }); it('triggers completion settlement on the last question', async () => { - const userAfterXp = { ...freeUserRow, xpTotal: 150 }; + const userAfterAttempt = { ...freeUserRow, dailyAttemptsLeft: 4 }; + const userAfterXp = { ...userAfterAttempt, xpTotal: 150 }; mockSelectQueue([ [makeSession({ answeredCount: 4, correctCount: 4 })], // session [], // no existing answer [testQuestion], // question (but we submit q-5) [], // no previous correct answer for first knowledge card + [], // addXp(correct): no existing weekly XP + [], // addXp(correct): no existing leaderboard group + [freeUserRow], // deductDailyAttempt → getResourceUser // settleCompletedChallenge → getProgressSummary (before) - [freeUserRow], // getResourceUser + [userAfterAttempt], // getResourceUser [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak [], // getSubscriptionStatus - [freeUserRow], // getDailyAttempts + [userAfterAttempt], // getDailyAttempts [{ used: 0, restored: 0 }], // getHighRewardQuota [], // no existing daily progress // updateChapterProgress @@ -421,6 +428,7 @@ describe('challenge-service', () => { [{ coinsBalance: 40 }], // current wallet balance [{ id: 'daily-1' }], // daily progress row for coin aggregation [knowledgeCardRow], + [{ groupId: 'week-2026-05-11-group-1' }], // addXp(first card): reuse weekly group // getProgressSummary (final) [userAfterXp], [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], diff --git a/src/__tests__/utils/errors.test.ts b/src/__tests__/utils/errors.test.ts new file mode 100644 index 0000000..c6b02bb --- /dev/null +++ b/src/__tests__/utils/errors.test.ts @@ -0,0 +1,27 @@ +import Fastify from 'fastify'; +import { describe, expect, it } from 'vitest'; +import { errorHandler } from '../../utils/errors.js'; + +describe('errorHandler', () => { + it('keeps Fastify request parsing errors as 4xx responses', async () => { + const app = Fastify(); + app.setErrorHandler(errorHandler); + app.post('/empty-json', async () => ({ ok: true })); + + const response = await app.inject({ + method: 'POST', + url: '/empty-json', + headers: { 'content-type': 'application/json' }, + payload: '', + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + data: null, + error: { + code: 'FST_ERR_CTP_EMPTY_JSON_BODY', + }, + }); + }); +}); diff --git a/src/__tests__/utils/json-parser.test.ts b/src/__tests__/utils/json-parser.test.ts new file mode 100644 index 0000000..b12d9f3 --- /dev/null +++ b/src/__tests__/utils/json-parser.test.ts @@ -0,0 +1,36 @@ +import Fastify from 'fastify'; +import { describe, expect, it } from 'vitest'; +import { registerJsonBodyParser } from '../../utils/json-parser.js'; + +describe('registerJsonBodyParser', () => { + it('treats an empty application/json body as an empty object', async () => { + const app = Fastify(); + registerJsonBodyParser(app); + app.post('/empty-json', async (request) => ({ body: request.body })); + + const response = await app.inject({ + method: 'POST', + url: '/empty-json', + headers: { 'content-type': 'application/json' }, + payload: '', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ body: {} }); + }); + + it('still rejects malformed JSON', async () => { + const app = Fastify(); + registerJsonBodyParser(app); + app.post('/bad-json', async () => ({ ok: true })); + + const response = await app.inject({ + method: 'POST', + url: '/bad-json', + headers: { 'content-type': 'application/json' }, + payload: '{', + }); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/src/config/regions.ts b/src/config/regions.ts new file mode 100644 index 0000000..150800a --- /dev/null +++ b/src/config/regions.ts @@ -0,0 +1,46 @@ +export interface RegionConfig { + code: string; + name: string; + shortName: string; + parentCode: string | null; + level: number; + sortOrder: number; + enabled: boolean; +} + +export const REGION_CONFIG_VERSION = '2026-06-08.1'; + +// MVP 阶段只下发中国大陆一级行政区;结构保留 parentCode/level 以兼容后续二级地区扩展。 +export const REGIONS: readonly RegionConfig[] = Object.freeze([ + { code: '110000', name: '北京市', shortName: '北京', parentCode: null, level: 1, sortOrder: 10, enabled: true }, + { code: '120000', name: '天津市', shortName: '天津', parentCode: null, level: 1, sortOrder: 20, enabled: true }, + { code: '310000', name: '上海市', shortName: '上海', parentCode: null, level: 1, sortOrder: 30, enabled: true }, + { code: '500000', name: '重庆市', shortName: '重庆', parentCode: null, level: 1, sortOrder: 40, enabled: true }, + { code: '130000', name: '河北省', shortName: '河北', parentCode: null, level: 1, sortOrder: 50, enabled: true }, + { code: '140000', name: '山西省', shortName: '山西', parentCode: null, level: 1, sortOrder: 60, enabled: true }, + { code: '150000', name: '内蒙古自治区', shortName: '内蒙古', parentCode: null, level: 1, sortOrder: 70, enabled: true }, + { code: '210000', name: '辽宁省', shortName: '辽宁', parentCode: null, level: 1, sortOrder: 80, enabled: true }, + { code: '220000', name: '吉林省', shortName: '吉林', parentCode: null, level: 1, sortOrder: 90, enabled: true }, + { code: '230000', name: '黑龙江省', shortName: '黑龙江', parentCode: null, level: 1, sortOrder: 100, enabled: true }, + { code: '320000', name: '江苏省', shortName: '江苏', parentCode: null, level: 1, sortOrder: 110, enabled: true }, + { code: '330000', name: '浙江省', shortName: '浙江', parentCode: null, level: 1, sortOrder: 120, enabled: true }, + { code: '340000', name: '安徽省', shortName: '安徽', parentCode: null, level: 1, sortOrder: 130, enabled: true }, + { code: '350000', name: '福建省', shortName: '福建', parentCode: null, level: 1, sortOrder: 140, enabled: true }, + { code: '360000', name: '江西省', shortName: '江西', parentCode: null, level: 1, sortOrder: 150, enabled: true }, + { code: '370000', name: '山东省', shortName: '山东', parentCode: null, level: 1, sortOrder: 160, enabled: true }, + { code: '410000', name: '河南省', shortName: '河南', parentCode: null, level: 1, sortOrder: 170, enabled: true }, + { code: '420000', name: '湖北省', shortName: '湖北', parentCode: null, level: 1, sortOrder: 180, enabled: true }, + { code: '430000', name: '湖南省', shortName: '湖南', parentCode: null, level: 1, sortOrder: 190, enabled: true }, + { code: '440000', name: '广东省', shortName: '广东', parentCode: null, level: 1, sortOrder: 200, enabled: true }, + { code: '450000', name: '广西壮族自治区', shortName: '广西', parentCode: null, level: 1, sortOrder: 210, enabled: true }, + { code: '460000', name: '海南省', shortName: '海南', parentCode: null, level: 1, sortOrder: 220, enabled: true }, + { code: '510000', name: '四川省', shortName: '四川', parentCode: null, level: 1, sortOrder: 230, enabled: true }, + { code: '520000', name: '贵州省', shortName: '贵州', parentCode: null, level: 1, sortOrder: 240, enabled: true }, + { code: '530000', name: '云南省', shortName: '云南', parentCode: null, level: 1, sortOrder: 250, enabled: true }, + { code: '540000', name: '西藏自治区', shortName: '西藏', parentCode: null, level: 1, sortOrder: 260, enabled: true }, + { code: '610000', name: '陕西省', shortName: '陕西', parentCode: null, level: 1, sortOrder: 270, enabled: true }, + { code: '620000', name: '甘肃省', shortName: '甘肃', parentCode: null, level: 1, sortOrder: 280, enabled: true }, + { code: '630000', name: '青海省', shortName: '青海', parentCode: null, level: 1, sortOrder: 290, enabled: true }, + { code: '640000', name: '宁夏回族自治区', shortName: '宁夏', parentCode: null, level: 1, sortOrder: 300, enabled: true }, + { code: '650000', name: '新疆维吾尔自治区', shortName: '新疆', parentCode: null, level: 1, sortOrder: 310, enabled: true }, +]); diff --git a/src/db/schema.ts b/src/db/schema.ts index d27542a..e3438b2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -37,6 +37,9 @@ export const users = mysqlTable('users', { dailyXpDate: date('daily_xp_date'), // 每日经验统计日期。 currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), // 当前界面主题。 activeTrackId: varchar('active_track_id', { length: 50 }), // 当前学习路径。 + regionCode: varchar('region_code', { length: 20 }), // 用户选择的地区编码,用于地区榜归属。 + regionSelectedAt: datetime('region_selected_at'), // 最近一次选择地区的时间。 + regionChangedAt: datetime('region_changed_at'), // 最近一次地区变更时间,用于每自然月一次的限制。 dailyAttemptsLeft: smallint('daily_attempts_left').default(5), // 当日剩余挑战次数。 dailyAttemptsDate: date('daily_attempts_date'), // 每日挑战次数统计日期。 checkInDays: int('check_in_days').default(0), // 累计签到天数。 @@ -46,6 +49,7 @@ export const users = mysqlTable('users', { updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。 }, (table) => [ uniqueIndex('uk_auth').on(table.authType, table.authId), + index('idx_users_region').on(table.regionCode), ]); // ── Categories ───────────────────────────────────────────────────── @@ -432,6 +436,21 @@ export const userWeeklyXp = mysqlTable('user_weekly_xp', { foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), ]); +// ── User Region History ──────────────────────────────────────────── + +// 用户地区变更记录,用于审计每自然月一次的限制判断。 +export const userRegionChangeLogs = mysqlTable('user_region_change_logs', { + id: char('id', { length: 36 }).primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + fromRegionCode: varchar('from_region_code', { length: 20 }), + toRegionCode: varchar('to_region_code', { length: 20 }).notNull(), + changedAt: datetime('changed_at').default(sql`CURRENT_TIMESTAMP`), + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), +}, (table) => [ + index('idx_region_change_user_changed').on(table.userId, table.changedAt), + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), +]); + // ── Subscriptions ────────────────────────────────────────────────── // 用户订阅权益与平台购买数据。 diff --git a/src/index.ts b/src/index.ts index 1fd1249..f7fb2ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import jwt from '@fastify/jwt'; import { config } from './utils/config.js'; import { errorHandler } from './utils/errors.js'; +import { registerJsonBodyParser } from './utils/json-parser.js'; import authMiddleware from './middleware/auth.js'; import adminAuthMiddleware from './middleware/admin-auth.js'; import requestLogger from './middleware/request-logger.js'; @@ -31,6 +32,8 @@ async function main(): Promise { }, }); + registerJsonBodyParser(app); + // ── Plugins ────────────────────────────────────────────────────── await app.register(helmet); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index a1c46b0..af9acb7 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -19,6 +19,7 @@ async function authMiddleware(app: FastifyInstance): Promise { '/v1/auth/phone', '/v1/auth/refresh', '/v1/auth/providers', + '/v1/app/regions', ]; if (publicPaths.some((p) => request.url.startsWith(p))) { diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts index e314ab5..3f83ae3 100644 --- a/src/routes/app-api.ts +++ b/src/routes/app-api.ts @@ -1,6 +1,7 @@ import { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { getBootstrap } from '../services/app/bootstrap-service.js'; +import { getRegionsConfig, updateUserRegion } from '../services/app/regions-service.js'; import { getThemeTrackById, getThemeTracks } from '../services/learning/tracks-service.js'; import { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js'; import { @@ -33,9 +34,14 @@ const preferencesSchema = z.object({ activeTrackId: z.string().min(1).max(50), }); +const userRegionSchema = z.object({ + regionCode: z.string().regex(/^\d{6}$/), +}); + const leaderboardQuerySchema = z.object({ scope: z.enum(['region', 'topic']).default('region'), trackId: z.string().optional(), + regionCode: z.string().regex(/^\d{6}$/).optional(), page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), }); @@ -72,6 +78,11 @@ export async function appApiRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + app.get('/app/regions', async () => { + const data = getRegionsConfig(); + return { success: true, data, error: null }; + }); + app.get('/tracks', async (request) => { const data = await getThemeTracks(getUserId(request)); return { success: true, data, error: null }; @@ -117,6 +128,13 @@ export async function appApiRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + app.patch('/users/me/region', async (request) => { + const parsed = userRegionSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const region = await updateUserRegion(getUserId(request), parsed.data.regionCode); + return { success: true, data: { region }, error: null }; + }); + app.post('/progress/check-in', async (request) => { const data = await checkIn(getUserId(request)); return { success: true, data, error: null }; @@ -156,6 +174,7 @@ export async function appApiRoutes(app: FastifyInstance): Promise { getUserId(request), parsed.data.scope, parsed.data.trackId, + parsed.data.regionCode, parsed.data.page, parsed.data.limit, ); @@ -165,7 +184,7 @@ export async function appApiRoutes(app: FastifyInstance): Promise { app.get('/leaderboards/me', async (request) => { const parsed = leaderboardQuerySchema.safeParse(request.query); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); - const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId); + const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId, parsed.data.regionCode); return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null }; }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index fd0ccc4..d299791 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -8,6 +8,7 @@ import { sendPhoneCode, loginWithPhone } from '../services/auth/phone.js'; import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js'; import { linkGuestAccount } from '../services/auth/account-link-service.js'; import { NotFoundError } from '../utils/errors.js'; +import { toUserRegionDto } from '../services/app/regions-service.js'; import { config } from '../utils/config.js'; const guestLoginSchema = z.object({ @@ -266,6 +267,7 @@ export async function authRoutes(app: FastifyInstance): Promise { heartsRemaining: user.heartsRemaining ?? 5, dailyXpEarned: user.dailyXpEarned ?? 0, dailyXpGoal: user.dailyXpGoal ?? 50, + region: toUserRegionDto(user.regionCode, user.regionSelectedAt, user.regionChangedAt), }, error: null, }); diff --git a/src/services/app/bootstrap-service.ts b/src/services/app/bootstrap-service.ts index 35bb574..e1ca344 100644 --- a/src/services/app/bootstrap-service.ts +++ b/src/services/app/bootstrap-service.ts @@ -7,6 +7,7 @@ import { getShopCatalog } from '../shop/shop-service.js'; import { getClientSubscription } from '../subscription/subscription-api-service.js'; import { getCoinBalance } from '../gamification/coin-service.js'; import { getClientInventory } from '../gamification/inventory-service.js'; +import { toUserRegionDto } from './regions-service.js'; import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js'; export async function getBootstrap(userId: string): Promise { @@ -17,6 +18,9 @@ export async function getBootstrap(userId: string): Promise { avatarUrl: users.avatarUrl, tier: users.tier, xpTotal: users.xpTotal, + regionCode: users.regionCode, + regionSelectedAt: users.regionSelectedAt, + regionChangedAt: users.regionChangedAt, }) .from(users) .where(eq(users.id, userId)) @@ -41,6 +45,7 @@ export async function getBootstrap(userId: string): Promise { avatarUrl: user?.avatarUrl ?? null, tier: (user?.tier ?? 'free') as SubscriptionTier, level, + region: toUserRegionDto(user?.regionCode, user?.regionSelectedAt, user?.regionChangedAt), }, progress, tracks, diff --git a/src/services/app/regions-service.ts b/src/services/app/regions-service.ts new file mode 100644 index 0000000..c89a149 --- /dev/null +++ b/src/services/app/regions-service.ts @@ -0,0 +1,159 @@ +import { and, eq, gte, lt, sql } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; +import { db } from '../../db/client.js'; +import { userRegionChangeLogs, users } from '../../db/schema.js'; +import { REGION_CONFIG_VERSION, REGIONS, type RegionConfig } from '../../config/regions.js'; +import { AppError, ConflictError } from '../../utils/errors.js'; +import type { RegionDto, RegionsConfigDto, UserRegionDto } from '../../types/app-api.js'; + +const REGIONS_UPDATED_AT = '2026-06-08T00:00:00.000Z'; + +function toRegionDto(region: RegionConfig): RegionDto { + return { + code: region.code, + name: region.name, + shortName: region.shortName, + parentCode: region.parentCode, + level: region.level, + sortOrder: region.sortOrder, + enabled: region.enabled, + }; +} + +export function getRegionsConfig(): RegionsConfigDto { + return { + version: REGION_CONFIG_VERSION, + countryCode: 'CN', + hierarchy: 'flat', + updatedAt: REGIONS_UPDATED_AT, + regions: REGIONS.map(toRegionDto), + }; +} + +export function findRegionByCode(regionCode: string | null | undefined): RegionDto | null { + if (!regionCode) return null; + const region = REGIONS.find((item) => item.code === regionCode && item.enabled); + return region ? toRegionDto(region) : null; +} + +function getShanghaiMonthRange(now = new Date()): { start: Date; end: Date } { + const shanghaiNow = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const year = shanghaiNow.getUTCFullYear(); + const month = shanghaiNow.getUTCMonth(); + // 自然月限制按中国用户预期的北京时间计算,存库仍使用 UTC 时间。 + const start = new Date(Date.UTC(year, month, 1) - 8 * 60 * 60 * 1000); + const end = new Date(Date.UTC(year, month + 1, 1) - 8 * 60 * 60 * 1000); + return { start, end }; +} + +export function getNextRegionChangeAllowedAt(changedAt: Date | string | null | undefined): string | null { + if (!changedAt) return null; + const date = new Date(changedAt); + const shanghaiDate = new Date(date.getTime() + 8 * 60 * 60 * 1000); + const year = shanghaiDate.getUTCFullYear(); + const month = shanghaiDate.getUTCMonth(); + const nextMonthStartUtc = new Date(Date.UTC(year, month + 1, 1) - 8 * 60 * 60 * 1000); + return nextMonthStartUtc.toISOString(); +} + +export function toUserRegionDto( + regionCode: string | null | undefined, + selectedAt: Date | string | null | undefined, + changedAt: Date | string | null | undefined, +): UserRegionDto | null { + const region = findRegionByCode(regionCode); + if (!region) return null; + return { + code: region.code, + name: region.name, + shortName: region.shortName, + selectedAt: selectedAt ? new Date(selectedAt).toISOString() : null, + nextChangeAllowedAt: getNextRegionChangeAllowedAt(changedAt), + }; +} + +export async function getUserSelectedRegion(userId: string): Promise { + const [user] = await db + .select({ + regionCode: users.regionCode, + regionSelectedAt: users.regionSelectedAt, + regionChangedAt: users.regionChangedAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return toUserRegionDto(user?.regionCode, user?.regionSelectedAt, user?.regionChangedAt); +} + +export async function updateUserRegion(userId: string, regionCode: string): Promise { + const targetRegion = findRegionByCode(regionCode); + if (!targetRegion) { + throw new AppError('请选择有效的地区。', 400, 'INVALID_REGION'); + } + + const [user] = await db + .select({ + regionCode: users.regionCode, + regionSelectedAt: users.regionSelectedAt, + regionChangedAt: users.regionChangedAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + throw new AppError('User not found', 404, 'NOT_FOUND'); + } + + if (user.regionCode === regionCode) { + return toUserRegionDto(user.regionCode, user.regionSelectedAt, user.regionChangedAt)!; + } + + const now = new Date(); + const { start, end } = getShanghaiMonthRange(now); + + // 首次选择不受限制;已有地区后,每个北京时间自然月只允许一次变更。 + if (user.regionCode) { + const [changeLog] = await db + .select({ id: userRegionChangeLogs.id }) + .from(userRegionChangeLogs) + .where(and( + eq(userRegionChangeLogs.userId, userId), + gte(userRegionChangeLogs.changedAt, start), + lt(userRegionChangeLogs.changedAt, end), + )) + .limit(1); + + if (changeLog) { + throw new ConflictError('每个自然月只能修改一次地区,请下个月再试。', 'REGION_CHANGE_LIMIT_REACHED'); + } + } + + await db + .update(users) + .set({ + regionCode, + regionSelectedAt: sql`NOW()`, + regionChangedAt: sql`NOW()`, + }) + .where(eq(users.id, userId)); + + await db + .insert(userRegionChangeLogs) + .values({ + id: uuid(), + userId, + fromRegionCode: user.regionCode ?? null, + toRegionCode: regionCode, + changedAt: sql`NOW()`, + }); + + return { + code: targetRegion.code, + name: targetRegion.name, + shortName: targetRegion.shortName, + selectedAt: now.toISOString(), + nextChangeAllowedAt: getNextRegionChangeAllowedAt(now), + }; +} diff --git a/src/services/gamification/leaderboard-service.ts b/src/services/gamification/leaderboard-service.ts index 88d7d28..8821d07 100644 --- a/src/services/gamification/leaderboard-service.ts +++ b/src/services/gamification/leaderboard-service.ts @@ -109,6 +109,44 @@ export async function getLeaderboard(userId: string, _tier?: string, page = 1, l return { items, pagination: { total, page, limit } }; } +/** + * 获取指定地区的当前周排行榜。 + * 地区榜不再使用随机周榜分组,而是按用户资料中的 regionCode 过滤。 + */ +export async function getRegionLeaderboard(regionCode: string, page = 1, limit = 20): Promise<{ + items: LeaderboardEntry[]; + pagination: { total: number; page: number; limit: number }; +}> { + const { weekStart } = getCurrentWeekRange(); + const weekStartStr = weekStart.toISOString().slice(0, 10); + const offset = (page - 1) * limit; + + const allEntries = await db + .select({ + userId: userWeeklyXp.userId, + weeklyXp: userWeeklyXp.xpEarned, + nickname: users.nickname, + avatarUrl: users.avatarUrl, + }) + .from(userWeeklyXp) + .innerJoin(users, eq(userWeeklyXp.userId, users.id)) + .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${users.regionCode} = ${regionCode}`) + .orderBy(desc(userWeeklyXp.xpEarned)) + .limit(1000); + + const total = allEntries.length; + const items: LeaderboardEntry[] = allEntries.slice(offset, offset + limit).map((entry, i) => ({ + userId: entry.userId, + nickname: entry.nickname ?? null, + avatarUrl: entry.avatarUrl ?? null, + weeklyXp: entry.weeklyXp ?? 0, + rank: offset + i + 1, + tier: getTierForRank(offset + i + 1), + })); + + return { items, pagination: { total, page, limit } }; +} + /** * 获取用户在本周排行榜中的组内排名。 * 统计同组内本周 XP 比自己高的用户数量,得出组内排名。 @@ -141,6 +179,31 @@ export async function getUserRank(userId: string): Promise<{ rank: number; tier: return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; } +/** 获取用户在指定地区当前周排行榜中的排名。 */ +export async function getUserRegionRank(userId: string, regionCode: string): Promise<{ rank: number; tier: string; weeklyXp: number } | null> { + const { weekStart } = getCurrentWeekRange(); + const weekStartStr = weekStart.toISOString().slice(0, 10); + + const [userRow] = await db + .select({ xpEarned: userWeeklyXp.xpEarned }) + .from(userWeeklyXp) + .innerJoin(users, eq(userWeeklyXp.userId, users.id)) + .where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${users.regionCode} = ${regionCode}`) + .limit(1); + + if (!userRow) return null; + const userXp = userRow.xpEarned ?? 0; + + const [higher] = await db + .select({ count: sql`COUNT(*)` }) + .from(userWeeklyXp) + .innerJoin(users, eq(userWeeklyXp.userId, users.id)) + .where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${users.regionCode} = ${regionCode} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`); + + const rank = Number(higher?.count ?? 0) + 1; + return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; +} + /** 前三名奖励金币配置:第 1 名 300,第 2 名 150,第 3 名 50。 */ const TOP_REWARD_COINS: ReadonlyMap = new Map([ [1, 300], diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 8602057..1bb4e30 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -444,8 +444,9 @@ export async function submitChallengeAnswer( if (!heartResult.success && heartResult.remaining === 0) { throw new ValidationError('红心已用完,请等待恢复或观看广告'); } - await deductDailyAttempt(userId); } + // 每题成功裁决后消耗 1 次今日答题次数;幂等重复提交会在前面直接返回快照,不会重复扣减。 + await deductDailyAttempt(userId); const totalQuestions = session.totalQuestions ?? CHALLENGE_RULES.questionsPerSession; const answeredAfter = Math.min((session.answeredCount ?? 0) + 1, totalQuestions); diff --git a/src/services/learning/leaderboard-api-service.ts b/src/services/learning/leaderboard-api-service.ts index 80cc5a7..c934e28 100644 --- a/src/services/learning/leaderboard-api-service.ts +++ b/src/services/learning/leaderboard-api-service.ts @@ -1,8 +1,10 @@ -import { getLeaderboard, getLeaderboardMeta, getUserRank } from '../gamification/leaderboard-service.js'; +import { getLeaderboard, getLeaderboardMeta, getRegionLeaderboard, getUserRank, getUserRegionRank } from '../gamification/leaderboard-service.js'; import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js'; import { db } from '../../db/client.js'; import { users } from '../../db/schema.js'; import { eq } from 'drizzle-orm'; +import { AppError } from '../../utils/errors.js'; +import { findRegionByCode, getUserSelectedRegion } from '../app/regions-service.js'; function getBadge(rank: number): string { if (rank === 1) return '榜首'; @@ -13,8 +15,9 @@ function getBadge(rank: number): string { export async function getClientLeaderboard( userId: string, - _scope: LeaderboardScope, + scope: LeaderboardScope, _trackId: string | undefined, + regionCode: string | undefined, page: number, limit: number, ): Promise<{ @@ -22,6 +25,51 @@ export async function getClientLeaderboard( meta: LeaderboardMetaDto; pagination: { total: number; page: number; limit: number }; }> { + if (scope === 'region') { + const [selectedRegion, baseMeta] = await Promise.all([ + getUserSelectedRegion(userId), + getLeaderboardMeta(userId), + ]); + const viewRegion = findRegionByCode(regionCode ?? selectedRegion?.code); + + if (regionCode && !viewRegion) { + throw new AppError('请选择有效的地区。', 400, 'INVALID_REGION'); + } + + if (!viewRegion) { + return { + items: [], + meta: { + ...baseMeta, + requiresRegionSelection: true, + selectedRegion, + viewRegion: null, + }, + pagination: { total: 0, page, limit }, + }; + } + + const data = await getRegionLeaderboard(viewRegion.code, page, limit); + return { + items: data.items.map((entry) => ({ + rank: entry.rank, + userId: entry.userId, + displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'), + avatarUrl: entry.avatarUrl, + xp: entry.weeklyXp, + badge: getBadge(entry.rank), + isMe: entry.userId === userId, + })), + meta: { + ...baseMeta, + requiresRegionSelection: false, + selectedRegion, + viewRegion, + }, + pagination: data.pagination, + }; + } + const [data, meta] = await Promise.all([ getLeaderboard(userId, undefined, page, limit), getLeaderboardMeta(userId), @@ -44,9 +92,75 @@ export async function getClientLeaderboard( export async function getClientLeaderboardMe( userId: string, - _scope: LeaderboardScope, + scope: LeaderboardScope, _trackId: string | undefined, -): Promise<{ entry: LeaderboardEntryDto; meta: LeaderboardMetaDto } | null> { + regionCode: string | undefined, +): Promise<{ entry: LeaderboardEntryDto | null; meta: LeaderboardMetaDto } | null> { + if (scope === 'region') { + const [selectedRegion, baseMeta] = await Promise.all([ + getUserSelectedRegion(userId), + getLeaderboardMeta(userId), + ]); + const viewRegion = findRegionByCode(regionCode ?? selectedRegion?.code); + + if (regionCode && !viewRegion) { + throw new AppError('请选择有效的地区。', 400, 'INVALID_REGION'); + } + + if (!viewRegion) { + return { + entry: null, + meta: { + ...baseMeta, + requiresRegionSelection: true, + selectedRegion, + viewRegion: null, + }, + }; + } + + const rank = await getUserRegionRank(userId, viewRegion.code); + if (!rank) { + return { + entry: null, + meta: { + ...baseMeta, + requiresRegionSelection: false, + selectedRegion, + viewRegion, + }, + }; + } + + const [user] = await db + .select({ + nickname: users.nickname, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return { + entry: { + rank: rank.rank, + userId, + displayName: user?.nickname ?? '你', + avatarUrl: user?.avatarUrl ?? null, + xp: rank.weeklyXp, + badge: getBadge(rank.rank), + isMe: true, + }, + meta: { + ...baseMeta, + rank: rank.rank, + requiresRegionSelection: false, + selectedRegion, + viewRegion, + }, + }; + } + const [rank, meta] = await Promise.all([ getUserRank(userId), getLeaderboardMeta(userId), diff --git a/src/types/app-api.ts b/src/types/app-api.ts index 9e6e787..f465a90 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -5,12 +5,39 @@ export type LeaderboardScope = 'region' | 'topic'; export type RewardSource = 'ad' | 'check_in' | 'subscription' | 'admin_grant' | 'debug'; export type SubscriptionPlatform = 'huawei' | 'apple' | 'google'; +export interface RegionDto { + code: string; + name: string; + shortName: string; + parentCode: string | null; + level: number; + sortOrder: number; + enabled: boolean; +} + +export interface RegionsConfigDto { + version: string; + countryCode: 'CN'; + hierarchy: 'flat'; + updatedAt: string; + regions: readonly RegionDto[]; +} + +export interface UserRegionDto { + code: string; + name: string; + shortName: string; + selectedAt: string | null; + nextChangeAllowedAt: string | null; +} + export interface UserBriefDto { id: string; nickname: string; avatarUrl: string | null; tier: SubscriptionTier; level: number; + region: UserRegionDto | null; } export interface SubscriptionDto { @@ -220,6 +247,9 @@ export interface LeaderboardMetaDto { weekEnd: string; nextRefreshAt: string; groupId: string | null; + requiresRegionSelection?: boolean; + selectedRegion?: UserRegionDto | null; + viewRegion?: RegionDto | null; /** 当前用户组内排名(仅 /leaderboards/me 返回)。 */ rank?: number; /** 当前周奖励预览:各组前 3 名的金币奖励。 */ diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 87e4ac9..855a867 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -77,6 +77,19 @@ export function errorHandler( return; } + const httpError = error as Error & { statusCode?: number; code?: string }; + if (typeof httpError.statusCode === 'number' && httpError.statusCode >= 400 && httpError.statusCode < 500) { + reply.status(httpError.statusCode).send({ + success: false, + data: null, + error: { + code: httpError.code ?? 'BAD_REQUEST', + message: httpError.message, + }, + }); + return; + } + // Unexpected errors — log full details for debugging _request.log.error({ err: error }, 'Unhandled error'); diff --git a/src/utils/json-parser.ts b/src/utils/json-parser.ts new file mode 100644 index 0000000..b61150b --- /dev/null +++ b/src/utils/json-parser.ts @@ -0,0 +1,20 @@ +import type { FastifyInstance } from 'fastify'; + +/** + * 兼容移动端空 POST:部分客户端会带 `Content-Type: application/json` + * 但不发送 body。Fastify 5 默认把这种请求视为 JSON 解析错误; + * 对本项目的无 body POST 来说,空 JSON 应等价于 `{}`。 + */ +export function registerJsonBodyParser(app: FastifyInstance): void { + const defaultJsonParser = app.getDefaultJsonParser('error', 'ignore'); + + app.removeContentTypeParser('application/json'); + app.addContentTypeParser('application/json', { parseAs: 'string' }, (request, body, done) => { + if (body.length === 0) { + done(null, {}); + return; + } + + defaultJsonParser(request, body, done); + }); +}