From 2649b2427709d0b75e2aab7c3c3b67464d345b49 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Tue, 5 May 2026 16:12:04 +0800 Subject: [PATCH] Add ad recovery API contract --- db/migrations/0002_foamy_rachel_grey.sql | 27 + db/migrations/meta/0002_snapshot.json | 1606 ++++++++++++++++ db/migrations/meta/_journal.json | 7 + docs/api-reference.md | 1927 ++++++------------- src/db/schema.ts | 29 + src/index.ts | 2 + src/routes/rewards.ts | 48 + src/services/rewards/ad-recovery-service.ts | 475 +++++ 8 files changed, 2745 insertions(+), 1376 deletions(-) create mode 100644 db/migrations/0002_foamy_rachel_grey.sql create mode 100644 db/migrations/meta/0002_snapshot.json create mode 100644 src/routes/rewards.ts create mode 100644 src/services/rewards/ad-recovery-service.ts diff --git a/db/migrations/0002_foamy_rachel_grey.sql b/db/migrations/0002_foamy_rachel_grey.sql new file mode 100644 index 0000000..b086111 --- /dev/null +++ b/db/migrations/0002_foamy_rachel_grey.sql @@ -0,0 +1,27 @@ +CREATE TABLE `ad_recovery_sessions` ( + `id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `type` enum('hearts','bonusAttempts','streakProtection') NOT NULL, + `status` enum('pending','settling','completed','failed','expired') DEFAULT 'pending', + `client_request_id` varchar(80) NOT NULL, + `complete_request_id` varchar(80), + `platform` enum('ios','android','harmony','web') NOT NULL, + `ad_provider` varchar(50) NOT NULL, + `ad_placement_id` varchar(120) NOT NULL, + `provider_reward_token` varchar(500), + `reward_snapshot` json, + `progress_before` json, + `progress_after` json, + `failure_reason` varchar(80), + `provider_error` varchar(500), + `duplicate_count` int DEFAULT 0, + `expires_at` datetime NOT NULL, + `completed_at` datetime, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `ad_recovery_sessions_id` PRIMARY KEY(`id`), + CONSTRAINT `uk_ad_recovery_user_client_request` UNIQUE(`user_id`,`client_request_id`) +); +--> statement-breakpoint +ALTER TABLE `ad_recovery_sessions` ADD CONSTRAINT `ad_recovery_sessions_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_ad_recovery_user_type_status_created` ON `ad_recovery_sessions` (`user_id`,`type`,`status`,`created_at`); diff --git a/db/migrations/meta/0002_snapshot.json b/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..0e0c893 --- /dev/null +++ b/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1606 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "7f4322d5-fca2-43e2-8d99-d98ecef54b41", + "prevId": "5bab5fce-302f-485a-a496-e4deeafc2269", + "tables": { + "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": {} + }, + "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": {} + }, + "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 + }, + "league": { + "name": "league", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "week_end": { + "name": "week_end", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_week": { + "name": "idx_user_week", + "columns": [ + "user_id", + "week_start" + ], + "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": {} + }, + "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_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_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": {} + }, + "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 + }, + "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 + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index afab203..42a6bf7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777827874032, "tag": "0001_sturdy_invaders", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1777965665440, + "tag": "0002_foamy_rachel_grey", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md index 8266c70..87c13a0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,86 +1,58 @@ # Duoqi API Reference -> 多奇服务端 API 接口文档 -> Base URL: `http://:3000/v1` +> 多奇服务端 API 接口文档。本文按当前 Fastify 路由和 TypeScript DTO 更新。 -## 目录 +## Base URL -- [通用约定](#通用约定) -- [客户端 API](#客户端-api) - - [健康检查](#健康检查) - - [认证](#认证) - - [答题](#答题) - - [进度](#进度) - - [游戏化](#游戏化) - - [支付](#支付) -- [管理端 API](#管理端-api) - - [管理端认证](#管理端认证) - - [管理员管理](#管理员管理) - - [题目管理](#题目管理) - - [分类管理](#分类管理) - - [知识点卡片](#知识点卡片) - - [技能树管理](#技能树管理) - - [用户管理](#用户管理) - - [统计数据](#统计数据) - - [反馈管理](#反馈管理) +| 环境 | Base URL | +|------|----------| +| 生产 | `https://api.duoqi.me/v1` | +| 本地开发 | `http://localhost:3000/v1` | ---- +健康检查是唯一不带 `/v1` 前缀的客户端端点:`GET /health`。 ## 通用约定 -### 认证方式 +### 认证 | 类型 | Header | 适用路径 | |------|--------|----------| -| 无需认证 | - | `/v1/health`, `/v1/auth/*` | +| 无需认证 | - | `/health`, `/v1/auth/guest`, `/v1/auth/huawei`, `/v1/auth/refresh`, `/v1/admin/login` | | JWT | `Authorization: Bearer ` | 大多数客户端 API | -| Admin JWT | `Authorization: Bearer ` | `/v1/admin/*` (推荐) | -| Admin Token | `Authorization: Bearer ` | `/v1/admin/*` (向后兼容) | +| Admin JWT | `Authorization: Bearer ` | `/v1/admin/*` | -### 统一响应格式 +### 统一响应 -```typescript -// 成功响应 +```json { "success": true, - "data": , - "error": null -} - -// 错误响应 -{ - "success": false, - "data": null, - "error": { - "code": "ERROR_CODE", - "message": "错误描述" - } -} - -// 分页响应(额外包含) -{ - "success": true, - "data": [...], - "pagination": { - "total": 100, - "page": 1, - "limit": 20 - }, + "data": {}, "error": null } ``` -### 状态码 +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input" + } +} +``` -| 状态码 | 含义 | -|--------|------| -| 200 | 请求成功 | -| 400 | 请求参数验证失败 | -| 401 | 未认证 / 认证失败 | -| 404 | 资源不存在 | -| 501 | 功能未实现 | +分页响应会额外包含: ---- +```json +{ + "pagination": { + "total": 100, + "page": 1, + "limit": 20 + } +} +``` ## 客户端 API @@ -88,43 +60,38 @@ #### GET /health -健康检查端点,用于服务可用性探测。 +认证:无 -**认证**: 无 +响应: -**请求**: 无 - -**响应**: ```json { "success": true, "data": { "status": "ok", - "timestamp": "2026-04-10T12:00:00.000Z" + "timestamp": "2026-05-05T12:00:00.000Z" }, "error": null } ``` ---- - ### 认证 #### POST /auth/guest -游客登录,通过设备 ID 创建或获取用户账号。 +认证:无 +限流:10 次/分钟 -**认证**: 无 -**限流**: 10 次/分钟 +请求: -**请求体**: ```json { - "deviceId": "string (必填)" + "deviceId": "device-id" } ``` -**响应**: +响应: + ```json { "success": true, @@ -136,104 +103,69 @@ "tier": "free" }, "tokens": { - "accessToken": "jwt_token", - "refreshToken": "jwt_token" + "accessToken": "jwt", + "refreshToken": "jwt" } }, "error": null } ``` ---- - #### POST /auth/huawei -华为账号登录。 +认证:无 +限流:10 次/分钟 -**认证**: 无 -**限流**: 10 次/分钟 +请求: -**请求体**: ```json { - "authorizationCode": "string (必填)" + "authorizationCode": "authorization-code" } ``` -**响应**: -```json -{ - "success": true, - "data": { - "user": { - "id": "uuid", - "nickname": "用户昵称", - "avatarUrl": "头像URL", - "tier": "free" - }, - "tokens": { - "accessToken": "jwt_token", - "refreshToken": "jwt_token" - } - }, - "error": null -} -``` - ---- - -#### POST /auth/phone - -手机号登录(未实现)。 - -**认证**: 无 -**状态**: 501 Not Implemented - ---- +响应同 `/auth/guest`。 #### POST /auth/refresh -刷新访问令牌。 +认证:无 +限流:10 次/分钟 -**认证**: 无 -**限流**: 10 次/分钟 +请求: -**请求体**: ```json { - "refreshToken": "string (必填)" + "refreshToken": "jwt" } ``` -**响应**: +响应: + ```json { "success": true, "data": { - "accessToken": "new_jwt_token", - "refreshToken": "new_refresh_token" + "accessToken": "jwt", + "refreshToken": "jwt" }, "error": null } ``` ---- - #### GET /auth/me -获取当前用户信息。 +认证:JWT -**认证**: JWT +响应: -**响应**: ```json { "success": true, "data": { "id": "uuid", "nickname": "用户昵称", - "avatarUrl": "头像URL", - "tier": "free | pro | proplus", + "avatarUrl": "https://example.com/avatar.png", + "tier": "free", "xpTotal": 150, "streakDays": 3, "heartsRemaining": 5, @@ -244,316 +176,220 @@ } ``` ---- +### Flutter 客户端聚合 API -### 答题 +这组接口对应 Flutter 原型使用的主题路线、挑战、资源摘要、商店和订阅合同。 -#### GET /quiz/categories +#### GET /app/bootstrap -获取所有题目分类列表。 +认证:JWT -**认证**: JWT +响应: -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "name": "历史", - "slug": "history", - "parentId": null - } - ], - "error": null -} -``` - ---- - -#### GET /quiz/categories/:id/chapters - -获取指定分类下的章节列表。 - -**认证**: JWT - -**路径参数**: -- `id`: 分类 ID - -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "categoryId": "uuid", - "title": "第一章", - "parentId": null, - "sortOrder": 1 - } - ], - "error": null -} -``` - ---- - -#### GET /quiz/chapters/:id/questions - -获取章节下的题目(包含用户答题状态)。 - -**认证**: JWT - -**路径参数**: -- `id`: 章节 ID - -**响应**: ```json { "success": true, "data": { - "chapterId": "uuid", - "title": "第一章", - "questionsRequired": 5, - "passThreshold": 3, - "questions": [ - { - "id": "uuid", - "stem": { "text": "题目内容" }, - "contentType": "text", - "options": ["A", "B", "C", "D"], - "answered": false, - "isCorrect": null - } + "user": { + "id": "uuid", + "nickname": "知识探险家", + "avatarUrl": null, + "tier": "free", + "level": 1 + }, + "progress": { + "hearts": 5, + "maxHearts": 5, + "nextHeartRestoreAt": null, + "dailyAttemptsLeft": 5, + "dailyAttemptsMax": 5, + "nextAttemptResetAt": "2026-05-06T00:00:00.000Z", + "xp": 0, + "level": 1, + "xpToNextLevel": 400, + "streakDays": 0, + "checkInDays": 0, + "streakProtectedUntil": null, + "activeTrackId": null, + "isSubscribed": false + }, + "tracks": [], + "shopBenefits": [], + "subscription": { + "status": "none", + "tier": "free", + "expiresAt": null, + "autoRenew": false + } + }, + "error": null +} +``` + +#### GET /tracks + +认证:JWT + +响应: + +```json +{ + "success": true, + "data": [ + { + "id": "history", + "name": "历史", + "icon": "🏛", + "progress": 25, + "nodes": [ + { + "id": "uuid", + "title": "第一章", + "status": "current", + "reward": "+40 XP", + "questionCount": 4 + } + ] + } + ], + "error": null +} +``` + +`nodes[].status` 取值:`done`, `current`, `locked`, `chest`。 + +#### GET /tracks/:trackId + +认证:JWT +路径参数:`trackId` 可以是分类 `id` 或 `slug`。 + +响应:单个 `ThemeTrackDto`,不存在时 `data` 为 `null`。 + +#### GET /challenges/next + +认证:JWT + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `trackId` | string | 是 | 分类 `id` 或 `slug` | + +响应: + +```json +{ + "success": true, + "data": { + "challengeId": "question-uuid", + "trackId": "history", + "nodeId": "chapter-uuid", + "question": { + "id": "question-uuid", + "prompt": "题目文本", + "options": [ + { "id": "a", "text": "选项 A" }, + { "id": "b", "text": "选项 B" }, + { "id": "c", "text": "选项 C" } + ] + } + }, + "error": null +} +``` + +没有可用题目时 `data` 为 `null`。 + +#### POST /challenges/answer + +认证:JWT + +请求: + +```json +{ + "challengeId": "question-uuid", + "questionId": "question-uuid", + "selectedOptionId": "a", + "timeMs": 1500, + "comboCount": 0 +} +``` + +响应: + +```json +{ + "success": true, + "data": { + "challengeId": "question-uuid", + "answerState": "correct", + "correctOptionId": "a", + "xpDelta": 10, + "progress": { + "hearts": 5, + "dailyAttemptsLeft": 5, + "xp": 10, + "streakDays": 0 + }, + "knowledgeCard": { + "id": "card-uuid", + "title": "知识点摘要", + "summary": "知识点摘要", + "fact": "深入解析" + }, + "rewards": [ + { "type": "xp", "amount": 10, "title": "+10 XP" } ] }, "error": null } ``` ---- +`answerState` 取值:`correct`, `wrong`。 -#### POST /quiz/answer +#### GET /progress/summary -提交答案。 +认证:JWT -**认证**: JWT +响应:`ProgressSummaryDto`,字段同 `/app/bootstrap` 中的 `progress`。 + +#### PATCH /progress/preferences + +认证:JWT + +请求: -**请求体**: ```json { - "questionId": "uuid (必填)", - "selectedAnswer": "string (必填)", - "timeMs": 1500 + "activeTrackId": "history" } ``` -**响应**: -```json -{ - "success": true, - "data": { - "correct": true, - "correctAnswer": "B", - "xpEarned": 10, - "streakBonus": 0, - "chapterCompleted": false - }, - "error": null -} -``` +响应:更新后的 `ProgressSummaryDto`。 ---- +#### POST /progress/check-in -#### POST /quiz/rate +认证:JWT +请求:无 -评价题目质量。 +响应:更新后的 `ProgressSummaryDto`。 -**认证**: JWT +#### GET /leaderboards -**请求体**: -```json -{ - "questionId": "uuid (必填)", - "rating": "good | bad" -} -``` +认证:JWT -**响应**: -```json -{ - "success": true, - "data": null, - "error": null -} -``` +查询参数: ---- +| 参数 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `scope` | `region` 或 `topic` | `region` | 排行榜范围 | +| `trackId` | string | - | `scope=topic` 时可传 | +| `page` | number | 1 | 页码 | +| `limit` | number | 20 | 1-100 | -### 进度 +响应: -#### GET /progress/dashboard - -获取用户进度概览。 - -**认证**: JWT - -**响应**: -```json -{ - "success": true, - "data": { - "xpTotal": 150, - "streakDays": 3, - "heartsRemaining": 5, - "dailyXpEarned": 20, - "dailyXpGoal": 50, - "categoriesCompleted": 1, - "totalCategories": 5 - }, - "error": null -} -``` - ---- - -#### GET /progress/streak - -获取连胜信息。 - -**认证**: JWT - -**响应**: -```json -{ - "success": true, - "data": { - "currentStreak": 3, - "longestStreak": 7, - "lastActiveDate": "2026-04-10" - }, - "error": null -} -``` - ---- - -#### GET /progress/hearts - -获取红心信息。 - -**认证**: JWT - -**响应**: -```json -{ - "success": true, - "data": { - "remaining": 5, - "max": 5, - "nextRestoreAt": "2026-04-10T13:00:00.000Z" - }, - "error": null -} -``` - ---- - -#### POST /progress/hearts/restore - -恢复红心。 - -**认证**: JWT - -**请求体**: -```json -{ - "method": "ad | wait | upgrade" -} -``` - -**响应**: -```json -{ - "success": true, - "data": { - "remaining": 5, - "restored": 1 - }, - "error": null -} -``` - ---- - -#### GET /progress/chapters - -获取所有章节进度。 - -**认证**: JWT - -**响应**: -```json -{ - "success": true, - "data": [ - { - "chapterId": "uuid", - "title": "第一章", - "completedQuestions": 3, - "totalQuestions": 5, - "passed": false, - "passedAt": null - } - ], - "error": null -} -``` - ---- - -#### POST /feedback - -提交用户反馈。 - -**认证**: JWT - -**请求体**: -```json -{ - "content": "string (必填, 1-2000字符)", - "contact": "string (可选, 最多255字符)", - "pageContext": "string (可选, 最多200字符)" -} -``` - -**响应**: -```json -{ - "success": true, - "data": null, - "error": null -} -``` - ---- - -### 游戏化 - -#### GET /leaderboard - -获取排行榜。 - -**认证**: JWT - -**查询参数**: -- `tier`: "free" | "pro" | "proplus" (可选) -- `page`: 页码 (默认: 1) -- `limit`: 每页数量 (默认: 20) - -**响应**: ```json { "success": true, @@ -561,9 +397,11 @@ { "rank": 1, "userId": "uuid", - "nickname": "玩家昵称", - "avatarUrl": "头像URL", - "xpTotal": 5000 + "displayName": "玩家昵称", + "avatarUrl": null, + "xp": 5000, + "badge": "王者", + "isMe": false } ], "pagination": { @@ -575,162 +413,245 @@ } ``` ---- +#### GET /leaderboards/me -#### GET /leaderboard/me +认证:JWT -获取当前用户排名。 +查询参数同 `/leaderboards`。 -**认证**: JWT +响应: -**响应**: ```json { "success": true, "data": { "rank": 15, - "xpTotal": 1500 + "userId": "uuid", + "displayName": "我", + "avatarUrl": null, + "xp": 1500, + "badge": "新秀", + "isMe": true }, "error": null } ``` ---- +#### GET /shop -#### GET /achievements +认证:JWT -获取成就列表。 +响应: -**认证**: JWT - -**响应**: ```json { "success": true, "data": [ { - "id": "uuid", - "code": "first_win", - "name": "初出茅庐", - "description": "完成第一道题", - "iconUrl": "图标URL", - "unlocked": true, - "unlockedAt": "2026-04-10T10:00:00.000Z" + "id": "restore-hearts", + "type": "hearts", + "title": "恢复满血", + "description": "血量不足时继续挑战", + "enabled": true, + "requiresAd": true } ], "error": null } ``` ---- +说明:`requiresAd=true` 的权益应通过 `/rewards/ad-recovery/session` 和 `/rewards/ad-recovery/complete` 完成资格检查和结算。 -#### POST /achievements/check +#### GET /subscription -检查并解锁新成就。 +认证:JWT -**认证**: JWT +响应: -**响应**: -```json -{ - "success": true, - "data": { - "newlyUnlocked": [ - { - "id": "uuid", - "code": "streak_7", - "name": "连胜达人", - "description": "连续7天活跃" - } - ] - }, - "error": null -} -``` - ---- - -### 支付 - -#### POST /payment/verify-huawei - -验证华为 IAP 收据并激活订阅。 - -**认证**: JWT - -**请求体**: -```json -{ - "purchaseToken": "string (必填)", - "productId": "string (必填)", - "tier": "pro | proplus" -} -``` - -**响应**: ```json { "success": true, "data": { + "status": "active", "tier": "pro", - "provider": "huawei", - "active": true, - "expiresAt": "2026-05-10T00:00:00.000Z" - }, - "error": null -} -``` - ---- - -#### GET /payment/subscription - -获取当前订阅状态。 - -**认证**: JWT - -**响应**: -```json -{ - "success": true, - "data": { - "tier": "pro", - "provider": "huawei", - "active": true, - "expiresAt": "2026-05-10T00:00:00.000Z", + "expiresAt": "2026-06-05T00:00:00.000Z", "autoRenew": true }, "error": null } ``` ---- +`status` 取值:`none`, `active`, `expired`, `cancelled`。`tier` 取值:`free`, `pro`, `proplus`。 -## 管理端 API +#### POST /subscription/verify -### 管理端认证 +认证:JWT -#### POST /admin/auth/login +请求: -管理员用户名密码登录。 - -**认证**: 无 - -**请求体**: ```json { - "username": "string (必填)", - "password": "string (必填, 最少8字符)" + "platform": "huawei", + "purchaseToken": "purchase-token", + "productId": "duoqi_plus_monthly", + "tier": "pro" } ``` -**响应**: +响应:更新后的订阅 DTO。当前仅支持 `platform=huawei`,其他平台返回 `UNSUPPORTED_PLATFORM`。 + +### 激励广告恢复 + +#### POST /rewards/ad-recovery/session + +认证:JWT + +用途:展示广告前创建恢复会话,服务端返回会话 ID、广告位和资格状态。 + +请求: + +```json +{ + "type": "hearts", + "clientRequestId": "uuid-from-client", + "platform": "ios", + "adProvider": "mock" +} +``` + +`type` 取值:`hearts`, `bonusAttempts`, `streakProtection`。 +`platform` 取值:`ios`, `android`, `harmony`, `web`。 + +符合资格响应: + ```json { "success": true, "data": { - "accessToken": "jwt_token (1h有效)", - "refreshToken": "jwt_token (30d有效)", + "sessionId": "uuid", + "eligible": true, + "type": "hearts", + "adPlacementId": "duoqi_restore_hearts_ios", + "remainingToday": 2, + "expiresAt": "2026-05-05T12:30:00.000Z" + }, + "error": null +} +``` + +不符合资格响应: + +```json +{ + "success": true, + "data": { + "sessionId": null, + "eligible": false, + "reason": "daily_limit_reached", + "nextAvailableAt": "2026-05-06T00:00:00.000Z" + }, + "error": null +} +``` + +#### POST /rewards/ad-recovery/complete + +认证:JWT + +用途:广告 SDK 返回完整播放后提交凭证,由服务端幂等结算奖励。`mock` provider 用于测试;真实 provider 需要提交 `providerRewardToken`。 + +请求: + +```json +{ + "sessionId": "uuid", + "clientRequestId": "uuid-from-client", + "adProvider": "admob", + "providerRewardToken": "opaque-provider-token", + "completedAt": "2026-05-05T12:03:00.000Z" +} +``` + +成功响应: + +```json +{ + "success": true, + "data": { + "status": "completed", + "type": "hearts", + "reward": { + "heartsDelta": 3, + "dailyAttemptsDelta": 0, + "streakProtectionGranted": false + }, + "progress": { + "hearts": 5, + "maxHearts": 5, + "dailyAttemptsLeft": 2, + "dailyAttemptsMax": 5, + "streakDays": 21, + "streakProtectedUntil": null + }, + "limits": { + "remainingHeartsRecoveriesToday": 2, + "remainingAttemptRecoveriesToday": 3, + "nextStreakProtectionAvailableAt": "2026-05-12T00:00:00.000Z" + } + }, + "error": null +} +``` + +失败响应: + +```json +{ + "success": true, + "data": { + "status": "failed", + "reason": "provider_verification_failed", + "message": "广告未完整播放,未发放奖励。", + "progress": { + "hearts": 2, + "maxHearts": 5, + "dailyAttemptsLeft": 1, + "dailyAttemptsMax": 5 + } + }, + "error": null +} +``` + +`reason` 取值:`ad_not_completed`, `provider_verification_failed`, `session_expired`, `daily_limit_reached`, `cooldown_active`, `already_subscribed`, `invalid_type`。 + +## 管理端 API + +管理端路由统一带 `/v1/admin` 前缀。 + +### 管理端认证 + +#### POST /admin/login + +认证:无 + +请求: + +```json +{ + "username": "admin", + "password": "password123" +} +``` + +响应: + +```json +{ + "success": true, + "data": { + "accessToken": "jwt", + "refreshToken": "jwt", "admin": { "id": "uuid", "username": "admin", @@ -741,1003 +662,329 @@ } ``` -**错误 (401)**: +#### PUT /admin/change-password + +认证:Admin JWT + +请求: + ```json { - "success": false, - "data": null, - "error": { - "code": "UNAUTHORIZED", - "message": "Invalid username or password" - } + "currentPassword": "old-password", + "newPassword": "new-password" } ``` ---- +响应: -#### POST /admin/auth - -管理端 Token 认证(向后兼容,推荐使用 `/admin/auth/login`)。 - -**认证**: 无 - -**请求体**: -```json -{ - "token": "string (必填)" -} -``` - -**响应**: ```json { "success": true, "data": { - "authenticated": true + "message": "Password changed successfully" }, "error": null } ``` -**错误 (401)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "UNAUTHORIZED", - "message": "Invalid admin token" - } -} -``` - ---- - ### 管理员管理 -> 仅 super_admin 角色可执行写操作(POST、PUT、DELETE),读操作(GET)所有管理员均可访问。 - #### GET /admin/admins -获取管理员列表。 - -**认证**: Admin JWT - -**查询参数**: -- `page`: 页码 (默认: 1, 必须 ≥ 1) -- `limit`: 每页数量 (默认: 20, 范围: 1-50) -- `role`: "admin" | "super_admin" (可选,按角色筛选) -- `isActive`: 0 | 1 (可选,按状态筛选) - -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "username": "admin", - "role": "super_admin", - "isActive": 1, - "lastLoginAt": "2026-04-11T10:00:00.000Z", - "createdAt": "2026-04-01T00:00:00.000Z", - "updatedAt": "2026-04-11T10:00:00.000Z" - } - ], - "pagination": { - "total": 5, - "page": 1, - "limit": 20 - }, - "error": null -} -``` - ---- +认证:Admin JWT +查询参数:`page`, `limit`, `role`, `isActive`。 +响应:管理员数组 + `pagination`。 #### GET /admin/admins/:id -获取管理员详情。 - -**认证**: Admin JWT - -**路径参数**: -- `id`: 管理员 ID - -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "username": "admin", - "role": "super_admin", - "isActive": 1, - "lastLoginAt": "2026-04-11T10:00:00.000Z", - "createdAt": "2026-04-01T00:00:00.000Z", - "updatedAt": "2026-04-11T10:00:00.000Z" - }, - "error": null -} -``` - -**错误 (404)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "NOT_FOUND", - "message": "Admin user not found" - } -} -``` - ---- +认证:Admin JWT #### POST /admin/admins -创建新管理员(super_admin 专属)。 +认证:Admin JWT,且 `role=super_admin` -**认证**: Admin JWT (super_admin) +请求: -**请求体**: ```json { - "username": "string (必填, 3-50字符)", - "password": "string (必填, 8-128字符)", - "role": "admin | super_admin (必填)" + "username": "newadmin", + "password": "password123", + "role": "admin" } ``` -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "username": "newadmin", - "role": "admin", - "isActive": 1, - "lastLoginAt": null, - "createdAt": "2026-04-11T12:00:00.000Z", - "updatedAt": "2026-04-11T12:00:00.000Z", - "plainPassword": "随机生成的初始密码" - }, - "error": null -} -``` - -**错误 (403)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "FORBIDDEN", - "message": "Super admin privileges required" - } -} -``` - -**错误 (400)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "VALIDATION_ERROR", - "message": "Username \"admin\" already exists" - } -} -``` - ---- - #### PUT /admin/admins/:id -更新管理员信息(super_admin 专属)。 +认证:Admin JWT,且 `role=super_admin` -**认证**: Admin JWT (super_admin) +请求: -**路径参数**: -- `id`: 管理员 ID - -**请求体**: ```json { - "username": "string (可选, 3-50字符)", - "role": "admin | super_admin (可选)", - "isActive": 0 | 1 (可选)" + "username": "updated", + "role": "admin", + "isActive": 1 } ``` -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "username": "updated_username", - "role": "admin", - "isActive": 0, - "lastLoginAt": "2026-04-11T10:00:00.000Z", - "createdAt": "2026-04-01T00:00:00.000Z", - "updatedAt": "2026-04-11T12:00:00.000Z" - }, - "error": null -} -``` - -**安全规则**: -- 禁止删除或降级最后一个 super_admin -- 用户名必须唯一 - ---- - #### DELETE /admin/admins/:id -软删除管理员(super_admin 专属)。 - -**认证**: Admin JWT (super_admin) - -**路径参数**: -- `id`: 管理员 ID - -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "username": "deleted_admin", - "role": "admin", - "isActive": 0, - "lastLoginAt": "2026-04-11T10:00:00.000Z", - "createdAt": "2026-04-01T00:00:00.000Z", - "updatedAt": "2026-04-11T12:00:00.000Z" - }, - "error": null -} -``` - -**说明**: -- 软删除:将 `isActive` 设为 0,不删除记录 -- 禁止删除最后一个 super_admin - ---- +认证:Admin JWT,且 `role=super_admin`。软删除管理员。 #### POST /admin/admins/:id/reset-password -重置管理员密码(super_admin 专属)。 - -**认证**: Admin JWT (super_admin) - -**路径参数**: -- `id`: 管理员 ID - -**响应**: -```json -{ - "success": true, - "data": { - "adminId": "uuid", - "username": "admin", - "plainPassword": "新随机生成的12位密码" - }, - "error": null -} -``` - -**说明**: -- 生成 12 位随机密码,包含大小写字母、数字、符号 -- 明文密码仅在响应中返回一次,请妥善保存 -- 密码使用 BCrypt 哈希后存储到数据库 - ---- +认证:Admin JWT,且 `role=super_admin`。响应包含一次性明文密码 `plainPassword`。 ### 题目管理 #### GET /admin/questions -获取题目列表。 +认证:Admin JWT -**认证**: Admin Token - -**查询参数**: -- `page`: 页码 (默认: 1) -- `limit`: 每页数量 (默认: 20) -- `status`: draft | reviewing | published | archived (可选,按状态筛选) -- `categoryId`: 分类 ID (可选) -- `keyword`: 关键词搜索,匹配题干(stem)和选项(distractors)内容 (可选) -- `difficulty`: 难度值 1-5,精确匹配 (可选) -- `source`: system | ugc,按来源筛选 (可选) -- `sortBy`: 排序字段,createdAt | updatedAt | difficulty (默认: createdAt) -- `sortOrder`: asc | desc (默认: desc) - -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "stem": { "text": "题目内容" }, - "contentType": "text", - "correctAnswer": "B", - "distractors": ["A", "C", "D"], - "categoryId": "uuid", - "difficulty": 3, - "status": "published" - } - ], - "pagination": { - "total": 100, - "page": 1, - "limit": 20 - }, - "error": null -} -``` - ---- +查询参数:`page`, `limit`, `status`, `categoryId`, `keyword`, `difficulty`, `source`, `sortBy`, `sortOrder`。 +`sortBy` 取值:`createdAt`, `updatedAt`, `difficulty`。`sortOrder` 取值:`asc`, `desc`。 #### GET /admin/questions/:id -获取题目详情。 - -**认证**: Admin Token - -**路径参数**: -- `id`: 题目 ID - -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "stem": { "text": "题目内容" }, - "contentType": "text", - "correctAnswer": "B", - "distractors": ["A", "C", "D"], - "categoryId": "uuid", - "difficulty": 3, - "status": "published", - "knowledgeCard": { - "id": "uuid", - "summary": "知识点摘要", - "deepDive": "深入解析", - "sourceRef": "来源引用" - } - }, - "error": null -} -``` - ---- +认证:Admin JWT #### POST /admin/questions -创建新题目。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { "stem": { "text": "题目内容" }, - "contentType": "text | image | video | audio", - "correctAnswer": "B (必填)", - "distractors": ["A", "C", "D"], - "categoryId": "uuid (必填)", + "contentType": "text", + "correctAnswer": "正确答案", + "distractors": ["干扰项1", "干扰项2"], + "categoryId": "history", "difficulty": 3, "knowledgeCard": { - "summary": "知识点摘要 (必填)", + "summary": "知识点摘要", "deepDive": "深入解析", - "sourceRef": "来源引用" + "sourceRef": "来源" } } ``` -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "status": "draft" - }, - "error": null -} -``` - ---- +`contentType` 取值:`text`, `image`, `video`, `audio`。 #### PUT /admin/questions/:id -更新题目。 - -**认证**: Admin Token - -**路径参数**: -- `id`: 题目 ID - -**请求体**: -```json -{ - "stem": { "text": "题目内容" }, - "contentType": "text | image | video | audio", - "correctAnswer": "B", - "distractors": ["A", "C", "D"], - "categoryId": "uuid", - "difficulty": 3, - "status": "draft | reviewing | published | archived" -} -``` - ---- - -#### DELETE /admin/questions/:id - -归档题目。 - -**认证**: Admin Token - -**路径参数**: -- `id`: 题目 ID - -**响应**: -```json -{ - "success": true, - "data": null, - "error": null -} -``` - ---- +认证:Admin JWT。请求字段同创建接口,均可选,额外支持 `status`。 #### PATCH /admin/questions/:id/status -变更题目状态(带流转校验)。 +认证:Admin JWT -**认证**: Admin Token +请求: -**路径参数**: -- `id`: 题目 ID - -**请求体**: ```json { - "status": "draft | reviewing | published | archived (必填)" + "status": "published" } ``` -**允许的状态流转**: -| 当前状态 | 可变更到 | -|----------|----------| -| draft | reviewing, archived | -| reviewing | published, draft, archived | -| published | archived | -| archived | draft | +`status` 取值:`draft`, `reviewing`, `published`, `archived`。服务会校验状态流转。 -**响应**: -```json -{ - "success": true, - "data": { - "id": "uuid", - "stem": { "text": "题目内容" }, - "contentType": "text", - "correctAnswer": "B", - "distractors": ["A", "C", "D"], - "categoryId": "uuid", - "difficulty": 3, - "status": "published", - "knowledgeCard": { ... } - }, - "error": null -} -``` +#### DELETE /admin/questions/:id -**错误 (404)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "NOT_FOUND", - "message": "题目不存在" - } -} -``` - -**错误 (400)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "INVALID_STATUS_TRANSITION", - "message": "不允许从 published 变更为 reviewing" - } -} -``` - ---- +认证:Admin JWT。归档题目。 #### POST /admin/questions/batch-publish -批量发布题目(带状态流转校验,仅 reviewing 状态可发布)。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { - "ids": ["uuid1", "uuid2"] + "ids": ["uuid"] } ``` -**参数说明**: -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| ids | string[] | 是 | 题目 ID 数组,1-200 个,每个为合法 UUID | - -**响应**: -```json -{ - "success": true, - "data": { - "total": 3, - "succeeded": 2, - "failed": [ - { "id": "uuid3", "reason": "不允许从 draft 变更为 published" } - ] - }, - "error": null -} -``` - -**data 字段说明**: -| 字段 | 类型 | 说明 | -|------|------|------| -| total | number | 提交的 ID 总数 | -| succeeded | number | 成功更新的数量 | -| failed | array | 失败记录列表(包含 id 和 reason) | - ---- - #### POST /admin/questions/batch-archive -批量归档题目(带状态流转校验,draft/reviewing/published 状态可归档)。 - -**认证**: Admin Token - -**请求体**: -```json -{ - "ids": ["uuid1", "uuid2"] -} -``` - -**参数说明**: -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| ids | string[] | 是 | 题目 ID 数组,1-200 个,每个为合法 UUID | - -**响应**: 与 batch-publish 相同的 `BatchResult` 格式。 - ---- +认证:Admin JWT。请求同 `batch-publish`。 #### POST /admin/questions/batch-delete -批量删除题目(软删除,等同于批量归档)。 - -**认证**: Admin Token - -**请求体**: -```json -{ - "ids": ["uuid1", "uuid2"] -} -``` - -**参数说明**: -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| ids | string[] | 是 | 题目 ID 数组,1-200 个,每个为合法 UUID | - -**响应**: 与 batch-publish 相同的 `BatchResult` 格式。 - ---- +认证:Admin JWT。软删除,等同批量归档。 #### POST /admin/questions/import -批量导入题目(JSON 格式,全有或全无策略)。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { "questions": [ { "stem": { "text": "题目内容" }, - "contentType": "text | image | video | audio", - "correctAnswer": "正确答案 (必填)", + "contentType": "text", + "correctAnswer": "正确答案", "distractors": ["干扰项1", "干扰项2"], - "categoryId": "分类ID (必填)", + "categoryId": "history", "difficulty": 3, "knowledgeCard": { - "summary": "知识点摘要 (必填)", + "summary": "知识点摘要", "deepDive": "深入解析", - "sourceRef": "来源引用" + "sourceRef": "来源" } } ] } ``` -**参数说明**: -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| questions | array | 是 | 题目数组,1-200 条 | -| questions[].stem | object | 是 | 题干,至少包含 text 字段 | -| questions[].contentType | string | 是 | 内容类型:text/image/video/audio | -| questions[].correctAnswer | string | 是 | 正确答案,至少1字符 | -| questions[].distractors | string[] | 是 | 干扰项,至少2个 | -| questions[].categoryId | string | 是 | 所属分类 ID | -| questions[].difficulty | number | 否 | 难度 1-5 | -| questions[].knowledgeCard | object | 否 | 知识点卡片 | -| questions[].knowledgeCard.summary | string | 是* | 知识点摘要 | -| questions[].knowledgeCard.deepDive | string | 否 | 深入解析 | -| questions[].knowledgeCard.sourceRef | string | 否 | 来源引用 | - -**成功响应**: -```json -{ - "success": true, - "data": { - "total": 10, - "succeeded": 10, - "ids": ["uuid1", "uuid2", "..."] - }, - "error": null -} -``` - -**校验失败响应 (400)**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "VALIDATION_FAILED", - "message": "部分题目校验失败", - "details": [ - { "index": 2, "errors": ["distractors: 必须包含至少2个元素"] }, - { "index": 5, "errors": ["categoryId: 必填字段"] } - ] - } -} -``` - -**说明**: -- 导入的题目默认状态为 `draft` -- 全有或全无:任一条目校验失败则全部不导入 -- 所有条目先校验完毕,再统一报告错误,最后才执行事务插入 -- 校验包含 categoryId 外键存在性检查:不存在的分类 ID 会触发 `VALIDATION_FAILED` 错误 - ---- +单次 1-200 条,全有或全无。 #### POST /admin/questions/import-csv -批量导入题目(CSV 格式,全有或全无策略)。 +认证:Admin JWT +`Content-Type`: `text/plain` -**认证**: Admin Token +CSV 表头: -**Content-Type**: `text/plain` - -**请求体**: CSV 文本,首行为表头 - -**CSV 表头(固定列顺序)**: -``` -categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef -``` - -**CSV 示例**: ```csv categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef -history,text,1,秦始皇统一六国是在哪一年?,公元前221年,公元前206年,公元前256年,公元前230年,,,秦始皇嬴政于公元前221年完成统一,建立了中国历史上第一个大一统王朝,《史记·秦始皇本纪》 -history,text,2,被称为'诗仙'的唐代诗人是谁?,李白,杜甫,白居易,王维,,李白是唐代最伟大的浪漫主义诗人,, ``` -**列说明**: -| 列名 | 必填 | 说明 | -|------|------|------| -| categoryId | 是 | 分类 ID | -| contentType | 是 | text/image/video/audio | -| difficulty | 否 | 难度 1-5,留空则不设置 | -| stemText | 是 | 题目文本 | -| correctAnswer | 是 | 正确答案 | -| distractor1-5 | 至少填2个 | 干扰项,留空的列会被忽略 | -| cardSummary | 否 | 知识点摘要(填则整行知识卡片必填 summary) | -| cardDeepDive | 否 | 深入解析 | -| cardSourceRef | 否 | 来源引用 | - -**成功/失败响应**: 与 JSON 导入相同格式。 - -**说明**: -- CSV 字段可用双引号包裹,支持字段内逗号和换行 -- 字段内的双引号用 `""` 表示 -- categoryId 外键存在性校验与 JSON 导入一致 -- 单次导入上限 200 条,超出返回 `VALIDATION_ERROR` - -**额外错误**: -```json -{ - "success": false, - "data": null, - "error": { - "code": "CSV_PARSE_ERROR", - "message": "CSV 表头列数应为 13,实际 10" - } -} -``` - ---- +### 分类管理 #### GET /admin/categories -获取分类列表。 - -**认证**: Admin Token - -**查询参数**: -- `page`: 页码 (默认: 1, 必须 ≥ 1) -- `limit`: 每页数量 (默认: 20, 范围: 1-50) - -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "name": "历史", - "slug": "history", - "parentId": null, - "sortOrder": 1, - "questionCount": 120, - "status": "active" - } - ], - "pagination": { - "total": 45, - "page": 1, - "limit": 20 - }, - "error": null -} -``` - ---- +认证:Admin JWT +查询参数:`page`, `limit`。 +响应:分类数组 + `pagination`。 #### POST /admin/categories -创建分类。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { - "id": "uuid (必填)", - "name": "分类名称 (必填)", - "slug": "分类slug (必填)", - "parentId": "uuid", + "id": "history", + "name": "历史", + "slug": "history", + "parentId": null, "sortOrder": 1 } ``` ---- - #### PUT /admin/categories/:id -更新分类。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { - "name": "分类名称", - "slug": "分类slug", - "parentId": "uuid | null", + "name": "历史", + "slug": "history", + "parentId": null, "sortOrder": 1, - "status": "active | inactive" + "status": "active" } ``` ---- - #### DELETE /admin/categories/:id -归档分类。 - -**认证**: Admin Token - ---- +认证:Admin JWT。归档分类。 ### 知识点卡片 #### GET /admin/knowledge-cards -获取知识点卡片列表。 - -**认证**: Admin Token - -**查询参数**: -- `page`: 页码 (默认: 1) -- `limit`: 每页数量 (默认: 20) - ---- +认证:Admin JWT +查询参数:`page`, `limit`。 +响应:知识点卡片数组 + `pagination`。 #### GET /admin/knowledge-cards/by-question/:questionId -根据题目 ID 获取知识点卡片。 - -**认证**: Admin Token - ---- +认证:Admin JWT #### PUT /admin/knowledge-cards/:id -更新知识点卡片。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { - "summary": "摘要 (必填)", + "summary": "摘要", "deepDive": "深入解析", - "sourceRef": "来源引用" + "sourceRef": "来源" } ``` ---- +所有字段均可选,但至少应传一个需要更新的字段。 ### 技能树管理 #### GET /admin/skill-tree -获取章节列表。 - -**认证**: Admin Token - -**查询参数**: -- `categoryId`: 分类 ID (可选) - ---- +认证:Admin JWT +查询参数:`categoryId`。 #### POST /admin/skill-tree -创建章节。 +认证:Admin JWT -**认证**: Admin Token +请求: -**请求体**: ```json { - "categoryId": "uuid (必填)", - "title": "章节标题 (必填)", - "parentId": "uuid", + "categoryId": "history", + "title": "第一章", + "parentId": null, "sortOrder": 1, - "questionsRequired": 5, - "passThreshold": 3 + "questionsRequired": 4, + "passThreshold": 2 } ``` ---- - #### PUT /admin/skill-tree/:id -更新章节。 - -**认证**: Admin Token - -**请求体**: -```json -{ - "title": "章节标题", - "parentId": "uuid | null", - "sortOrder": 1, - "questionsRequired": 5, - "passThreshold": 3 -} -``` - ---- +认证:Admin JWT。请求字段同创建接口,均可选。 #### DELETE /admin/skill-tree/:id -删除章节。 - -**认证**: Admin Token - ---- +认证:Admin JWT。 ### 用户管理 #### GET /admin/users -获取用户列表。 - -**认证**: Admin Token - -**查询参数**: -- `page`: 页码 (默认: 1) -- `limit`: 每页数量 (默认: 20) -- `search`: 搜索关键词 (昵称/ID) - -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "nickname": "玩家昵称", - "avatarUrl": "头像URL", - "tier": "free", - "xpTotal": 150, - "streakDays": 3, - "banned": false - } - ], - "pagination": { ... }, - "error": null -} -``` - ---- +认证:Admin JWT +查询参数:`page`, `limit`, `search`。 +响应:用户数组 + `pagination`。 #### GET /admin/users/:id -获取用户详情。 - -**认证**: Admin Token - ---- +认证:Admin JWT #### PUT /admin/users/:id/ban -封禁用户。 - -**认证**: Admin Token - ---- +认证:Admin JWT #### PUT /admin/users/:id/unban -解封用户。 - -**认证**: Admin Token - ---- +认证:Admin JWT ### 统计数据 #### GET /admin/stats -获取仪表盘统计数据。 +认证:Admin JWT -**认证**: Admin Token +响应: -**响应**: ```json { "success": true, @@ -1752,97 +999,25 @@ history,text,2,被称为'诗仙'的唐代诗人是谁?,李白,杜甫,白居易 } ``` ---- - ### 反馈管理 #### GET /admin/feedback -获取用户反馈列表。 +认证:Admin JWT +查询参数:`page`, `limit`。 +响应:反馈数组 + `pagination`。 -**认证**: Admin Token - -**查询参数**: -- `page`: 页码 (默认: 1) -- `limit`: 每页数量 (默认: 20) - -**响应**: -```json -{ - "success": true, - "data": [ - { - "id": "uuid", - "userId": "uuid", - "content": "反馈内容", - "contact": "联系方式", - "pageContext": "页面上下文", - "createdAt": "2026-04-10T10:00:00.000Z" - } - ], - "pagination": { ... }, - "error": null -} -``` - ---- - -## 附录 - -### 错误代码 +## 错误代码 | 代码 | 说明 | |------|------| -| VALIDATION_ERROR | 请求参数验证失败 | -| VALIDATION_FAILED | 批量导入中部分题目校验失败 | -| CSV_PARSE_ERROR | CSV 解析失败(格式或表头不匹配) | -| UNAUTHORIZED | 未认证或认证失败 | -| FORBIDDEN | 权限不足(需要 super_admin) | -| NOT_FOUND | 资源不存在 | -| INVALID_STATUS_TRANSITION | 题目状态流转不合法 | -| INVALID_RECEIPT | 支付收据验证失败 | -| NOT_IMPLEMENTED | 功能未实现 | -| INTERNAL_ERROR | 服务器内部错误 | - -### 数据模型 - -#### User (用户) -```typescript -{ - id: string; // UUID - nickname: string | null; // 昵称 - avatarUrl: string | null; // 头像URL - tier: 'free' | 'pro' | 'proplus'; // 会员等级 - xpTotal: number; // 总经验值 - streakDays: number; // 连续天数 - heartsRemaining: number; // 剩余红心 - banned: boolean; // 是否封禁 -} -``` - -#### Question (题目) -```typescript -{ - id: string; // UUID - stem: Record; // 题目内容(支持多语言) - contentType: 'text' | 'image' | 'video' | 'audio'; - correctAnswer: string; // 正确答案 - distractors: string[]; // 干扰项 - categoryId: string; // 分类ID - difficulty: 1-5; // 难度等级 - status: 'draft' | 'reviewing' | 'published' | 'archived'; -} -``` - -#### Chapter (章节) -```typescript -{ - id: string; // UUID - categoryId: string; // 分类ID - title: string; // 章节标题 - parentId: string | null; // 父章节ID - sortOrder: number; // 排序 - questionsRequired: number; // 需要答题数 - passThreshold: number; // 通过阈值 -} -``` +| `VALIDATION_ERROR` | 请求参数验证失败 | +| `VALIDATION_FAILED` | 批量导入中部分题目校验失败 | +| `CSV_PARSE_ERROR` | CSV 解析失败 | +| `UNAUTHORIZED` | 未认证或认证失败 | +| `FORBIDDEN` | 权限不足 | +| `NOT_FOUND` | 资源不存在 | +| `INVALID_STATUS_TRANSITION` | 题目状态流转不合法 | +| `INVALID_RECEIPT` | 支付收据验证失败 | +| `UNSUPPORTED_PLATFORM` | 订阅平台暂不支持 | +| `INTERNAL_ERROR` | 服务器内部错误 | diff --git a/src/db/schema.ts b/src/db/schema.ts index 95f1ab0..edaf89f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -223,6 +223,35 @@ export const subscriptions = mysqlTable('subscriptions', { uniqueIndex('uk_subscription_user').on(table.userId), ]); +// ── Rewarded Ad Recovery Sessions ───────────────────────────────── + +export const adRecoverySessions = mysqlTable('ad_recovery_sessions', { + id: char('id', { length: 36 }).primaryKey(), + userId: char('user_id', { length: 36 }).notNull(), + type: mysqlEnum('type', ['hearts', 'bonusAttempts', 'streakProtection']).notNull(), + status: mysqlEnum('status', ['pending', 'settling', 'completed', 'failed', 'expired']).default('pending'), + clientRequestId: varchar('client_request_id', { length: 80 }).notNull(), + completeRequestId: varchar('complete_request_id', { length: 80 }), + platform: mysqlEnum('platform', ['ios', 'android', 'harmony', 'web']).notNull(), + adProvider: varchar('ad_provider', { length: 50 }).notNull(), + adPlacementId: varchar('ad_placement_id', { length: 120 }).notNull(), + providerRewardToken: varchar('provider_reward_token', { length: 500 }), + rewardSnapshot: json('reward_snapshot').$type>(), + progressBefore: json('progress_before').$type>(), + progressAfter: json('progress_after').$type>(), + failureReason: varchar('failure_reason', { length: 80 }), + providerError: varchar('provider_error', { length: 500 }), + duplicateCount: int('duplicate_count').default(0), + expiresAt: datetime('expires_at').notNull(), + completedAt: datetime('completed_at'), + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}, (table) => [ + uniqueIndex('uk_ad_recovery_user_client_request').on(table.userId, table.clientRequestId), + index('idx_ad_recovery_user_type_status_created').on(table.userId, table.type, table.status, table.createdAt), + foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), +]); + // ── Admin Audit Log ──────────────────────────────────────────────── export const adminAuditLog = mysqlTable('admin_audit_log', { diff --git a/src/index.ts b/src/index.ts index 09baf84..1fd1249 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { progressRoutes } from './routes/progress.js'; import { gamificationRoutes } from './routes/gamification.js'; import { paymentRoutes } from './routes/payment.js'; import { appApiRoutes } from './routes/app-api.js'; +import { rewardsRoutes } from './routes/rewards.js'; import { adminRoutes } from './routes/admin/index.js'; async function main(): Promise { @@ -67,6 +68,7 @@ async function main(): Promise { app.register(gamificationRoutes, { prefix: '/v1' }); app.register(paymentRoutes, { prefix: '/v1' }); app.register(appApiRoutes, { prefix: '/v1' }); + app.register(rewardsRoutes, { prefix: '/v1' }); // Admin routes: higher rate limit (100/min) app.register(adminRoutes, { prefix: '/v1/admin' }); diff --git a/src/routes/rewards.ts b/src/routes/rewards.ts new file mode 100644 index 0000000..84172db --- /dev/null +++ b/src/routes/rewards.ts @@ -0,0 +1,48 @@ +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + completeAdRecoverySession, + createAdRecoverySession, +} from '../services/rewards/ad-recovery-service.js'; + +const adRecoveryTypeSchema = z.enum(['hearts', 'bonusAttempts', 'streakProtection']); +const platformSchema = z.enum(['ios', 'android', 'harmony', 'web']); + +const createAdRecoverySessionSchema = z.object({ + type: adRecoveryTypeSchema, + clientRequestId: z.string().min(1).max(80), + platform: platformSchema, + adProvider: z.string().min(1).max(50), +}); + +const completeAdRecoverySessionSchema = z.object({ + sessionId: z.string().uuid(), + clientRequestId: z.string().min(1).max(80), + adProvider: z.string().min(1).max(50), + providerRewardToken: z.string().max(500).optional(), + completedAt: z.string().datetime(), +}); + +function getUserId(request: { user: unknown }): string { + return (request.user as { userId: string }).userId; +} + +function validationError(message: string | undefined) { + return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message } }; +} + +export async function rewardsRoutes(app: FastifyInstance): Promise { + app.post('/rewards/ad-recovery/session', async (request) => { + const parsed = createAdRecoverySessionSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await createAdRecoverySession(getUserId(request), parsed.data); + return { success: true, data, error: null }; + }); + + app.post('/rewards/ad-recovery/complete', async (request) => { + const parsed = completeAdRecoverySessionSchema.safeParse(request.body); + if (!parsed.success) return validationError(parsed.error.issues[0]?.message); + const data = await completeAdRecoverySession(getUserId(request), parsed.data); + return { success: true, data, error: null }; + }); +} diff --git a/src/services/rewards/ad-recovery-service.ts b/src/services/rewards/ad-recovery-service.ts new file mode 100644 index 0000000..c5eca19 --- /dev/null +++ b/src/services/rewards/ad-recovery-service.ts @@ -0,0 +1,475 @@ +import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; +import { db } from '../../db/client.js'; +import { adRecoverySessions, users } from '../../db/schema.js'; +import { MAX_FREE_HEARTS } from '../progress/hearts-service.js'; +import { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js'; +import { getSubscriptionStatus } from '../payment/subscription-service.js'; +import { freezeStreak } from '../progress/streak-service.js'; +import type { ProgressSummaryDto } from '../../types/app-api.js'; + +export type AdRecoveryType = 'hearts' | 'bonusAttempts' | 'streakProtection'; +export type AdRecoveryPlatform = 'ios' | 'android' | 'harmony' | 'web'; + +export type AdRecoveryReason = + | 'ad_not_completed' + | 'provider_verification_failed' + | 'session_expired' + | 'daily_limit_reached' + | 'cooldown_active' + | 'already_subscribed' + | 'invalid_type'; + +export interface CreateAdRecoverySessionInput { + type: AdRecoveryType; + clientRequestId: string; + platform: AdRecoveryPlatform; + adProvider: string; +} + +export interface CompleteAdRecoveryInput { + sessionId: string; + clientRequestId: string; + adProvider: string; + providerRewardToken?: string; + completedAt: string; +} + +export interface AdRecoverySessionResponse { + sessionId: string | null; + eligible: boolean; + type?: AdRecoveryType; + adPlacementId?: string; + remainingToday?: number; + expiresAt?: string; + reason?: AdRecoveryReason; + nextAvailableAt?: string; +} + +export interface AdRecoveryCompleteResponse { + status: 'completed' | 'failed'; + type?: AdRecoveryType; + reward?: { + heartsDelta: number; + dailyAttemptsDelta: number; + streakProtectionGranted: boolean; + }; + reason?: AdRecoveryReason; + message?: string; + progress: ProgressSummaryDto; + limits?: AdRecoveryLimits; +} + +export interface AdRecoveryLimits { + remainingHeartsRecoveriesToday: number; + remainingAttemptRecoveriesToday: number; + nextStreakProtectionAvailableAt: string | null; +} + +const FREE_DAILY_RECOVERY_LIMIT = 3; +const SESSION_TTL_MS = 30 * 60 * 1000; +const STREAK_PROTECTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; +const TRUSTED_TEST_PROVIDERS = new Set(['mock']); + +type SessionRecord = typeof adRecoverySessions.$inferSelect; +type UserTier = 'free' | 'pro' | 'proplus'; + +function now(): Date { + return new Date(); +} + +function todayStart(): Date { + const date = now(); + date.setUTCHours(0, 0, 0, 0); + return date; +} + +function tomorrowStart(): Date { + const date = todayStart(); + date.setUTCDate(date.getUTCDate() + 1); + return date; +} + +function toIso(value: Date | string | null): string | null { + if (!value) return null; + return typeof value === 'string' ? new Date(value).toISOString() : value.toISOString(); +} + +function toDate(value: Date | string): Date { + return typeof value === 'string' ? new Date(value) : value; +} + +function placementId(type: AdRecoveryType, platform: AdRecoveryPlatform): string { + const suffixByType: Record = { + hearts: 'restore_hearts', + bonusAttempts: 'restore_bonus_attempts', + streakProtection: 'streak_protection', + }; + return `duoqi_${suffixByType[type]}_${platform}`; +} + +function isSubscribed(tier: UserTier | null | undefined, subscription: Awaited>): boolean { + return tier === 'pro' || tier === 'proplus' || (subscription.status === 'active' && subscription.tier !== 'free'); +} + +async function getUserTier(userId: string): Promise { + const [user] = await db + .select({ tier: users.tier }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return (user?.tier ?? 'free') as UserTier; +} + +async function completedCountToday(userId: string, type: Extract): Promise { + const rows = await db + .select({ id: adRecoverySessions.id }) + .from(adRecoverySessions) + .where(and( + eq(adRecoverySessions.userId, userId), + eq(adRecoverySessions.type, type), + eq(adRecoverySessions.status, 'completed'), + gte(adRecoverySessions.completedAt, todayStart()), + lt(adRecoverySessions.completedAt, tomorrowStart()), + )); + return rows.length; +} + +async function getLastStreakProtection(userId: string): Promise { + const [session] = await db + .select() + .from(adRecoverySessions) + .where(and( + eq(adRecoverySessions.userId, userId), + eq(adRecoverySessions.type, 'streakProtection'), + eq(adRecoverySessions.status, 'completed'), + )) + .orderBy(desc(adRecoverySessions.completedAt)) + .limit(1); + return session ?? null; +} + +async function getLimits(userId: string): Promise { + const [heartCount, attemptCount, lastStreak] = await Promise.all([ + completedCountToday(userId, 'hearts'), + completedCountToday(userId, 'bonusAttempts'), + getLastStreakProtection(userId), + ]); + const lastCompletedAt = lastStreak?.completedAt ? toDate(lastStreak.completedAt) : null; + const nextStreakProtectionAvailableAt = lastCompletedAt + ? new Date(lastCompletedAt.getTime() + STREAK_PROTECTION_COOLDOWN_MS).toISOString() + : null; + + return { + remainingHeartsRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - heartCount), + remainingAttemptRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - attemptCount), + nextStreakProtectionAvailableAt, + }; +} + +async function checkEligibility(userId: string, type: AdRecoveryType): Promise<{ eligible: true; remainingToday?: number } | { eligible: false; reason: AdRecoveryReason; nextAvailableAt?: string }> { + const [tier, subscription, progress, limits] = await Promise.all([ + getUserTier(userId), + getSubscriptionStatus(userId), + getProgressSummary(userId), + getLimits(userId), + ]); + + if (isSubscribed(tier, subscription)) { + return { eligible: false, reason: 'already_subscribed' }; + } + + if (type === 'hearts') { + if (limits.remainingHeartsRecoveriesToday <= 0) { + return { eligible: false, reason: 'daily_limit_reached', nextAvailableAt: tomorrowStart().toISOString() }; + } + if (progress.hearts >= progress.maxHearts) { + return { eligible: false, reason: 'invalid_type' }; + } + return { eligible: true, remainingToday: limits.remainingHeartsRecoveriesToday - 1 }; + } + + if (type === 'bonusAttempts') { + if (limits.remainingAttemptRecoveriesToday <= 0) { + return { eligible: false, reason: 'daily_limit_reached', nextAvailableAt: tomorrowStart().toISOString() }; + } + if (progress.dailyAttemptsLeft >= progress.dailyAttemptsMax) { + return { eligible: false, reason: 'invalid_type' }; + } + return { eligible: true, remainingToday: limits.remainingAttemptRecoveriesToday - 1 }; + } + + const nextAvailableAt = limits.nextStreakProtectionAvailableAt; + if (nextAvailableAt && new Date(nextAvailableAt).getTime() > Date.now()) { + return { eligible: false, reason: 'cooldown_active', nextAvailableAt }; + } + + return { eligible: true }; +} + +function sessionToCreateResponse(session: SessionRecord): AdRecoverySessionResponse { + return { + sessionId: session.id, + eligible: true, + type: session.type, + adPlacementId: session.adPlacementId, + expiresAt: toIso(session.expiresAt) ?? undefined, + }; +} + +function completionFailed(reason: AdRecoveryReason, progress: ProgressSummaryDto, message = '广告未完整播放,未发放奖励。'): AdRecoveryCompleteResponse { + return { status: 'failed', reason, message, progress }; +} + +function providerCompletionVerified(adProvider: string, providerRewardToken?: string): boolean { + return TRUSTED_TEST_PROVIDERS.has(adProvider) || Boolean(providerRewardToken?.trim()); +} + +function affectedRows(result: unknown): number | null { + if (Array.isArray(result)) return affectedRows(result[0]); + if (result && typeof result === 'object') { + const value = 'affectedRows' in result + ? (result as { affectedRows?: unknown }).affectedRows + : (result as { rowsAffected?: unknown }).rowsAffected; + return typeof value === 'number' ? value : null; + } + return null; +} + +async function markFailed(session: SessionRecord, reason: AdRecoveryReason, progress: ProgressSummaryDto, providerError?: string): Promise { + await db + .update(adRecoverySessions) + .set({ + status: reason === 'session_expired' ? 'expired' : 'failed', + failureReason: reason, + providerError, + progressAfter: progress as unknown as Record, + }) + .where(eq(adRecoverySessions.id, session.id)); + return completionFailed(reason, progress, reason === 'session_expired' ? '广告会话已过期,请重新加载广告。' : undefined); +} + +async function getSession(userId: string, sessionId: string): Promise { + const [session] = await db + .select() + .from(adRecoverySessions) + .where(and( + eq(adRecoverySessions.id, sessionId), + eq(adRecoverySessions.userId, userId), + )) + .limit(1); + return session ?? null; +} + +async function completedResponse(userId: string, session: SessionRecord): Promise { + return { + status: 'completed', + type: session.type, + reward: session.rewardSnapshot as NonNullable, + progress: session.progressAfter as unknown as ProgressSummaryDto, + limits: await getLimits(userId), + }; +} + +async function applyReward(userId: string, type: AdRecoveryType, before: ProgressSummaryDto): Promise<{ reward: NonNullable; progress: ProgressSummaryDto }> { + if (type === 'hearts') { + await db + .update(users) + .set({ + heartsRemaining: MAX_FREE_HEARTS, + heartsLastRestore: sql`NOW()`, + }) + .where(eq(users.id, userId)); + const progress = await getProgressSummary(userId); + return { + reward: { + heartsDelta: Math.max(0, progress.hearts - before.hearts), + dailyAttemptsDelta: 0, + streakProtectionGranted: false, + }, + progress, + }; + } + + if (type === 'bonusAttempts') { + const attempts = await getDailyAttempts(userId); + const next = Math.min(attempts.left + 1, attempts.max); + await db + .update(users) + .set({ + dailyAttemptsLeft: next, + dailyAttemptsDate: sql`CAST(${new Date().toISOString().slice(0, 10)} AS DATE)`, + }) + .where(eq(users.id, userId)); + const progress = await getProgressSummary(userId); + return { + reward: { + heartsDelta: 0, + dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft), + streakProtectionGranted: false, + }, + progress, + }; + } + + await freezeStreak(userId); + const protectedUntil = new Date(); + protectedUntil.setUTCHours(24, 0, 0, 0); + await db + .update(users) + .set({ streakProtectedUntil: protectedUntil }) + .where(eq(users.id, userId)); + const progress = await getProgressSummary(userId); + return { + reward: { + heartsDelta: 0, + dailyAttemptsDelta: 0, + streakProtectionGranted: true, + }, + progress, + }; +} + +export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise { + const [existing] = await db + .select() + .from(adRecoverySessions) + .where(and( + eq(adRecoverySessions.userId, userId), + eq(adRecoverySessions.clientRequestId, input.clientRequestId), + )) + .limit(1); + + if (existing) { + await db + .update(adRecoverySessions) + .set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` }) + .where(eq(adRecoverySessions.id, existing.id)); + return sessionToCreateResponse(existing); + } + + const eligibility = await checkEligibility(userId, input.type); + if (!eligibility.eligible) { + return { + sessionId: null, + eligible: false, + reason: eligibility.reason, + nextAvailableAt: eligibility.nextAvailableAt, + }; + } + + const id = uuid(); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + const adPlacementId = placementId(input.type, input.platform); + + await db.insert(adRecoverySessions).values({ + id, + userId, + type: input.type, + status: 'pending', + clientRequestId: input.clientRequestId, + platform: input.platform, + adProvider: input.adProvider, + adPlacementId, + expiresAt, + }); + + return { + sessionId: id, + eligible: true, + type: input.type, + adPlacementId, + remainingToday: eligibility.remainingToday, + expiresAt: expiresAt.toISOString(), + }; +} + +export async function completeAdRecoverySession(userId: string, input: CompleteAdRecoveryInput): Promise { + const session = await getSession(userId, input.sessionId); + const progress = await getProgressSummary(userId); + if (!session) { + return completionFailed('invalid_type', progress, '广告恢复会话不存在。'); + } + + if (session.status === 'completed' && session.progressAfter && session.rewardSnapshot) { + await db + .update(adRecoverySessions) + .set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` }) + .where(eq(adRecoverySessions.id, session.id)); + return completedResponse(userId, session); + } + + if (session.status === 'failed' || session.status === 'expired') { + await db + .update(adRecoverySessions) + .set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` }) + .where(eq(adRecoverySessions.id, session.id)); + return completionFailed((session.failureReason as AdRecoveryReason | null) ?? 'ad_not_completed', progress); + } + + if (session.clientRequestId !== input.clientRequestId) { + return markFailed(session, 'provider_verification_failed', progress, 'clientRequestId mismatch'); + } + + if (session.adProvider !== input.adProvider) { + return markFailed(session, 'provider_verification_failed', progress, 'adProvider mismatch'); + } + + if (toDate(session.expiresAt).getTime() < Date.now()) { + return markFailed(session, 'session_expired', progress); + } + + if (!providerCompletionVerified(input.adProvider, input.providerRewardToken)) { + return markFailed(session, 'provider_verification_failed', progress, 'missing provider reward token'); + } + + const claimResult = await db + .update(adRecoverySessions) + .set({ + status: 'settling', + completeRequestId: input.clientRequestId, + providerRewardToken: input.providerRewardToken ?? null, + }) + .where(and( + eq(adRecoverySessions.id, session.id), + eq(adRecoverySessions.status, 'pending'), + )); + const claimedRows = affectedRows(claimResult); + if (claimedRows === 0) { + const current = await getSession(userId, input.sessionId); + if (current?.status === 'completed' && current.progressAfter && current.rewardSnapshot) { + return completedResponse(userId, current); + } + return completionFailed('ad_not_completed', progress, '广告恢复会话正在结算,请稍后重试。'); + } + + const eligibility = await checkEligibility(userId, session.type); + if (!eligibility.eligible) { + return markFailed(session, eligibility.reason, progress); + } + + const before = progress; + const { reward, progress: after } = await applyReward(userId, session.type, before); + const completedAt = new Date(input.completedAt); + const safeCompletedAt = Number.isNaN(completedAt.getTime()) ? new Date() : completedAt; + + await db + .update(adRecoverySessions) + .set({ + status: 'completed', + completeRequestId: input.clientRequestId, + rewardSnapshot: reward, + progressBefore: before as unknown as Record, + progressAfter: after as unknown as Record, + completedAt: safeCompletedAt, + }) + .where(eq(adRecoverySessions.id, session.id)); + + return { + status: 'completed', + type: session.type, + reward, + progress: after, + limits: await getLimits(userId), + }; +}